diff --git a/.gitignore b/.gitignore index ca2a5b7af..cadb36cea 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,21 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 6fdaab48b..d80ce75c6 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY= TELEBIRR_PUBLIC_KEY= TELEBIRR_INSECURE_TLS=false +# Public origin of the freight customer portal. Password-reset links sent to +# customers are built against this — it must be browser-reachable. +FREIGHT_PORTAL_URL=http://localhost:5173 + # Portal pages the payment provider redirects the browser to after payment. # Point these at the freight portal's public payment result routes. PAYMENT_RETURN_URL=http://localhost:5173/payment/success diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bca74475e..66909a037 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -14,6 +14,7 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", + "seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts", "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 72a5952d7..9270dd0a1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -39,10 +39,12 @@ import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; +import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; +import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; @@ -59,6 +61,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; @@ -74,8 +77,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { VerifaydaModule } from './modules/verifayda/verifayda.module'; -import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -101,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware"; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], + load: [ + appConfig, + databaseConfig, + telebirrConfig, + rabbitmqConfig, + faydaConfig, + ], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -159,10 +168,12 @@ import { LoggerMiddleware } from "./logger.middleware"; BillingModule, NotificationsModule, NotificationInboxModule, + SupportChatModule, FileUploadSettingsModule, DropdownSettingsModule, ContractTemplatesModule, OtpModule, + HealthModule, RuleEngineModule, BackofficeModule, DemoPermissionsModule, @@ -196,6 +207,7 @@ import { LoggerMiddleware } from "./logger.middleware"; EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, @@ -217,10 +229,11 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + // private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, @@ -251,13 +264,17 @@ export class AppModule implements OnApplicationBootstrap { // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); + // await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, + // Dire Dawa). Idempotent; creates no yards. + await this.yardFacilitiesSeeder.run(); + // Dropdown settings are not seeded on boot; run them with // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). @@ -266,6 +283,9 @@ export class AppModule implements OnApplicationBootstrap { // await this.demoUsersSeeder.run(); // await this.freightStaffUsersSeeder.run(); // await this.pricingDataSeeder.run(); + // IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so + // it will not recognise a hand-created Indode warehouse and will seed a second + // one alongside it. Only enable it against an Indode that has no warehouse. // await this.indodeFacilitySeeder.run(); // await this.batch14TestDataSeeder.run(); // await this.batch5TestDataSeeder.run(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 9769a7f18..a412bf990 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) => ), ); +/** + * Read-only reference data (yard dropdowns, search filters): any signed-in + * staff. Menu/page visibility stays permission-gated in the frontend — this + * only lets forms populate their lookups. + */ +export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); + export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const TrainSchedulingView = () => @@ -22,9 +29,22 @@ export const TrainSchedulingView = () => export const TrainSchedulingManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.manage); -export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); +/** + * Fleet guards take an optional granular per-resource key (locomotives:create, + * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain + * valid as a one-of fallback so existing role grants keep working. + */ +export const FleetView = (granular?: string) => + BookingStaff( + granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + ); -export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +export const FleetManage = (granular?: string) => + BookingStaff( + granular + ? [granular, FREIGHT_PERMS.fleet.manage] + : FREIGHT_PERMS.fleet.manage, + ); /** Requester creates a wagon-transfer request (count-only, no wagon picks). */ export const WagonTransferRequest = () => diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts new file mode 100644 index 000000000..d0d01535a --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -0,0 +1,51 @@ +import { + assertCanApproveContractStep, + canEditContractStep, +} from './freight-permission.util'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +// The document-edit gate (canEditContractStep) must be STRICT: only the approver +// whose turn it is may edit. This is the fix for a previous approver keeping the +// "Edit contract articles" button after acting, because the approve gate lets +// through anyone holding any contract-approve permission. +describe('canEditContractStep (strict per-step edit gate)', () => { + const director = { + employee: { position: { positionType: { key: '-marketing-director-' } } }, + }; + // A line staff who already approved their own step but still holds a + // contract-approve permission — the exact actor that leaked edit rights. + const officerWithApprovePerm = { + employee: { + position: { + positionType: { key: '-marketing-officer-' }, + permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }], + }, + }, + }; + const superAdmin = { roles: [{ key: 'super_admin' }] }; + + it('lets the step’s own approver edit', () => { + expect(canEditContractStep(director, '-marketing-director-')).toBe(true); + }); + + it('lets an approval admin edit any step', () => { + expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true); + }); + + it('does NOT let a different approver edit just because they hold an approve permission', () => { + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); + + it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => { + // The approve gate passes the officer via the any-permission blanket… + expect(() => + assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'), + ).not.toThrow(); + // …but the edit gate does not — that divergence IS the fix. + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 69596c21d..429c910d3 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin'; const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; type PermissionLike = { key?: string }; +type PositionTypeLike = { key?: string }; type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: | { - position?: { permissions?: PermissionLike[] }; + position?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }; delegatedPositions?: { permissions?: PermissionLike[] }[]; } | { - positions?: { permissions?: PermissionLike[] }[]; + positions?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }[]; }[] | null; }; @@ -90,12 +97,138 @@ export function assertFreightPermission( throw new ForbiddenException(`Missing permission: ${permissionKey}`); } +/** + * The caller's IAM position-type keys (`iam.position_types.key`). A position + * type is the platform's notion of a role — it is what carries permissions via + * `iam.position_type_permissions` — and it is the vocabulary contract approval + * chains are configured in. + * + * Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is + * an object on some tokens and an array on others. + * + * Note delegated positions carry no `positionType` in the token, so a delegate + * is not reachable here — they authorize through the permission arm of + * `assertCanApproveContractStep` instead. + */ +export function collectPositionTypeKeys( + user: MeLikeUser | null | undefined, +): string[] { + const employee = user?.employee; + if (!employee) return []; + + const keys = new Set(); + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + if (pos.positionType?.key) keys.add(pos.positionType.key); + } + } + return [...keys]; + } + + if (employee.position?.positionType?.key) { + keys.add(employee.position.positionType.key); + } + return [...keys]; +} + +/** + * Legacy chain roles predate position types. Historical `approval_rules` and + * in-flight `contract_approval_steps` rows still carry them, so map each to the + * position types that stand in for it. Without this, an approver holding a + * modern position type could not action an older step. + */ +const LEGACY_ROLE_POSITION_TYPES: Record = { + LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'], + DIRECTOR: ['director', 'operation-director'], + CEO: ['chief', 'deputy'], +}; + const APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, CEO: FREIGHT_PERMS.bookings.approveCeo, }; +const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, +}; + +const ANY_CONTRACT_APPROVE_PERMISSION = [ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, +]; + +/** + * May this caller action a contract approval step requiring `requiredRole`? + * + * `requiredRole` is an `iam.position_types.key` for chains configured by an + * admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows. + * A caller passes when any of these hold: + * + * - they are a super/organization admin (blanket bypass); + * - their position type matches the step, directly or via a legacy alias; + * - they hold the approve permission the legacy role maps to; + * - they hold any contract approve permission — this covers delegates (whose + * position type is absent from the token) and staff whose IAM position has + * no position type assigned yet. + */ +export function assertCanApproveContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + if (legacyPermission && hasFreightPermission(user, legacyPermission)) return; + + if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) { + return; + } + + throw new ForbiddenException( + `You are not the required approver (${requiredRole}) for this step.`, + ); +} + +/** + * Strict "is it exactly this caller's turn?" test — mirrors the backoffice + * `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep} + * EXCEPT the blanket "holds any contract-approve permission" fallback is + * dropped: a line-staff holding `approveLineStaff` must NOT read as the director + * for a director step. Used to gate contract-document editing so approval hands + * edit rights to the NEXT approver only — a previous approver who already acted + * (but still holds an approve permission) loses the edit button, as required. + * + * (Kept separate from the approve/reject gate, which keeps the blanket fallback + * so delegates whose token omits a position type can still action their step.) + */ +export function canEditContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): boolean { + if (isFreightApprovalAdmin(user)) return true; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return true; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return true; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission)); +} + export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts new file mode 100644 index 000000000..5cae30302 --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -0,0 +1,13 @@ +/** + * Goods Received Note number: `GRN---`. + * + * Shared so a GRN raised at a load/unload facility is indistinguishable from one + * raised in a warehouse — the two live in different tables + * (facility_handling_events vs warehouse_inventory), and a second generator would + * eventually let their formats drift apart. + */ +export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; +} diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts new file mode 100644 index 000000000..8c3310321 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts @@ -0,0 +1,52 @@ +import { usesEdrMileService } from './mile-haulage.util'; + +/** + * The road legs are chosen on the contract and copied onto the booking. EDR + * haulage and a customer's own truck are alternatives, so this one answer gates + * both sides — the customer-truck guard and the mile-queue guard. + */ +describe('usesEdrMileService', () => { + const booking = (over: Partial[0]> = {}) => ({ + tradeDirection: 'IMPORT', + firstMile: null, + lastMile: null, + ...over, + }); + + it('an import that chose delivery uses EDR haulage', () => { + expect(usesEdrMileService(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(true); + }); + + it('an import that chose nothing does not', () => { + expect(usesEdrMileService(booking())).toBe(false); + }); + + it('ignores the pickup address on an import — collection is the export leg', () => { + expect(usesEdrMileService(booking({ firstMile: 'Modjo' }))).toBe(false); + }); + + it('an export that chose collection uses EDR haulage', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })), + ).toBe(true); + }); + + it('ignores the delivery address on an export — delivery is the import leg', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })), + ).toBe(false); + }); + + it('a domestic booking counts either leg', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })), + ).toBe(true); + expect( + usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), + ).toBe(true); + }); + + it('treats a whitespace-only address as no choice', () => { + expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.ts b/apps/edr-freight-api/src/common/mile-haulage.util.ts new file mode 100644 index 000000000..1ca83d06e --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-haulage.util.ts @@ -0,0 +1,49 @@ +/** The booking fields that decide who hauls the road legs. */ +export interface MileHaulageRow { + tradeDirection: string | null; + /** `first_mile_pickup_address` — set when the customer asked EDR to collect. */ + firstMile: string | null; + /** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */ + lastMile: string | null; +} + +/** + * Whether the customer bought the EDR road leg that matters for their direction: + * delivery at the end of an import, collection at the start of an export. A + * DOMESTIC booking can use either, so either one counts. + * + * The address is the signal because it is the only per-booking record of the + * choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be + * used — every service type ships with both set to true, so reading them would + * mean every booking uses EDR haulage and none could ever self-haul. + */ +export function usesEdrMileService(booking: MileHaulageRow): boolean { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + switch (booking.tradeDirection) { + case 'IMPORT': + return hasLastMile; + case 'EXPORT': + return hasFirstMile; + default: + return hasFirstMile || hasLastMile; + } +} + +/** + * EDR haulage and a customer's own truck are alternatives, never both. Whichever + * side is being set up, it has to reject the other — a guard on only one side + * lets the two paths open on the same booking, each unaware of the other. + */ +export const SELF_HAUL_CONFLICT_MESSAGE = + 'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.'; + +export const EDR_HAULAGE_CONFLICT_MESSAGE = + 'Customer truck assignment is only allowed when first/last mile delivery is not selected'; + +/** + * The road legs are chosen on the contract. A booking whose contract bought + * neither has no business in the first/last-mile queues at all. + */ +export const NO_MILE_SERVICE_MESSAGE = + 'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.'; diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts new file mode 100644 index 000000000..fbb3a436a --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -0,0 +1,159 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + remainingBulkTons, +} from './truck-load.util'; + +/** + * One physical rule, shared by customer self-haul and EDR last-mile. It used to + * be written out three times (addTruck, updateTruck, departTruck) plus a fourth + * in LastMileService. + */ +describe('assertTruckLoad', () => { + const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111']; + + it('accepts two 20ft containers on one truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '20ft'], + }), + ).not.toThrow(); + }); + + it('accepts a single 40ft container', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['40ft'], + }), + ).not.toThrow(); + }); + + it('rejects a 40ft sharing the truck — it fills the bed', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['40ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects more than two containers', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'], + bookingContainers: booking, + sizes: ['20ft', '20ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container that is not on the booking', () => { + expect(() => + assertTruckLoad({ + containers: ['ZZZZ9999999'], + bookingContainers: booking, + sizes: ['20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container already riding another truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['20ft'], + assignedElsewhere: ['ABCD1234567'], + }), + ).toThrow(ConflictException); + }); + + it('skips membership checks when the booking has no containers (bulk)', () => { + expect(() => + assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }), + ).not.toThrow(); + }); + + it('still caps the count when the booking has no containers', () => { + expect(() => + assertTruckLoad({ + containers: ['A', 'B', 'C'], + bookingContainers: [], + sizes: [], + }), + ).toThrow(BadRequestException); + }); +}); + +describe('assertBulkTonnageRemains', () => { + it('allows another truck while tonnage is left', () => { + expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow(); + }); + + it('rejects a truck once the booking is fully hauled', () => { + expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException); + }); + + it('does not cap a booking with no declared weight', () => { + // Nothing to draw down against — capping here would block every truck. + expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow(); + }); +}); + +describe('remainingBulkTons', () => { + const dataSourceReturning = (totalTons: string, hauledTons: string) => + ({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never; + + it('counts trucks from both haulage paths against the declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1'); + + expect(result).toEqual({ + totalTons: 100, + hauledTons: 60, + remainingTons: 40, + complete: false, + }); + }); + + it('is complete once everything is hauled', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('never reports negative tonnage when trucks overshoot', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('is not complete for a booking with no declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1'); + + expect(result.complete).toBe(false); + }); +}); + +describe('assertTruckCountWithinContainers', () => { + it('allows one truck per container', () => { + expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow(); + }); + + it('rejects more trucks than containers', () => { + expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException); + }); + + it('does not cap a bulk booking, which has no container count', () => { + expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts new file mode 100644 index 000000000..b65bc12fb --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -0,0 +1,148 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +/** Two 20ft containers fit a truck bed; one 40ft fills it. */ +export const MAX_CONTAINERS_PER_TRUCK = 2; + +/** + * What one truck is being asked to carry, and the booking context to judge it + * against. `sizes` are the container_size labels of `containers`, in any order — + * only whether a 40ft is present matters. + */ +export interface TruckLoadCheck { + containers: string[]; + /** Every container number on the booking. Empty means nothing to validate against. */ + bookingContainers: string[]; + sizes: string[]; + /** Containers already riding another truck on this booking. */ + assignedElsewhere?: string[]; +} + +/** + * The physical rule for loading one truck, shared by both haulage paths. + * + * A customer's own truck and an EDR last-mile truck obey the same physics, but + * the rule was implemented twice — once in CustomerTruckService, once in + * LastMileService — along with a byte-identical container-size query. Two copies + * of one rule drift, and that is exactly how the self-haul guard ended up + * enforced on one side only. + */ +export function assertTruckLoad({ + containers, + bookingContainers, + sizes, + assignedElsewhere = [], +}: TruckLoadCheck): void { + if (containers.length > MAX_CONTAINERS_PER_TRUCK) { + throw new BadRequestException( + `A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`, + ); + } + + // With no container list on the booking there is nothing to check membership + // against — bulk bookings take this path. + if (!bookingContainers.length) return; + + for (const number of containers) { + if (!bookingContainers.includes(number)) { + throw new BadRequestException( + `Container ${number} is not one of this booking's containers`, + ); + } + if (assignedElsewhere.includes(number)) { + throw new ConflictException(`Container ${number} is already loaded onto another truck`); + } + } + + // A 40ft fills the bed, so it travels alone. + if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } +} + +/** Never put more trucks on a booking than it has containers to fill them. */ +export function assertTruckCountWithinContainers( + truckCount: number, + bookingContainerCount: number, +): void { + if (bookingContainerCount > 0 && truckCount > bookingContainerCount) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`, + ); + } +} + +/** + * How much of a bulk booking is still to be hauled. Counts trucks from BOTH + * haulage paths — a booking uses one or the other, and the rule ("trucks until + * no tonnage is left") is the same either way, so a single sum keeps them from + * disagreeing. + * + * Only departed trucks count: tonnage is known once the truck is weighed out. + */ +export async function remainingBulkTons( + dataSource: DataSource, + bookingId: string, +): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> { + const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> = + await dataSource.query( + `SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons", + COALESCE(( + SELECT SUM(va.net_weight_tons) + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm + ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + WHERE lm.booking_id = b.id + AND va.deleted_at IS NULL + AND va.departed_at IS NOT NULL + ), 0) + + COALESCE(( + SELECT SUM(a.net_weight_tons) + FROM freight.customer_truck_assignments a + WHERE a.booking_id = b.id + AND a.deleted_at IS NULL + AND a.departed_at IS NOT NULL + ), 0) AS "hauledTons" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + const totalTons = Number(row?.totalTons ?? 0); + const hauledTons = Number(row?.hauledTons ?? 0); + const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000); + return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 }; +} + +/** A fully-hauled bulk booking has nothing left for another truck to carry. */ +export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void { + if (totalTons > 0 && remainingTons <= 0) { + throw new BadRequestException( + 'This bulk booking is fully hauled — no tonnage left to assign trucks for', + ); + } +} + +/** + * container_size labels for the given container numbers on a booking. Shared so + * the two haulage paths read sizes the same way. + */ +export async function bookingContainerSizes( + dataSource: DataSource, + bookingId: string, + numbers: string[], +): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((row) => (row.size ?? '').trim()); +} diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts new file mode 100644 index 000000000..dd22f3c37 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts @@ -0,0 +1,72 @@ +import { validate } from 'class-validator'; +import { IsISO8601, IsOptional } from 'class-validator'; + +import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator'; + +class Subject { + @IsOptional() + @IsISO8601() + @IsNotBackdated() + occurredAt?: string; +} + +const subjectWith = (occurredAt?: string) => { + const subject = new Subject(); + subject.occurredAt = occurredAt; + return subject; +}; + +const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt)); + +const backdatedErrors = (errors: Awaited>) => + errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated')); + +describe('IsNotBackdated', () => { + it('rejects a timestamp from the past', async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const errors = await errorsFor(yesterday); + + expect(backdatedErrors(errors)).toHaveLength(1); + expect(errors[0].constraints?.IsNotBackdated).toBe( + 'occurredAt cannot be backdated — it must be now or later', + ); + }); + + it('accepts now', async () => { + const errors = await errorsFor(new Date().toISOString()); + + expect(errors).toHaveLength(0); + }); + + it('accepts a value stale only by transit and clock skew', async () => { + // What an honest caller sends: "now" as of when the request was built. + const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString(); + + const errors = await errorsFor(almostNow); + + expect(errors).toHaveLength(0); + }); + + it('rejects a value staler than the skew allowance', async () => { + const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString(); + + const errors = await errorsFor(tooStale); + + expect(backdatedErrors(errors)).toHaveLength(1); + }); + + it('ignores an absent value so @IsOptional decides', async () => { + const errors = await errorsFor(undefined); + + expect(errors).toHaveLength(0); + }); + + it('leaves an unparseable value to the format validator', async () => { + const errors = await errorsFor('not-a-date'); + + // Reported as a format problem, not as a backdate. + expect(backdatedErrors(errors)).toHaveLength(0); + expect(errors[0].constraints).toHaveProperty('isIso8601'); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts new file mode 100644 index 000000000..7a767e7f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts @@ -0,0 +1,56 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * A caller may not stamp an event as having happened before now. + * + * A request cannot reach the server at the instant it was built, and a caller's + * clock is not the server's, so a timestamp that honestly means "now" always + * arrives a little stale. Comparing straight against `Date.now()` would reject + * it. The skew allowance below is what makes an honest "now" pass — it is not a + * window for backdating, and it is deliberately far too small to reach any + * earlier event worth backdating to. + */ +export const CLOCK_SKEW_TOLERANCE_MS = 60_000; + +@ValidatorConstraint({ name: 'IsNotBackdated', async: false }) +export class IsNotBackdatedConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + // Absence is not this validator's business; pair with @IsOptional. + if (value === undefined || value === null || value === '') return true; + const parsed = new Date(value as string | Date); + // An unparseable value is a format error — let @IsISO8601/@IsDateString own + // that message rather than reporting it as a backdate. + if (Number.isNaN(parsed.getTime())) return true; + const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS; + return parsed.getTime() >= Date.now() - toleranceMs; + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} cannot be backdated — it must be now or later`; + } +} + +/** + * Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}. + * Pass a different tolerance only with a reason. + */ +export function IsNotBackdated( + toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS, + validationOptions?: ValidationOptions, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [toleranceMs], + validator: IsNotBackdatedConstraint, + }); + }; +} diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts new file mode 100644 index 000000000..cf6ca7a6e --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts @@ -0,0 +1,54 @@ +import { + validate, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { IsTin, normalizeTin } from './is-tin.validator'; + +class Required { + @IsString() + @IsNotEmpty() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin!: string; +} + +class Optional { + @IsOptional() + @IsString() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin?: string; +} + +async function errs(cls: any, tin: any) { + const o = new cls(); + o.tin = tin; + return (await validate(o)).length; +} + +describe('IsTin', () => { + it('accepts a real 10-digit TIN', async () => { + expect(await errs(Required, '0012345678')).toBe(0); + }); + + it.each([ + ['letters', 'ABCDEFGHIJ'], + ['symbols', '!!!!!!!!!!'], + ['too short', '123'], + ['too long', '12345678901'], + ['draft TIN', 'D123456789'], + ['spaced', '012 345678'], + ])('rejects %s', async (_label, value) => { + expect(await errs(Required, value)).toBeGreaterThan(0); + }); + + it('rejects empty on the required DTO but allows omission on the optional one', async () => { + expect(await errs(Required, '')).toBeGreaterThan(0); + expect(await errs(Optional, undefined)).toBe(0); + }); + + it('normalizes messy input', () => { + expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678'); + expect(normalizeTin('')).toBe(''); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts new file mode 100644 index 000000000..9396a884d --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts @@ -0,0 +1,54 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** An Ethiopian TIN is exactly 10 digits. */ +export const TIN_REGEX = /^\d{10}$/; + +/** + * Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by + * CompaniesService.generateDraftTin(), because the column is NOT NULL + unique. + * Those never travel through a DTO, so this constraint deliberately rejects them + * — a "D…" value arriving on a request body is client-supplied and invalid. + */ +@ValidatorConstraint({ name: 'IsTin', async: false }) +export class IsTinConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. + if (value === undefined || value === null || value === '') return true; + if (typeof value !== 'string') return false; + return TIN_REGEX.test(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be exactly 10 digits`; + } +} + +/** Class-validator decorator enforcing the 10-digit TIN format. */ +export function IsTin(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsTinConstraint, + }); + }; +} + +/** + * Strip everything that isn't a digit and cap at 10 characters. Tolerant — + * never throws; returns the value unchanged when empty/nullish. + */ +export function normalizeTin( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined || value === null || value === '') return value; + return value.replace(/\D/g, '').slice(0, 10); +} diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index e493cc393..050b07145 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -9,6 +9,14 @@ export default registerAs("app", () => ({ env: process.env.NODE_ENV ?? "development", port: parseInt(process.env.PORT ?? "3001", 10), apiPrefix: "api", + /** + * Public origin of the freight customer portal. Password-reset links mailed + * or SMS'd to customers are built against this, so it must be the address the + * customer's browser can actually reach — not an internal service name. + */ + portalBaseUrl: ( + process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173" + ).replace(/\/+$/, ""), trainScheduling: { maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 6aacab16c..92bef9dd3 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -13,6 +13,9 @@ export interface RenderedClause { /** A dynamic article ready for the Handlebars template. */ export interface RenderedArticle { number: number; + /** Stable article id from the template (e.g. "pricing") — lets the layout + * inject the live rate schedule table under the pricing article. */ + id: string; title: string; /** Set (instead of clauses) when the body is a single plain paragraph. */ paragraph?: string; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index a298b8dcb..363008660 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -18,6 +18,7 @@ import { ContractDynamicTemplateView, ContractViewModel, } from './contract-view-model.builder'; +import { RateSchedule } from './contract-rate-schedule.builder'; /** * Signature row for the contract PDF. Mirrors the booking builder's @@ -135,6 +136,7 @@ export class ContractDocumentViewModelBuilder { } const pricing = this.buildPricing(contract); + const rateSchedule = this.buildRateSchedule(pricing); const signatures = await this.loadSignatures(contractId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -177,6 +179,7 @@ export class ContractDocumentViewModelBuilder { }, schedule: this.buildSchedule(contract), pricing: pricing as unknown as ContractViewModel['pricing'], + rateSchedule, // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], @@ -230,6 +233,40 @@ export class ContractDocumentViewModelBuilder { }; } + /** + * A rate schedule for the contract PDF, sourced from the contract's own frozen + * unit rates (its agreed lane prices) rather than the global rate config — a + * signed contract must show the prices it was signed on. Rendered as freight + * lanes labelled with the contract's primary origin → destination route. + */ + private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule { + const route = `${pricing.originLabel} → ${pricing.destinationLabel}`; + const freightLanes = pricing.unitRates.map((line) => ({ + route, + cargo: line.label, + currency: line.currency, + amount: this.formatAmount(line.unitPrice), + unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`, + })); + + return { + freightLanes, + additionalServices: [], + surcharges: [], + isEmpty: freightLanes.length === 0, + currencyLabel: pricing.currency, + }; + } + + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { const firstRoute = this.firstRoute(contract); const cargoScope = (contract.cargoScope ?? [])[0]; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 49fb3416a..765c41142 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { originLabel: 'Nagad', destinationLabel: 'Galaan Multipurpose Port', } as unknown as ContractViewModel['pricing'], + rateSchedule: { + freightLanes: [ + { route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' }, + ], + additionalServices: [ + { route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' }, + ], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', order: 1, }, + { + id: 'pricing', + title: 'Contract Price and Payment Terms', + body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.', + order: 2, + }, { id: 'duration', title: 'Duration', body: 'Valid until August 31, {{contractYear}}.', - order: 2, + order: 3, }, ], }, @@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { expect(html).toContain('#1b9e7a'); }); + it('renders the live rate schedule lane under the pricing article', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Rate Schedule'); + // Base freight lane pulled from the rate config + expect(html).toContain('Nagad → Galaan Multipurpose Port'); + expect(html).toContain('USD 100 per wagon'); + // Additional-service group + expect(html).toContain('First-mile pickup by truck'); + }); + it('keeps the generic layout when no dynamic template is attached', () => { const view = dynamicView(); delete view.dynamicTemplate; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts new file mode 100644 index 000000000..af9b9428c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@nestjs/common'; + +import { RatesService } from '../modules/rule-engine/services/rates.service'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { + ContractDirection, + ContractFreight, +} from './contract-template.types'; + +/** One priced line in the contract's rate schedule. */ +export interface RateScheduleRow { + /** "Negad → Mojo Dry Port" for base freight, service name otherwise. */ + route: string; + /** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */ + cargo: string; + currency: string; + /** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */ + amount: string; + /** Human unit, e.g. "per container", "per wagon", "per ton". */ + unit: string; +} + +/** + * The origin → destination rate schedule shown in a generated contract's + * pricing article. Grouped so the reader sees rail freight lanes first, then + * pickup/delivery legs, then trigger-based surcharges and demurrage. + */ +export interface RateSchedule { + /** Base rail freight lanes matching this contract's direction + freight. */ + freightLanes: RateScheduleRow[]; + /** First-mile / last-mile truck legs (route-agnostic). */ + additionalServices: RateScheduleRow[]; + /** Hazard, reefer, overweight, demurrage, customs, etc. */ + surcharges: RateScheduleRow[]; + /** True when every group is empty — the template falls back to prose. */ + isEmpty: boolean; + /** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */ + currencyLabel: string; +} + +const UNIT_LABELS: Record = { + PER_WAGON: 'per wagon', + PER_TON: 'per ton', + PER_CONTAINER: 'per container', + PER_KM: 'per km', + PER_INVOICE: 'per invoice', + FLAT: 'flat', +}; + +const SERVICE_ROUTE_LABELS: Partial> = { + FIRST_MILE: 'First-mile pickup by truck', + LAST_MILE: 'Last-mile delivery by truck', +}; + +/** Friendly wording for the trigger-based charges shown in the surcharge group. */ +const TRIGGER_ROUTE_LABELS: Partial> = { + HAZARDOUS: 'Hazardous cargo surcharge', + OVERWEIGHT: 'Overweight surcharge', + REEFER: 'Reefer (refrigerated) surcharge', + WITH_RETURN: 'Empty-container return service', + SHIPPING_LINE: 'Shipping line handling', + CONSOLIDATION: 'Container consolidation (extra document)', + LASHING: 'Cargo lashing and securing', + CANCELLATION: 'Booking cancellation fee', + DEMURRAGE: 'Demurrage / wagon detention', + PIL_EXTRA_FEE: 'PIL shipping line extra fee', + CUSTOMS_CLEARANCE: 'Customs clearance service', +}; + +@Injectable() +export class ContractRateScheduleBuilder { + constructor(private readonly ratesService: RatesService) {} + + /** + * Build the rate schedule for a contract of the given direction + freight. + * Base-freight lanes are filtered to the matching trade direction / freight + * kind so an import container contract shows import container lanes only; + * additional services and surcharges are route-agnostic and always shown. + */ + async build( + direction: ContractDirection, + freight: ContractFreight, + ): Promise { + const rates = await this.ratesService.findLiveRatesDetailed(); + + const freightLanes: RateScheduleRow[] = []; + const additionalServices: RateScheduleRow[] = []; + const surcharges: RateScheduleRow[] = []; + + for (const rate of rates) { + if (this.isBaseFreight(rate)) { + if (this.baseFreightMatches(rate, direction, freight)) { + freightLanes.push(this.laneRow(rate)); + } + continue; + } + + if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') { + additionalServices.push(this.serviceRow(rate)); + continue; + } + + // Everything left is a trigger-based charge (surcharge / demurrage / customs). + surcharges.push(this.surchargeRow(rate)); + } + + const currencyLabel = this.currencyLabel([ + ...freightLanes, + ...additionalServices, + ...surcharges, + ]); + + return { + freightLanes, + additionalServices, + surcharges, + isEmpty: + freightLanes.length === 0 && + additionalServices.length === 0 && + surcharges.length === 0, + currencyLabel, + }; + } + + private isBaseFreight(rate: Rate): boolean { + return ( + rate.trigger === 'ALWAYS' && + (rate.appliesTo === 'BULK' || + rate.appliesTo === 'CONTAINER' || + rate.appliesTo === 'INTERCITY') + ); + } + + private baseFreightMatches( + rate: Rate, + direction: ContractDirection, + freight: ContractFreight, + ): boolean { + // Domestic contracts price off intercity rates; the freight kind is carried + // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). + if (direction === 'DOM') { + if (rate.appliesTo !== 'INTERCITY') return false; + return freight === 'BULK' + ? rate.rateType === 'INTERCITY_BULK' + : rate.rateType === 'INTERCITY_CONTAINER'; + } + + // Import / export price off BULK or CONTAINER rates matching the direction. + const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER'; + if (rate.appliesTo !== wantAppliesTo) return false; + const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT'; + return rate.tradeDirection === wantDirection; + } + + private laneRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + return { + route: `${origin} → ${destination}`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private serviceRow(rate: Rate): RateScheduleRow { + return { + route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private surchargeRow(rate: Rate): RateScheduleRow { + return { + route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + /** The type a rate is scoped to (container/cargo), or a dash when unscoped. */ + private cargoLabel(rate: Rate): string { + return ( + rate.containerType?.label ?? + rate.containerType?.code ?? + rate.cargoType?.cargoTypeName ?? + '—' + ); + } + + private unitLabel(unit: Rate['rateUnit']): string { + return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' '); + } + + /** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */ + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + + private currencyLabel(rows: RateScheduleRow[]): string { + const seen: string[] = []; + for (const row of rows) { + if (!seen.includes(row.currency)) seen.push(row.currency); + } + return seen.join(', ') || 'USD'; + } + + private titleCase(value: string): string { + return value + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index a66a516c0..b0fc4c8ef 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -58,6 +58,15 @@ describe('ContractRendererService', () => { destinationLabel: 'Modjo', containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], }, + rateSchedule: { + freightLanes: [ + { route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' }, + ], + additionalServices: [], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: true, canSignStaff: false, diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index 7b3301c87..b1b02c62f 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((article, index) => ({ number: index + 1, + id: article.id, title: interpolateTemplateText(article.title, view), ...parseArticleBody(interpolateTemplateText(article.body, view)), })); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index d90b8e709..8b8b92f09 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -7,6 +7,7 @@ import { ContractSignerRole, } from '../modules/bookings/entities/booking-contract-signature.entity'; import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; @@ -74,6 +75,12 @@ export interface ContractViewModel { lastMileDeliveryAddress: string; }; pricing: PricingSchedule; + /** + * The live origin → destination rate schedule (base freight lanes + services + * + surcharges) matching this contract's direction and freight kind. Drives + * the pricing article's rate table so the contract mirrors the rate config. + */ + rateSchedule: RateSchedule; signatures: ContractSignatureView[]; canSignCustomer: boolean; canSignStaff: boolean; @@ -89,6 +96,7 @@ export class ContractViewModelBuilder { private readonly bookingsRepository: BookingsRepository, private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, + private readonly rateScheduleBuilder: ContractRateScheduleBuilder, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -101,6 +109,10 @@ export class ContractViewModelBuilder { booking.contractTemplateKey ?? this.templateResolver.resolve(booking); const template = getTemplateMeta(templateKey); const pricing = await this.pricingBuilder.build(booking); + const rateSchedule = await this.rateScheduleBuilder.build( + template.direction, + template.freight, + ); const signatures = await this.loadSignatures(bookingId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -143,6 +155,7 @@ export class ContractViewModelBuilder { }, schedule: this.buildSchedule(booking), pricing, + rateSchedule, signatures, canSignCustomer: booking.status === 'CONTRACT_READY' && !hasCustomer, diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index 64319612a..eec54fa79 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,25 +25,9 @@

Equipment return: {{pricing.equipmentReturn}}

{{/if}} - {{#if pricing.unitRates}} -

Unit Rate Schedule

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting - totals are determined per shipment at booking time; no total contract value is fixed at this stage. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{#unless rateSchedule.isEmpty}} +

Rate Schedule

+ {{> rate_schedule}} {{else}}

Charges

@@ -76,7 +60,7 @@
- {{/if}} + {{/unless}}

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index bec620655..4bdc9a24a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -20,5 +20,9 @@ {{/each}} {{/if}} + {{#if (eq id "pricing")}} +

Rate Schedule

+ {{> rate_schedule}} + {{/if}} {{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs new file mode 100644 index 000000000..f0cb0fa7d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs @@ -0,0 +1,55 @@ +{{#if rateSchedule.isEmpty}} +

+ No published rate schedule is currently on file for this corridor. Applicable charges will be quoted + by the Service Provider per shipment in accordance with the prevailing EDR tariff. +

+{{else}} +

+ The charges below are the current published railway tariff for this contract's trade direction and + freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting + totals are determined per shipment at booking time. +

+ + + + + + + + + + {{#if rateSchedule.freightLanes.length}} + + {{#each rateSchedule.freightLanes}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.additionalServices.length}} + + {{#each rateSchedule.additionalServices}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.surcharges.length}} + + {{#each rateSchedule.surcharges}} + + + + + + {{/each}} + {{/if}} + +
Route / ServiceCargo / EquipmentUnit price
Railway Freight — Origin → Destination
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Additional Services
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Surcharges, Demurrage & Fees
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
+{{/if}} diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 4ba35ea3b..0e06d9f7b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -134,26 +134,8 @@ - {{#if pricing.unitRates.length}} -

Agreed Unit Rates

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and resulting - totals are determined per shipment at booking time. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
- {{/if}} +

Published Rate Schedule

+ {{> rate_schedule}} {{!-- ────────────────────────── Signatures ───────────────────────────── --}} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts index 717a48217..a651b8f60 100644 --- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter // train_set_wagons null their link, wagon_movements cascade. await queryRunner.query(`DELETE FROM freight.wagons;`); - // Wagon.wagonNumber declares `unique: true`, but some environments never got - // the constraint. Repair it here — the table is empty at this point, so the - // index build cannot fail on pre-existing duplicates. - await queryRunner.query(` - CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key - ON freight.wagons (wagon_number); - `); + // Deliberately does NOT create a unique index on wagon_number. It once did, + // to satisfy an ON CONFLICT clause that no longer exists (the DELETE above + // makes collisions impossible). Recreating the plain index here would undo + // WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL + // unique index so soft-deleted wagons stop reserving their number — this + // seeder is run directly by scripts/seed-edr-wagons.ts, which would + // otherwise resurrect the plain index on an already-migrated database. for (const row of FLEET) { if (row.end - row.start + 1 !== row.count) { diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts new file mode 100644 index 000000000..7e12efdbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Stand the whole wagon fleet in Doraleh. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with a NULL yard — so this must stay later in timestamp order. + * + * A wagon with no yard cannot be coupled to a train (the train builder only + * offers AVAILABLE wagons standing in the train's own yard), which left the + * seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs + * originate from. + * + * The yard is created when absent: environments disagree about which yards + * exist, so this cannot assume one is there. + */ +const YARD_CODE = 'DORALEH'; + +export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface { + name = 'SeedWagonYardDoraleh2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Ensure the yard exists and is usable. Deliberately does NOT overwrite an + // existing label/country — a deployment that already calls this yard + // something else keeps its own naming. + await queryRunner.query( + ` + INSERT INTO freight.yards (code, label, country, is_active, display_order) + VALUES ($1, 'Doraleh', 'Djibouti', true, 12) + ON CONFLICT (code) DO UPDATE SET + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [YARD_CODE], + ); + + const [yard] = await queryRunner.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [YARD_CODE], + ); + + if (!yard?.id) { + throw new Error(`yard_missing:${YARD_CODE}`); + } + + // Whole fleet — a wagon already coupled to a built train follows the train, + // so leave those where they stand. + await queryRunner.query( + ` + UPDATE freight.wagons + SET current_yard_id = $1::uuid, + updated_at = now() + WHERE train_id IS NULL; + `, + [yard.id], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Back to the state SeedEdrWagonFleetErNumbering leaves them in. + await queryRunner.query(` + UPDATE freight.wagons + SET current_yard_id = NULL + WHERE train_id IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts new file mode 100644 index 000000000..620eebc14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its + * destination yard, but only some yards have the equipment to do it. EDR's + * load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad — + * and the set grows, so it must be data, not a constant. + * + * `yards.has_facility` marks a yard as a load/unload point; `yard_facilities` + * holds what that facility can do. Only a facility with `has_warehouse` (Indode + * today) stores cargo, and therefore accrues storage/demurrage — the rest just + * move it on and off the train. + * + * `facility_handling_events` records each load/unload and carries its GRN. + * warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so + * a facility with no warehouse could never have a row. `inventory_id` links to the + * storage record when the facility does have a warehouse. + */ +export class YardFacilities2290000000000 implements MigrationInterface { + name = 'YardFacilities2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yards + ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_facilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + has_warehouse boolean NOT NULL DEFAULT false, + equipment_notes text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + // One facility record per yard. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard" + ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.facility_handling_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid NULL REFERENCES freight.train_schedules(id), + event_type varchar(10) NOT NULL, + grn_number varchar(60) NULL, + quantity numeric(14, 3) NULL, + weight_tons numeric(14, 3) NULL, + inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id), + performed_by varchar(120) NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking" + ON freight.facility_handling_events (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard" + ON freight.facility_handling_events (yard_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn" + ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`); + await queryRunner.query(` + ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair for environments missing the GPS tracking tables. + * + * AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but + * some databases have it RECORDED in public.migrations without the tables ever + * landing. TypeORM never re-runs a recorded migration, so those environments + * stay broken through any number of restarts — the GT06 listener accepts tracker + * packets on its TCP port regardless of schema state and fails per packet with + * `relation "freight.gps_devices" does not exist`, dropping position fixes. + * + * This re-issues the same DDL under a new name so it is applied afresh. Every + * statement is IF NOT EXISTS, so it is a no-op where the tables already exist + * and safe on every environment. + * + * Kept byte-identical to the original DDL on purpose: this must converge on the + * schema the entities expect, not a variant of it. + */ +export class RepairGpsTrackingTables2300000000000 implements MigrationInterface { + name = "RepairGpsTrackingTables2300000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(): Promise { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} diff --git a/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts new file mode 100644 index 000000000..17db8a4e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Customer-support chat. A `support_conversations` row is the single ongoing + * thread with a company; `support_messages` are its text messages. There is no + * lifecycle column — a thread is opened by whichever side speaks first and + * stays open. Enum-like columns are varchar (no PG enum churn). + * + * The unique index on `company_id` is load-bearing, not just an optimization: + * the get-or-create path depends on it to settle concurrent first-messages. + * It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block + * a fresh one. + */ +export class CreateSupportChat2310000000000 implements MigrationInterface { + name = "CreateSupportChat2310000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL, + company_name varchar(200), + created_by_user_id uuid, + last_message_at timestamptz, + last_message_preview varchar(280), + last_message_author_role varchar(12), + customer_last_read_at timestamptz, + agent_last_read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY" + ON freight.support_conversations (company_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG" + ON freight.support_conversations (last_message_at) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL, + author_user_id uuid NOT NULL, + author_role varchar(12) NOT NULL, + author_name varchar(200), + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED" + ON freight.support_messages (conversation_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_conversations`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts new file mode 100644 index 000000000..8e693cf2a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts @@ -0,0 +1,140 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope base rail freight to a route (origin yard → destination yard). + * + * Until now a base-freight rate was keyed by direction + container/bulk scope + * only, so "container import" cost the same whether the box was railed to Dire + * Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which + * is what the business actually sells: `container import, Djibouti → Dire Dawa, + * 500 USD`. + * + * Existing base-freight rates predate the yard pair and cannot be backfilled — + * there is no way to know which route each was meant for. They are retired + * (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot + * and rate_change_requests hold FKs to them (RESTRICT) and those rows are price + * history. Retiring drops them out of pricing and the admin UI just the same; + * the yard-scoped replacements must be re-entered. + * + * Surcharges, first-mile and last-mile rates are untouched: they are not + * route-scoped and keep NULL yards. + */ +export class AddRateYardScope2320000000000 implements MigrationInterface { + name = 'AddRateYardScope2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Yard columns + FKs ────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL, + ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL; + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_origin_yard_id" + FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_destination_yard_id" + FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id); + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`, + ); + + // ── 2. Retire route-less base freight ────────────────────────────────── + // Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE + // RESTRICT and those snapshots are what past bookings were charged. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'ALWAYS' + AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'); + `); + + // ── 3. Route is part of a rate's identity ────────────────────────────── + // Two rates may now share rateType + scope + unit as long as they price + // different legs, so the yard pair joins the uniqueness tuple. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + // ── 4. Base freight must carry a route; nothing else may ─────────────── + // Retired rows are exempt — they are the route-less rates step 2 just + // superseded, and they must stay readable for snapshot history. + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The retired rates are not un-superseded: which route each belonged to was + // never recorded, so reviving them would restore rates that price the wrong + // legs. Down only reverses the schema. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP COLUMN IF EXISTS destination_yard_id, + DROP COLUMN IF EXISTS origin_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts b/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts new file mode 100644 index 000000000..1f383f1da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Let a support message carry files instead of text. + * + * No new table: chat attachments reuse the polymorphic `freight.files` record + * with `resource = 'support_message'` and `resource_id = `, the same + * way bookings/contracts/companies already store theirs. + * + * The only schema change is dropping NOT NULL from `support_messages.body`, so + * an attachment-only message can say "there is no text" rather than smuggling + * that fact through an empty string. DROP NOT NULL is a catalog-only change in + * Postgres — no table rewrite, no long lock — so this is safe on a live table. + * + * The partial index on (resource, resource_id) is what makes hydrating a page of + * messages one indexed lookup instead of a scan of every file row in the system. + */ +export class SupportChatAttachments2320000000000 implements MigrationInterface { + name = "SupportChatAttachments2320000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.support_messages + ALTER COLUMN body DROP NOT NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP" + ON freight.files (resource, resource_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP" + `); + + // Re-imposing NOT NULL would fail on any attachment-only message written + // while this migration was applied. Backfill those to '' first so the + // rollback is deterministic rather than dependent on production data. + await queryRunner.query(` + UPDATE freight.support_messages SET body = '' WHERE body IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.support_messages + ALTER COLUMN body SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts new file mode 100644 index 000000000..d6e7ae273 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A facility handles what its equipment can handle. Containers need a reach + * stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs + * far less, so all five facilities load and unload it. + * + * Both default true — a facility handles everything unless someone says + * otherwise, which keeps existing rows working and makes the seeder the place + * where the real capability is stated. + */ +export class YardFacilityFreightTypes2320000000000 implements MigrationInterface { + name = 'YardFacilityFreightTypes2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS handles_container, + DROP COLUMN IF EXISTS handles_bulk + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts new file mode 100644 index 000000000..b435e55ea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a global "booking close offset" — how long BEFORE departure a schedule's + * booking window shuts — configurable separately for import and export. + * + * When an offset is set, the window's close instant is `departure − offset` + * (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure + * Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole + * booking lifecycle: the first window close, every reopen cycle, and the export + * FCFS close all land at/at-or-before this cutoff instead of at departure. + * + * NULL / 0 preserves the previous behaviour exactly (import closes at + * open+duration clamped to departure; export closes at departure), so existing + * installs are unaffected until an offset is entered. + * + * `*_close_offset_minutes` on the global-rules singleton is the live config; the + * matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown + * even after a later global-rules edit. Both are nullable with no backfill — + * absent means "no offset", the safe default. + */ +export class AddBookingCloseOffset2330000000000 implements MigrationInterface { + name = "AddBookingCloseOffset2330000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_import_close_offset_minutes, + DROP COLUMN IF EXISTS rule_export_close_offset_minutes; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_close_offset_minutes, + DROP COLUMN IF EXISTS export_close_offset_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts new file mode 100644 index 000000000..6585cc842 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add `has_lashing` to cargo types. + * + * When true, every booking of that cargo type incurs the flat LASHING + * surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing + * cargo ships without the fee until the flag is turned on. + */ +export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface { + name = "AddCargoTypeHasLashing2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS has_lashing; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts new file mode 100644 index 000000000..ad389e929 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add an opt-in "reverse wagon order" flag to a train schedule. + * + * When true, the built wagon plan is flipped at build time so the physically-last + * wagon sits at position 1. Only the order (sequence_no) changes — composition and + * booking allocations travel with their slot. The flag is frozen on the schedule + * at creation and re-applied every time the wagon plan is rebuilt, so the stored + * train order and the schedule order always match. + * + * Defaults to false; existing schedules keep their as-built order. + */ +export class AddReverseWagonOrder2340000000000 implements MigrationInterface { + name = "AddReverseWagonOrder2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS reverse_wagon_order; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..95e007c8a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the "pricing" article of each seeded contract template so it points + * at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon, + * USD 919/40ft, …). The original CreateContractTemplates migration seeded the + * old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB + * rows and would otherwise contradict the rate-config-driven schedule table now + * rendered under the pricing article. + * + * Only the article whose id = 'pricing' is touched, and only when its body + * still matches the originally-seeded prose — so any admin edit to the pricing + * article is left untouched. Idempotent: re-running is a no-op once refreshed. + */ +export class RefreshContractPricingArticles2350000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((article) => article.id === 'pricing'); + if (!pricing) continue; + + // jsonb_set the title + body of the element whose id = 'pricing', matched + // by array index. Guarded so admin-edited bodies are never overwritten. + await queryRunner.query( + ` + UPDATE freight.contract_templates ct + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text) + ELSE elem + END + ) + FROM jsonb_array_elements(ct.articles) elem + ) + WHERE ct.code = $1 + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(ct.articles) e + WHERE e->>'id' = 'pricing' + AND e->>'body' LIKE ANY (ARRAY[ + '%USD 59.4 per metric ton%', + '%USD 696 (six hundred ninety-six) per wagon%', + '%USD 400 (four hundred) per wagon%', + '%From SGTD to Dire Dawa dry port, the rate is USD 919%', + '%Railway transportation charges from GMP to SGTD: USD 819%', + '%prevailing EDR domestic container tariff, as set out in the commercial schedule%' + ]) + ); + `, + [seed.code, pricing.title, pricing.body], + ); + } + } + + public async down(): Promise { + // No-op: the refreshed pricing prose is the correct forward state; reverting + // to hardcoded figures would reintroduce the rate-schedule contradiction. + } +} diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container handling opt-in: each physical container can now be marked + * hazardous / reefer / with-return individually, next to its VGM. The hazardous + * and reefer flags already existed on the unit row; only the return leg was + * missing, so a booking of 20 containers with 10 returning empty can bill the + * WITH_RETURN surcharge on 10 instead of all 20. + * + * Backfill: existing rows keep false. The line-level counts + * (booking_container.return_quantity etc.) stay authoritative for bookings made + * before this change — the rule engine falls back to them when no unit is flagged. + */ +export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface { + name = 'AddContainerUnitReturnFlag2370000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts new file mode 100644 index 000000000..2c252f127 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * New built-train lifecycle status DEACTIVATED: staff park a train indefinitely + * (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like + * UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never + * overwrites it and refuses to schedule a deactivated train. + * + * Postgres cannot drop an enum value, so down() is a no-op. + */ +export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface { + name = 'AddTrainDeactivatedStatus2380000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`, + ); + } + + public async down(): Promise { + // Enum values cannot be removed in Postgres; leaving the label is harmless. + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts new file mode 100644 index 000000000..99c764806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia) + * selectable in the Train Builder. The paired EXPORT number is derived + * (import − 1), so only the import side is configured. Seeded with the runs + * historically hardcoded in the backoffice's trainRuns constants; admins add + * new runs from the Dropdown Settings editor. + */ +export class SeedImportTrainNumbers2390000000000 implements MigrationInterface { + name = 'SeedImportTrainNumbers2390000000000'; + private readonly code = 'import_train_numbers'; + private readonly options: string[] = [ + '8002', + '8102', + '8202', + '8302', + '8402', + '8502', + '8602', + '8702', + '8802', + '8902', + '9002', + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta) + VALUES ($1, $2, $3, false, $4::jsonb) + RETURNING id;`, + [ + this.code, + 'Import train numbers', + 'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).', + JSON.stringify({ searchable: true, clearable: true }), + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const value = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, value, value, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts new file mode 100644 index 000000000..31263f4b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard soft-delete now appends `@` to the unique code (SEBETA → + * SEBETA@1755612345678) so the name can be reused by a new yard while + * UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long + * codes plus the 14-char suffix, so widen to 40. + */ +export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface { + name = 'WidenYardCodeForSoftDeleteSuffix2390000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`, + ); + } + + public async down(): Promise { + // Narrowing would fail on suffixed codes; keep 40. + } +} diff --git a/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts new file mode 100644 index 000000000..6cddae23d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck exit weights for customer self-haul, mirroring what + * `last_mile_vehicle_assignments` already carries for EDR trucks. + * + * A bulk booking is hauled away truck by truck until no tonnage is left, and the + * EDR side enforces that by summing `net_weight_tons` of departed trucks. The + * customer side had no net and no tare — only `gross_weight_kg`, which nothing + * in the live flow ever wrote (the release flow updated the EDR table only). So + * a self-haul bulk booking could take unlimited trucks: hauled tonnage always + * summed to zero. + * + * `gross_weight_kg` is left alone but note it holds TONNES despite its name — + * the weighing UI is in tonnes throughout. The new columns are named for the + * unit they actually hold. + */ +export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface { + name = 'AddCustomerTruckExitWeights2400000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL + `); + + // Departed trucks are what the drawdown sums, so it reads this index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed" + ON freight.customer_truck_assignments (booking_id, departed_at) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`, + ); + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts new file mode 100644 index 000000000..b3369b82d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts @@ -0,0 +1,77 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `freight.companies.region` was free text until region became a closed set + * (see ETHIOPIAN_REGIONS in @edr/types). This normalizes the rows written under + * the old rules so they satisfy the new dropdown. + * + * Two classes of bad data exist, handled differently: + * + * - Unambiguous spelling/case drift ("Addis ababa", "oromoia") — rewritten to + * the canonical spelling. + * - Values that are not regions at all ("Arba Minch", a city), and rows whose + * region contradicts their own zone/woreda — set to NULL. These are NOT + * guessed at: inferring "Gurage/Meskan" means Central Ethiopia would silently + * overwrite what the customer actually submitted. NULL surfaces the gap and + * the required dropdown forces a deliberate pick on next edit. + */ +export class NormalizeCompanyRegions2400000000000 implements MigrationInterface { + name = 'NormalizeCompanyRegions2400000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Canonical spellings — case/whitespace insensitive, safe to re-run. + await queryRunner.query(` + UPDATE freight.companies + SET region = v.canonical + FROM (VALUES + ('addis ababa', 'Addis Ababa'), + ('addis abeba', 'Addis Ababa'), + ('addisababa', 'Addis Ababa'), + ('oromia', 'Oromia'), + ('oromoia', 'Oromia'), + ('oromiya', 'Oromia'), + ('amhara', 'Amhara'), + ('somali', 'Somali'), + ('afar', 'Afar'), + ('tigray', 'Tigray'), + ('tigrai', 'Tigray'), + ('sidama', 'Sidama'), + ('harari', 'Harari'), + ('gambela', 'Gambela'), + ('gambella', 'Gambela'), + ('dire dawa', 'Dire Dawa'), + ('benishangul-gumuz', 'Benishangul-Gumuz'), + ('benishangul gumuz', 'Benishangul-Gumuz'), + ('central ethiopia', 'Central Ethiopia'), + ('south ethiopia', 'South Ethiopia') + ) AS v(variant, canonical) + WHERE freight.companies.region IS NOT NULL + AND lower(regexp_replace(btrim(freight.companies.region), '\\s+', ' ', 'g')) = v.variant + AND freight.companies.region <> v.canonical + `); + + // Anything still outside the canonical set is unresolvable — null it. + await queryRunner.query(` + UPDATE freight.companies + SET region = NULL + WHERE region IS NOT NULL + AND region <> '' + AND region NOT IN ( + 'Addis Ababa','Afar','Amhara','Benishangul-Gumuz','Central Ethiopia', + 'Dire Dawa','Gambela','Harari','Oromia','Sidama','Somali', + 'South Ethiopia','South West Ethiopia Peoples''','Tigray' + ) + `); + + // Normalize empty string to NULL so "unset" has one representation. + await queryRunner.query(` + UPDATE freight.companies SET region = NULL WHERE region = '' + `); + } + + public async down(): Promise { + // Irreversible by design: the original free-text values are not retained + // anywhere, so there is nothing to restore. Rolling back the code is safe — + // the column is still a nullable varchar(100) and accepts free text again. + } +} diff --git a/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts new file mode 100644 index 000000000..1b4674b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bookings no longer run an approval chain — accepting an intake approves the + * booking outright and generates its contract. The approval chain is now a + * contract-only concern, so `freight.approval_rules` is read by contracts alone. + * + * Also widens the role columns: chain steps now reference IAM position-type + * keys (`iam.position_types.key`), and real keys run past the old varchar(30) + * (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail + * on insert. + */ +export class DropBookingApprovalWidenRoles2410000000000 + implements MigrationInterface +{ + name = 'DropBookingApprovalWidenRoles2410000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.booking_approval_step;`, + ); + + for (const [table, column] of [ + ['approval_rules', 'required_role'], + ['approval_rules', 'blocks_role'], + ['contract_approval_steps', 'required_role'], + ['contract_approval_steps', 'blocks_role'], + ] as const) { + await queryRunner.query( + `ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`, + ); + } + } + + /** + * No-op: the booking approval chain is retired, so re-creating the table + * would leave dead schema behind. Narrowing the role columns again would + * truncate any position-type key already stored. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts new file mode 100644 index 000000000..db06b2ba2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for contract document edits. The document stays editable through + * the whole approval chain (each approver may edit on their turn), so the + * contract itself only ever holds the current snapshot — this table records who + * changed which article, and when. + */ +export class CreateContractDocumentRevisions2420000000000 + implements MigrationInterface +{ + name = 'CreateContractDocumentRevisions2420000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_document_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + actor_id uuid, + actor_role varchar(64), + step_id uuid, + summary varchar(255), + changes jsonb NOT NULL DEFAULT '[]'::jsonb + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract + ON freight.contract_document_revisions (contract_id, created_at DESC); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.contract_document_revisions;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts new file mode 100644 index 000000000..833d49fea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-document review state, so a backoffice reviewer can request a correction + * on one specific onboarding document instead of rejecting the whole role. + * + * Until now `freight.files` carried no status at all: the `pending_add` / + * `pending_remove` badges the portal shows are derived by diffing live rows + * against an open company change request, which says nothing about whether a + * reviewer is happy with a given document. `review_status` is that missing + * verdict — NULL means never reviewed, which is the state every existing row + * correctly starts in, so no backfill is needed. + * + * The partial index serves the approval gate, which asks "does this company (or + * profile) still have any document with an open change request?" on every + * role-status write. + */ +export class AddFileReviewStatus2430000000000 implements MigrationInterface { + name = 'AddFileReviewStatus2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL, + ADD COLUMN IF NOT EXISTS review_note text NULL, + ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL, + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request" + ON freight.files (resource, resource_id) + WHERE review_status = 'change_requested' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`, + ); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS review_status, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS reviewed_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts new file mode 100644 index 000000000..5468e2207 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Locomotive names must be unique so staff can identify a unit by name alone + * (the card view leads with `name`, falling back to `code`). Uniqueness is: + * + * - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name; + * - scoped to live rows — a decommissioned (soft-deleted) locomotive must not + * hold its name hostage, matching how the fleet reuses yard codes; + * - skipped for blank names — `name` stays optional, and NULL/'' rows are + * excluded rather than colliding with each other. + * + * A partial expression index gives all three; a plain UNIQUE column cannot. + */ +export class UniqueLocomotiveName2430000000000 implements MigrationInterface { + name = 'UniqueLocomotiveName2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every + // copy after the oldest (…-2, …-3) so the index can build; the oldest row + // keeps the original name. Deterministic on created_at, then id. + await queryRunner.query(` + WITH ranked AS ( + SELECT + id, + name, + row_number() OVER ( + PARTITION BY lower(btrim(name)) + ORDER BY created_at, id + ) AS rn + FROM "freight"."locomotives" + WHERE deleted_at IS NULL + AND name IS NOT NULL + AND btrim(name) <> '' + ) + UPDATE "freight"."locomotives" AS l + SET name = btrim(ranked.name) || '-' || ranked.rn + FROM ranked + WHERE l.id = ranked.id + AND ranked.rn > 1 + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active" + ON "freight"."locomotives" (lower(btrim("name"))) + WHERE "deleted_at" IS NULL + AND "name" IS NOT NULL + AND btrim("name") <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`, + ); + // The de-duplicating renames are not reversed: the original names are no + // longer recoverable, and restoring them would re-introduce the conflict. + } +} diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop the `active_profile_type` "active mode" column. A booking/contract now + * resolves its company_profile from the trade direction at creation time (with + * a forwarder passing an explicit companyProfileId), so no per-user active mode + * is stored. `onboarding_step` / `onboarding_completed` are unaffected. + */ +export class DropActiveProfileTypeFromExternalProfiles2450000000000 + implements MigrationInterface +{ + name = 'DropActiveProfileTypeFromExternalProfiles2450000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + // Rebuild the mode the same way the original column was backfilled: + // importer first, then exporter, then whichever profile the company has. + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The entity + frontend already list 'cac-bank' as a valid method, but the + // DB enum was never extended. Filtering payments by 'cac-bank' cast the + // literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301. + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts new file mode 100644 index 000000000..61431c5bc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Dedup stamp for the km/date-due maintenance alert — without it the daily + * cron would re-notify every day a schedule stays due. + */ +export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface { + name = 'AddMaintenanceDueNotifiedAt2480000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules + ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..f4f125eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * KM-based maintenance scheduling: per-vehicle service intervals (by km + * and/or days) driving the maintenance due engine. Raw schema-qualified SQL — + * the builder API resolved bare table names against the default schema and + * failed on boot ("Table maintenance_intervals does not exist"). + */ +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + name = 'AddMaintenanceIntervals2800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.maintenance_intervals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE, + maintenance_type varchar NOT NULL, + interval_km numeric(14,2), + interval_days integer, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts new file mode 100644 index 000000000..679846484 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist the signer's saved-signature image on the handover record, so the + * signed handover document can render the actual signature (not just the + * typed name) — parity with the booking-contract signing flow. + */ +export class AddSignatureToHandover2800000000001 implements MigrationInterface { + name = 'AddSignatureToHandover2800000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts new file mode 100644 index 000000000..17902ff0d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the customs clearance service fee to a direction + route. + * + * The fee was a single global flat rate; the business sells it per lane — + * "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now + * carry trade_direction + the yard pair, and contract pricing matches on them + * strictly (no route-less fallback). + * + * Existing route-less clearance rates cannot be backfilled (no way to know + * which lane each was meant for) — retired exactly like the base-freight + * retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for + * snapshot history. + */ +export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface { + name = 'CustomsClearanceRouteScope2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired (their lanes were never recorded); down only + // restores the pre-customs constraint shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts new file mode 100644 index 000000000..26c512cfc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the empty-container return surcharge to a direction + route + + * container type, like base freight (import-only for now — the box only goes + * back to the port on imports). + * + * Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired + * (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance + * were, kept readable for snapshot history. Route-scoped replacements must be + * re-entered; a booking that asks for return with no matching rate hard-blocks. + */ +export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface { + name = 'ReturnSurchargeRouteScope2830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'RETURN_SURCHARGE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired; down only restores the customs-era shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts index 2bf9f82fd..52a900fe8 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, NotFoundException, Param, ParseUUIDPipe, @@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; -import { CustomerResetService } from "./customer-reset.service"; +import { + CustomerResetService, + CustomerResetTarget, +} from "./customer-reset.service"; /** - * Staff-triggered password reset. The customer receives the code and sets their - * own password — staff never see or handle a credential. + * Staff-triggered password reset. The customer receives a single-use link and + * sets their own password — staff never see or handle a credential. */ @ApiTags("backoffice") @Controller("backoffice/customers") @@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service"; export class CustomerResetController { constructor(private readonly customerResetService: CustomerResetService) {} + @Get(":companyId/reset-target") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "The primary contact's IAM account a reset link would be sent to", + }) + async resetTarget( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const target = await this.customerResetService.getResetTarget(companyId); + + if (!target) { + throw new NotFoundException( + "This customer has no active primary-contact account to reset", + ); + } + + return target; + } + @Post(":companyId/reset-password") @BookingStaff(FREIGHT_PERMS.customers.resetPassword) @ApiOperation({ - summary: "Send a password-reset code to a customer's primary contact", + summary: "Send a password-reset link to a customer's primary contact", }) async resetPassword( @Param("companyId", ParseUUIDPipe) companyId: string, @Body() dto: BackofficeResetPasswordDto, ) { - const maskedTarget = await this.customerResetService.sendResetToCustomer( + const sent = await this.customerResetService.sendResetLinkToCustomer( companyId, dto.channel, ); - if (!maskedTarget) { + if (!sent) { throw new NotFoundException( `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`, ); } - return { channel: dto.channel, maskedTarget }; + return sent; } } diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 4c8f67599..4eb6ecc31 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,10 +1,38 @@ import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { EmailClientService } from "../notifications/email-client.service"; +import { SmsClientService } from "../notifications/sms-client.service"; import { ResetChannel } from "./dto/forgot-password.dto"; -import { ForgotPasswordService } from "./forgot-password.service"; +import { + ForgotPasswordService, + RESET_LINK_TTL_MS, +} from "./forgot-password.service"; +import { maskOtpTarget } from "./mask-target.util"; +import { isDomesticPhone } from "../otp/otp.service"; + +/** The account a staff-triggered reset would land on. */ +export interface CustomerResetTarget { + userId: string; + name: string; + email: string | null; + phone: string | null; + /** + * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when + * there is no phone. The backoffice uses this to disable the SMS channel for + * foreign numbers instead of sending a link that will never arrive. + */ + phoneIsDomestic: boolean | null; +} + +export interface SentResetLink { + channel: ResetChannel; + maskedTarget: string; + expiresAt: string; +} @Injectable() export class CustomerResetService { @@ -14,19 +42,124 @@ export class CustomerResetService { @InjectRepository(ExternalProfile) private readonly externalProfileRepository: Repository, private readonly forgotPasswordService: ForgotPasswordService, + private readonly emailClient: EmailClientService, + private readonly smsClient: SmsClientService, + private readonly config: ConfigService, ) {} /** - * Send a reset code to the company's primary contact. Returns the masked - * destination, or null when there is no eligible account for that channel. + * The IAM account a reset would actually reach. The backoffice shows these + * values rather than `company.email` / `company.phone`: the company row holds + * business contact detail, while the link is delivered to the primary + * contact's own login credentials — the two drift apart routinely, and showing + * the wrong one has staff telling customers to check an inbox nothing was sent + * to. + */ + async getResetTarget(companyId: string): Promise { + const resolved = await this.resolvePrimaryContactUser(companyId); + if (!resolved) return null; + + const { profile, user, userId } = resolved; + return { + userId, + name: `${profile.firstName} ${profile.lastName}`.trim(), + email: user.email ?? null, + phone: user.phoneNumber ?? null, + phoneIsDomestic: user.phoneNumber + ? isDomesticPhone(user.phoneNumber) + : null, + }; + } + + /** + * Mint a password-reset link and send it to the company's primary contact. + * Returns the masked destination, or null when there is no eligible account + * for that channel. * * Unlike the public flow this reports failure honestly — the caller is an * authenticated staff member, so there is nothing to enumerate. */ - async sendResetToCustomer( + async sendResetLinkToCustomer( companyId: string, channel: ResetChannel, - ): Promise { + ): Promise { + const resolved = await this.resolvePrimaryContactUser(companyId); + if (!resolved) return null; + + const { user, userId } = resolved; + const target = this.forgotPasswordService.targetFor(user, channel); + if (!target) return null; + + // A foreign number is unreachable by the domestic-only SMS gateway — treat + // it like a missing phone rather than reporting "link sent" for a message + // that will never arrive. The backoffice disables the channel up front via + // `phoneIsDomestic`; this guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + return null; + } + + // Mint first, send second: a failed send leaves an unused ticket that simply + // expires, whereas sending a link before the ticket exists would hand the + // customer a URL that is dead on arrival. + const ticket = await this.forgotPasswordService.mintResetTicket( + userId, + RESET_LINK_TTL_MS, + ); + const link = this.buildResetLink(ticket.userId, ticket.verificationCode); + const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); + + const { queued } = target.email + ? await this.emailClient.sendEmail({ + to: target.email, + subject: "Reset your EDR Freight password", + text: + "A password reset was started for your EDR Freight account.\n\n" + + `Open this link to choose a new password:\n${link}\n\n` + + "The link expires in 24 hours and can only be used once. If you did " + + "not expect this, ignore this message — your password stays unchanged.", + }) + : await this.smsClient.sendSms({ + to: target.phone as string, + message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`, + }); + + this.logger.log( + `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, + ); + + if (!queued) { + // The ticket is committed and the backoffice is about to say "link sent", + // but nothing left this process — with RABBITMQ_ENABLED=false both clients + // are no-ops. Without this line the only symptom is a customer who never + // receives anything, indistinguishable from carrier loss. + this.logger.error( + `reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${ + process.env.RABBITMQ_ENABLED ?? "unset" + } — transport reported no hand-off; no link will arrive`, + ); + // SECURITY: logs a live password-reset credential in cleartext. Same + // deliberate tradeoff the OTP service makes — this is the only way to + // complete a reset on an environment with no broker. Only reached when + // delivery already failed. + this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`); + } + + return { + channel, + maskedTarget: maskOtpTarget(target), + expiresAt: expiresAt.toISOString(), + }; + } + + /** + * The company's primary contact, gated on the same active-account rule the + * public flow uses — so a suspended customer cannot be reactivated by a + * staff-triggered reset (IAM's `set-password` flips `isActive` back on). + */ + private async resolvePrimaryContactUser(companyId: string) { const profile = await this.externalProfileRepository.findOne({ where: { companyId, isPrimaryContact: true }, }); @@ -36,24 +169,28 @@ export class CustomerResetService { return null; } - // Resolve through the same active-account gate the public flow uses, so a - // suspended customer cannot be reactivated by a staff-triggered reset. const user = await this.forgotPasswordService.resolveActiveUserById( profile.userId, ); - if (!user) { + if (!user?.id) { this.logger.warn( `Primary contact ${profile.userId} of company ${companyId} is not an active account`, ); return null; } - const target = await this.forgotPasswordService.requestReset(user, channel); - if (!target) return null; + return { profile, user, userId: user.id }; + } - this.logger.log( - `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, - ); - return this.forgotPasswordService.maskTarget(target); + /** + * The portal route that trades the token for a set-password form. Params are + * URL-encoded because the token is base64url — safe as-is, but the encoding + * keeps this correct if the token format ever changes. + */ + private buildResetLink(userId: string, token: string): string { + const base = this.config.get("app.portalBaseUrl"); + return `${base}/reset-password?uid=${encodeURIComponent( + userId, + )}&token=${encodeURIComponent(token)}`; } } diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts index be2f9bdac..34e16f628 100644 --- a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts +++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts @@ -1,7 +1,11 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum, IsNotEmpty, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; -/** The channel the reset code is delivered over. */ +/** + * The channel a reset LINK is delivered over. The OTP flow no longer picks one — + * it sends to every contact on the account — but the staff-triggered link flow + * still delivers over exactly one transport. + */ export enum ResetChannel { Email = "email", Phone = "phone", @@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto { @IsNotEmpty() identifier!: string; - @ApiProperty({ enum: ResetChannel }) + /** + * Accepted and ignored. The code now goes to the account's email AND phone, + * so there is nothing to choose — kept optional so clients still sending it + * (older portal/backoffice builds) are not rejected outright. + * @deprecated + */ + @ApiPropertyOptional({ + enum: ResetChannel, + deprecated: true, + description: "Ignored — the code is sent to every contact on the account.", + }) + @IsOptional() @IsEnum(ResetChannel) - channel!: ResetChannel; + channel?: ResetChannel; } export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { - @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @ApiProperty({ + description: + "The 6-digit code sent to the account's email and phone. Either delivery carries the same code.", + }) @IsString() @IsNotEmpty() otp!: string; @@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto { @IsEnum(ResetChannel) channel!: ResetChannel; } + +/** + * The two halves of a reset link's query string. Together they stand in for the + * identifier + OTP pair of the typed flow: the token proves possession of the + * inbox/handset the link was delivered to. + */ +export class ResolveResetLinkDto { + @ApiProperty({ description: "IAM user id from the reset link's `uid` param" }) + @IsUUID() + userId!: string; + + @ApiProperty({ description: "Opaque token from the reset link's `token` param" }) + @IsString() + @IsNotEmpty() + token!: string; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts index da49982d2..448a380c9 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -5,8 +5,13 @@ import { Public } from "@edr/api-common"; import { ForgotPasswordRequestDto, ForgotPasswordVerifyDto, + ResolveResetLinkDto, } from "./dto/forgot-password.dto"; -import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; +import { + ForgotPasswordService, + ResetLinkAccount, + ResetTicket, +} from "./forgot-password.service"; /** * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only @@ -24,17 +29,19 @@ export class ForgotPasswordController { @Post("forgot-password/request") @ApiOperation({ - summary: "Send a password-reset code over email or SMS", + summary: "Send a password-reset code to the account's email AND phone", description: - "Always reports success. An unknown, inactive, or channel-less account is " + - "indistinguishable from a real one, so this cannot be used to enumerate accounts.", + "One code, delivered over every contact the account has; either delivery " + + "verifies it. Always reports success — an unknown, inactive, or contactless " + + "account is indistinguishable from a real one, so this cannot be used to " + + "enumerate accounts.", }) async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); if (user) { try { - await this.forgotPasswordService.requestReset(user, dto.channel); + await this.forgotPasswordService.requestReset(user); } catch (error) { // A delivery failure must not change the response shape either — log it // and let the caller sit on the OTP screen. @@ -60,10 +67,18 @@ export class ForgotPasswordController { "alongside the same identifier and the new password.", }) verify(@Body() dto: ForgotPasswordVerifyDto): Promise { - return this.forgotPasswordService.verifyAndMintTicket( - dto.identifier, - dto.channel, - dto.otp, - ); + return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp); + } + + @Post("forgot-password/resolve-link") + @ApiOperation({ + summary: "Validate a staff-issued reset link and return its set-password ticket", + description: + "Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " + + "is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " + + "A bad or expired link is rejected here rather than after the password is typed.", + }) + resolveLink(@Body() dto: ResolveResetLinkDto): Promise { + return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token); } } diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index 42dc723d5..a3dbf1061 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -4,13 +4,14 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; import { DataSource, Repository } from "typeorm"; -import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon"; import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity"; import { OtpService, OtpTarget } from "../otp/otp.service"; import { ResetChannel } from "./dto/forgot-password.dto"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -21,11 +22,32 @@ const RESET_TICKET_TTL_MS = 10 * 60 * 1000; /** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */ const RESET_OTP_TTL_MS = 10 * 60 * 1000; +/** + * A staff-triggered reset link lives longer than a typed OTP: the customer may + * only see the SMS/email hours after the call that prompted it. + */ +export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000; + +/** IAM refuses a ticket once its row hits this many failed attempts. */ +const MAX_TICKET_ATTEMPTS = 5; + export interface ResetTicket { userId: string; verificationCode: string; } +/** + * What a valid reset link resolves to. `identifier` is the value IAM's + * `set-password` matches the user on (it accepts email / username / phone), so + * the portal can spend the ticket without the customer typing anything. + */ +export interface ResetLinkAccount { + userId: string; + identifier: string; + maskedIdentifier: string; + verificationCode: string; +} + @Injectable() export class ForgotPasswordService { private readonly logger = new Logger(ForgotPasswordService.name); @@ -80,8 +102,12 @@ export class ForgotPasswordService { .orderBy("u.createdAt", "DESC"); } - /** The address the code goes to, taken from the account — never from input. */ - private targetFor(user: User, channel: ResetChannel): OtpTarget | null { + /** + * A single channel of the account, for flows that genuinely deliver over one + * transport (the staff-triggered reset LINK picks email or SMS). Taken from + * the account — never from input. + */ + targetFor(user: User, channel: ResetChannel): OtpTarget | null { if (channel === ResetChannel.Email) { return user.email ? { email: user.email } : null; } @@ -89,20 +115,40 @@ export class ForgotPasswordService { } /** - * Send a reset code to the account's own email/phone. Returns the target so - * authenticated (backoffice) callers can echo a masked version; unauthenticated - * callers must discard it. + * Every contact the account has. The reset OTP goes to all of them and any one + * verifies it — a customer whose SMS never lands can finish from their inbox + * without restarting the flow on a different channel. An account holding only + * one of the two degrades to that channel; only a contactless account is null. + */ + targetsFor(user: User): OtpTarget | null { + const target: OtpTarget = {}; + if (user.email) target.email = user.email; + if (user.phoneNumber) target.phone = user.phoneNumber; + return target.email || target.phone ? target : null; + } + + /** + * The value IAM's `set-password` will match this account on. It looks the user + * up by email OR username OR phoneNumber (and lowercases whatever it is + * given), so prefer email, then phone, and fall back to username last — + * a mixed-case username would not survive that lowercasing. + */ + private identifierFor(user: User): string | null { + return user.email ?? user.phoneNumber ?? user.username ?? null; + } + + /** + * Send one reset code to every contact on the account — email AND phone — + * returning the target so authenticated (backoffice) callers can echo a masked + * version; unauthenticated callers must discard it. * * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp` - * upserts. A reset request therefore overwrites any pending signup code for - * the same address — last code sent wins. That is the pre-existing behaviour - * between any two flows sharing this table. + * replaces every row the target overlaps. A reset request therefore overwrites + * any pending signup code for the same addresses — last code sent wins. That + * is the pre-existing behaviour between any two flows sharing this table. */ - async requestReset( - user: User, - channel: ResetChannel, - ): Promise { - const target = this.targetFor(user, channel); + async requestReset(user: User): Promise { + const target = this.targetsFor(user); if (!target) return null; await this.otpService.sendOtp(target); @@ -119,11 +165,12 @@ export class ForgotPasswordService { */ async verifyAndMintTicket( identifier: string, - channel: ResetChannel, otp: string, ): Promise { const user = await this.resolveActiveUser(identifier); - const target = user && this.targetFor(user, channel); + // Same set of contacts `requestReset` sent to, so the code resolves whichever + // of the two the customer actually received it on. + const target = user && this.targetsFor(user); if (!user?.id || !target) { // Same shape as a wrong code: a caller probing for accounts learns nothing @@ -133,9 +180,18 @@ export class ForgotPasswordService { await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS); + } + + /** + * Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code + * is the proof of possession) and the staff-triggered link flow (where the + * ticket travels in the link and delivery to the account's own inbox/handset + * is the proof). + */ + async mintResetTicket(userId: string, ttlMs: number): Promise { const code = randomBytes(24).toString("base64url"); const verificationCode = await hashPassword(code); - const userId = user.id; await this.dataSource.transaction(async (manager) => { const repo = manager.getRepository(UserVerification); @@ -146,7 +202,7 @@ export class ForgotPasswordService { userId, otpType: EOtpType.RESET_PASSWORD, verificationCode, - expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + expiresAt: new Date(Date.now() + ttlMs), isUsed: false, attemptCount: 0, }); @@ -156,14 +212,65 @@ export class ForgotPasswordService { return { userId, verificationCode: code }; } + /** + * Validate a reset link and hand back everything the portal needs to spend it + * on IAM's `PATCH /api/auth/set-password`. + * + * The checks mirror IAM's own — newest row, unused, unexpired, attempts left, + * argon match — so a link that resolves here is one IAM will honour. Doing + * them up front is what lets the page say "this link has expired" before the + * customer types a password rather than after. + * + * Every rejection is the same message: a link is a bearer credential, and the + * holder of a bad one learns nothing about why it failed or whether the user + * id exists. + */ + async resolveResetLink( + userId: string, + token: string, + ): Promise { + const invalid = new BadRequestException( + "This password-reset link is invalid or has expired. Request a new one.", + ); + + const user = await this.resolveActiveUserById(userId); + const identifier = user && this.identifierFor(user); + if (!user || !identifier) throw invalid; + + const verification = await this.dataSource + .getRepository(UserVerification) + .findOne({ + where: { userId, otpType: EOtpType.RESET_PASSWORD }, + order: { createdAt: "DESC" }, + }); + + // `expiresAt` / `attemptCount` are optional on IAM's entity but always + // written by `mintResetTicket`. A row missing either is malformed, so treat + // it as expired rather than letting it through unchecked. + if ( + !verification || + verification.isUsed || + !verification.expiresAt || + verification.expiresAt < new Date() || + (verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS || + !(await verifyPassword(token, verification.verificationCode)) + ) { + this.logger.warn(`Reset link rejected for user ${userId}`); + throw invalid; + } + + return { + userId, + identifier, + maskedIdentifier: maskOtpTarget( + identifier.includes("@") ? { email: identifier } : { phone: identifier }, + ), + verificationCode: token, + }; + } + /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { - if (target.email) { - const [local, domain] = target.email.split("@"); - const head = local.slice(0, 1); - return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; - } - const phone = target.phone ?? ""; - return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + return maskOtpTarget(target); } } diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 6415cf4c1..10dbd0b37 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; import { ExternalProfile } from '../companies/entities/external-profile.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +22,27 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, + // Reset links go out over email/SMS directly, not through the OTP service. + NotificationsModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 50c90213b..006b482f4 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { DataSource } from 'typeorm'; import { collectPermissionKeys, @@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; @Injectable() export class FreightMeService { - getEnrichedProfile(user: TCurrentUser) { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * The JWT session snapshot has no position TYPE, but the backoffice needs it + * (GL sub-positions are identified by type key). Resolved live from IAM. + */ + private async lookupPositionType( + positionId: string | undefined, + ): Promise<{ key: string; name: unknown } | null> { + if (!positionId) return null; + try { + const rows: { key: string; name: unknown }[] = await this.dataSource.query( + `SELECT pt.key, pt.name + FROM iam.positions p + JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE p.id = $1`, + [positionId], + ); + return rows[0] ?? null; + } catch { + return null; // iam schema unreachable — degrade to the old payload shape + } + } + + async getEnrichedProfile(user: TCurrentUser) { + const positionType = await this.lookupPositionType( + user.employee?.position?.id, + ); + const employee = user.employee ? [ { @@ -27,6 +57,7 @@ export class FreightMeService { isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, permissions: user.employee.position.permissions ?? [], + positionType, }, ] : [], diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..81d49a522 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,27 @@ +import { OtpTarget } from "../otp/otp.service"; + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; +} + +function maskPhone(phone: string): string { + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + * + * A dual-channel target masks both and joins them, so the UI can say exactly + * where the code went ("a•@x.com and +251•••••4567") — a user who only checks + * one of the two otherwise assumes the other never received anything. + */ +export function maskOtpTarget(target: OtpTarget): string { + const parts: string[] = []; + if (target.email) parts.push(maskEmail(target.email)); + if (target.phone) parts.push(maskPhone(target.phone)); + return parts.join(" and "); +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 0f60876d4..2bdc6ee16 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1016,7 +1016,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber.replace("-", "_"), + orderRef: invoice.invoiceNumber.replace(/-/g, "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, @@ -1032,10 +1032,8 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // remove — real settlement flips this via the `${source}.invoice.paid` handler. + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); + // billing must not simulate it. Kept commented for local demos only. if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index b0a3ad766..3d8222d63 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,6 +16,7 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; +import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -120,17 +121,27 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open prepaid invoice when the booking is + * Expire the booking's currently-open invoices (freight PREPAID and the + * per-shipment clearance fee) when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no * open invoice (never invoiced, already paid/cancelled/expired). Pass a * caller `manager` to enlist in its transaction. */ - expireOpenInvoices( + async expireOpenInvoices( bookingId: string, manager?: EntityManager, ): Promise { + // The per-shipment clearance fee (GENERAL contracts) bills this same booking + // id under its own source/type — retire it alongside the freight invoice, or + // a cancelled shipment keeps a payable clearance invoice open. + await this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + bookingId, + CLEARANCE_BOOKING_INVOICE_TYPE, + manager, + ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index a4546e301..caa41f5e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { @@ -150,13 +157,24 @@ export class BookingLifecycleNotifierService { }); } - /** Clearance finalized → customer can proceed to request operation. */ + /** Document approval finalized → customer can proceed to request operation. */ clearanceReady(b: Booking): void { const msg = - `Clearance for booking ${b.reference} is complete. ` + + `Document approval for booking ${b.reference} is finalized. ` + `You can now proceed to request operation from the portal.`; - void this.notifyContact(b, msg, 'CLEARANCE READY'); - this.inApp(b, 'Clearance complete', msg, { + void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED'); + this.inApp(b, 'Document approval finalized', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Intercity documents approved → booking waits in the ride-along pool. */ + intercityDocumentsApproved(b: Booking): void { + const msg = + `Documents for intercity booking ${b.reference} are approved. ` + + `Operations will assign your shipment to a passing train; payment opens once it is accepted.`; + void this.notifyContact(b, msg, 'DOCUMENTS APPROVED'); + this.inApp(b, 'Documents approved', msg, { type: NotificationType.CLEARANCE_DECISION, }); } @@ -246,6 +264,19 @@ export class BookingLifecycleNotifierService { // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + /** + * A booking was created under a contract. Contract drawdowns never pass + * through submit, so this is the only point at which staff learn the booking + * exists — {@link submittedToStaff} covers the direct-booking flow instead. + */ + createdToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking created', + `Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`, + ); + } + /** Customer submitted a booking for review. */ submittedToStaff(b: Booking): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index d93b5b7bd..ddb4ce1e5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -1,4 +1,3 @@ -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { Booking } from './entities/booking.entity'; export interface BookingNextStep { @@ -9,7 +8,11 @@ export interface BookingNextStep { export function computeNextStep( booking: Pick, - nextPendingStep?: Pick | null, + /** + * Retained for call-site compatibility — bookings no longer run an approval + * chain, so this is always null. Approvals are a contract-only concern. + */ + nextPendingStep?: { requiredRole: string; stepOrder: number } | null, ): BookingNextStep | null { const { status } = booking; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index db6b70eae..fecd9111a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity'; const MOCK_CBE_RATE = 130; +// Base freight is configured per leg, so every rate and every booking names the +// route it runs. MOJO → DIRE is the corridor these rates are priced for. +const MOJO = 'yard-mojo'; +const DIRE = 'yard-dire-dawa'; +const LEBU = 'yard-lebu'; + describe('BookingPricingService — domestic corridor', () => { const intercityBulkUsd: Rate = { id: 'rate-intercity-bulk-usd', @@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_TON', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; const intercityContainerUsd: Rate = { @@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_CONTAINER', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; let service: BookingPricingService; @@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'USD', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 50, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -126,4 +142,101 @@ describe('BookingPricingService — domestic corridor', () => { const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; expect(line.currency).toBe('ETB'); }); + + // Rates are quoted per leg, so one configured for MOJO → DIRE must not price a + // shipment that runs LEBU → DIRE. Charging the wrong corridor's price because + // nobody configured this one yet is worse than billing no base freight. + it('does not price bulk off a rate configured for a different leg', async () => { + const booking = { + id: 'b-3', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(0); + expect(result.blocked).toHaveLength(1); + }); + + it('does not price containers off a rate configured for a different leg', async () => { + const booking = { + id: 'b-4', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 50, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: 'ct-20', quantity: 3 }], + }); + + expect(result.lineItems).toHaveLength(0); + }); + + // A mixed booking where only one container size has a configured rate must + // hard-block, not silently carry the unconfigured size for free. + it('blocks the unconfigured container size and prices the configured one', async () => { + const fortyOnly: Rate = { + ...intercityContainerUsd, + id: 'rate-ct-40-only', + containerTypeId: 'ct-40', + } as Rate; + ratesService.findLiveRates.mockResolvedValue([fortyOnly]); + + const booking = { + id: 'b-5', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [ + { containerTypeId: 'ct-40', quantity: 2 }, + { containerTypeId: 'ct-20', quantity: 3 }, + ], + }); + + expect(result.lineItems).toHaveLength(1); + expect(result.blocked).toHaveLength(1); + expect(result.blocked[0]).toContain('rate is configured'); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 091232611..e233a4fe6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -139,8 +137,12 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates } = - await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); + const { + lineItems: baseLines, + usedRates: baseRates, + warnings: baseWarnings, + blocked: baseBlocked, + } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -163,8 +165,16 @@ export class BookingPricingService { const usdAmount = mod.calculatedAmount; const rate = rateById.get(mod.rateId); - const unit = rate?.rateUnit ?? 'FLAT'; - const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + // Derived/route-matched charges (import overweight, empty-container + // return) carry their own unit price + billing unit — bill and display + // those, not whatever the referenced rate row says. + const isDerived = mod.unitPriceUsd != null; + const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; + const unitUsd = isDerived + ? Number(mod.unitPriceUsd) + : rate + ? Number(rate.rateValue) + : usdAmount; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise // derive from total ÷ unit price (the live unit price — a count, not a @@ -180,11 +190,11 @@ export class BookingPricingService { // H15: bill the frozen contract surcharge rate (already in the booking // currency) when this code has a snapshot; else keep the live amount. - const frozen = this.frozenRateByCode( - frozenRates, - mod.surchargeCode, - paymentCurrency, - ); + // Derived charges skip the snapshot — import overweight prices off the + // route's container freight, never a frozen OVERWEIGHT_PER_TON value. + const frozen = isDerived + ? null + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -249,8 +259,8 @@ export class BookingPricingService { usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, - warnings: ruleResult.warnings, - hardBlocked: ruleResult.hardBlocked, + warnings: [...ruleResult.warnings, ...baseWarnings], + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], overweightLines, }; } @@ -307,8 +317,12 @@ export class BookingPricingService { vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, + // Per-container opt-ins — PER_CONTAINER surcharges bill these. + hazardousQuantity: Number(bc.hazardousQuantity ?? 0), + reeferQuantity: Number(bc.reeferQuantity ?? 0), + returnQuantity: Number(bc.returnQuantity ?? 0), }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), @@ -363,6 +377,8 @@ export class BookingPricingService { isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. @@ -452,7 +468,12 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -474,51 +495,86 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); + const warnings: string[] = []; + const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { - const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); - if (!rate) continue; - - usedRatesMap.set(rate.id, rate); - const unitUsd = Number(rate.rateValue); + const rate = this.pickRate( + liveRates, + rateType, + container.containerTypeId, + 'USD', + booking.originYardId, + booking.destinationYardId, + ); // H15: frozen contract rate for this container size, when present — its // unitPrice is already in the booking currency (no USD→currency convert). + // It also stands on its own: a contract line prices off the agreed rate + // even when nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, ); + const label = await this.containerTypeLabel(container.containerTypeId); + if (!rate && !frozen) { + // Never price this line off another container type's (or another + // route's) rate, and never let an unpriced line through: a booking + // that ships a container type nobody configured a rate for would be + // carried for free. Hard-block instead — the customer drops the line + // or EDR configures the rate. + blocked.push( + `No ${rateType} rate is configured for ${label} on this route — ` + + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', + ); + continue; + } + + const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( - rate.rateUnit, + rateUnit, unitAmount, container.quantity, wagonCount, ); } else { - const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + const unitUsd = Number(rate!.rateValue); + const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } - const label = await this.containerTypeLabel(container.containerTypeId); + if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount, - unit: rate.rateUnit, - quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), + unit: rateUnit, + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } - if (lines.length === 0) { + if (lines.length === 0 && evalInput.containers.length === 0) { + // Bulk (and any booking with no container lines) still has to price off a + // rate configured for this leg — never one belonging to another route. + // Container bookings never reach this fallback: their lines price per + // container type above or stay unpriced with a warning — falling back to + // a corridor rate of a DIFFERENT container type billed once (qty 1) is + // how a 38-container booking was invoiced 40 USD instead of 1900. const fallback = liveRates.find( - (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, ); if (fallback) { usedRatesMap.set(fallback.id, fallback); @@ -554,10 +610,18 @@ export class BookingPricingService { quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); + } else if (isBulk) { + // Same rule as container lines: bulk freight with no rate on this leg + // must not proceed unpriced. + blocked.push( + `No ${rateType} rate is configured for this route — the booking ` + + 'cannot be priced. Ask EDR to configure the rate for this ' + + 'origin → destination.', + ); } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** @@ -713,20 +777,32 @@ export class BookingPricingService { } } + /** + * Base freight is quoted per leg, so a rate only applies to a booking running + * the exact origin → destination it was configured for. There is deliberately + * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment + * because nobody configured Mojo yet is worse than surfacing no line at all. + * Within the leg, a rate scoped to the container type wins over one that + * covers every type. + */ private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, + originYardId: string, + destinationYardId: string, ): Rate | undefined { + const onLeg = rates.filter( + (r) => + r.rateType === rateType && + r.currency === currency && + r.originYardId === originYardId && + r.destinationYardId === destinationYardId, + ); return ( - rates.find( - (r) => - r.rateType === rateType && - r.currency === currency && - r.containerTypeId === containerTypeId, - ) ?? - rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + onLeg.find((r) => r.containerTypeId === containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId) ); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..507ef43d8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from "@nestjs/common"; -import { In, Not } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../rule-engine/entities/container-type.entity"; @@ -34,8 +33,6 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; -const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const; - export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { @@ -110,7 +107,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); @@ -135,10 +131,7 @@ export class BookingReferenceDataService { const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = await Promise.all([ this.yardsRepository.findAll({ - where: { - isActive: true, - code: Not(In([...LEGACY_YARD_CODES])), - }, + where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), this.containerTypesRepository.findAll({ diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 607a0d7a4..068ed53af 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => { findById: jest.fn().mockResolvedValue(booking), }; const ruleEngineService = { - instantiateApprovalSteps: jest.fn().mockResolvedValue([]), + assertNoHardBlocks: jest.fn(), + }; + const contractService = { + generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }), }; const service = new BookingTransitionService( bookingsRepository as never, ruleEngineService as never, {} as never, // pricingService - {} as never, // contractService + contractService as never, {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService @@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { dutySlipUploadedToStaff: jest.fn(), } as never, // notifier ); - return { service, bookingsRepository, ruleEngineService }; + return { service, bookingsRepository, ruleEngineService, contractService }; } it('rejects accept when validity days is missing or non-positive', async () => { @@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { const [id, updates] = bookingsRepository.update.mock.calls[0]; expect(id).toBe('b-1'); expect(updates).toMatchObject({ - status: 'PENDING_APPROVAL', + status: 'APPROVED', approvedByStaffId: 'staff-1', contractValidityDays: 10, }); @@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => { expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime()); }); - it('instantiates the approval chain when accepting', async () => { - const { service, ruleEngineService } = makeService(); + it('approves outright and generates the contract (no approval chain)', async () => { + const { service, contractService } = makeService(); await service.acceptIntake('b-1', 'staff-1', 30); - expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ freightType: 'CONTAINER' }), - ); + expect(contractService.generateContract).toHaveBeenCalledWith('b-1'); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 9201e4fa9..cbbb999ef 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** @@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => { ); }); }); + +/** + * Export over-book gate at the customer's requestOperation step: export never + * splits, so the free-space check runs the moment the customer commits to a + * shipment day. When no single export train that day can carry the whole + * booking, `pickExportSchedule` throws and the request is refused BEFORE the + * booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated + * here (they are batched + splittable later). + */ +describe('BookingTransitionService — requestOperation export space gate', () => { + function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: 'CLEARANCE_READY', + tradeDirection, + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest + .fn() + .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), + }; + const bookingBatchService = { + // Over-book → the export gate rejects; otherwise it returns a schedule id. + pickExportSchedule: overbook + ? jest.fn().mockRejectedValue(new ConflictException('Not enough train space')) + : jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRequestedToStaff: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + ); + return { service, bookingsRepository, bookingBatchService }; + } + + it('rejects an over-booked export request and does NOT advance the booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + true, + ); + await expect( + service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('lets an export request through when a train fits the whole booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + false, + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); + + it('never runs the export gate for an import request', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'IMPORT', + true, // would reject IF called — proves it is not called + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled(); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2aed86027..36705daa0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,9 +7,8 @@ import { Logger, Optional, } from "@nestjs/common"; -import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { OnEvent } from "@nestjs/event-emitter"; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; @@ -42,6 +41,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -247,16 +247,6 @@ export class BookingTransitionService { return fresh; } - /** Auto-create booking approval steps from system rules when none exist yet. */ - private async ensureBookingApprovalSteps(booking: Booking): Promise { - if ((booking.approvalSteps?.length ?? 0) > 0) return; - - await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - } - async acceptIntake( bookingId: string, actorId: string, @@ -282,21 +272,33 @@ export class BookingTransitionService { const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "PENDING_APPROVAL", + // Bookings no longer run a multi-step approval chain — accepting the intake + // approves the booking outright and generates its contract. (The approval + // chain is a contract-only concern now; see contract-transition.service.) + await this.bookingsRepository.update(bookingId, { + status: "APPROVED", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - const fresh = await this.bookingsService.findById(updated!.id); + + // Generating the contract is best-effort: the acceptance is already + // committed, so a failure here must not roll it back. The booking stays + // APPROVED and staff can retry generation from the booking page. + try { + await this.contractService.generateContract(bookingId); + } catch (err) { + this.logger.warn( + `Contract generation failed after accepting booking ${bookingId}: ${err}. ` + + `The booking is APPROVED — retry generation from the booking page.`, + ); + } + + const fresh = await this.bookingsService.findById(bookingId); this.notifier.accepted(fresh); + this.notifier.approved(fresh); return fresh; } @@ -323,140 +325,6 @@ export class BookingTransitionService { return fresh; } - async approveStep( - bookingId: string, - stepId: string, - actorId: string, - requiredRole: string, - authUser?: TCurrentUser, - ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - - let booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - if ((booking.approvalSteps?.length ?? 0) === 0) { - await this.ensureBookingApprovalSteps(booking); - booking = await this.bookingsService.findById(bookingId); - } - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step || step.status !== "PENDING") { - throw new BadRequestException( - "Approval step not found or already actioned", - ); - } - - const next = - await this.bookingsRepository.findNextPendingApprovalStep(bookingId); - if (!next || next.id !== step.id) { - throw new BadRequestException( - "Approval steps must be completed in order", - ); - } - - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - - const blocksRole = step.blocksRole; - if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException( - `Role ${requiredRole} is blocked for this step`, - ); - } - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "APPROVED", - ); - - const updates: Record = {}; - const now = new Date(); - - if (requiredRole === "LINE_STAFF") { - updates.status = "APPROVED_PENDING_SIGNATURE"; - updates.approvedByStaffId = actorId; - updates.approvedByStaffAt = now; - } else if (requiredRole === "DIRECTOR") { - updates.signedByDirectorId = actorId; - updates.signedByDirectorAt = now; - } else if (requiredRole === "CEO") { - updates.signedByCeoId = actorId; - updates.signedByCeoAt = now; - } - - const allDone = - await this.bookingsRepository.allApprovalStepsComplete(bookingId); - if (allDone) { - updates.status = "APPROVED"; - } - - if (Object.keys(updates).length > 0) { - await this.bookingsRepository.update(bookingId, updates as never); - } - - if (allDone) { - const generated = await this.contractService.generateContract(bookingId); - const fresh = await this.bookingsService.findById(generated.id); - this.notifier.approved(fresh); - return fresh; - } - - return this.bookingsService.findById(bookingId); - } - - async rejectStep( - bookingId: string, - stepId: string, - actorId: string, - reason: string, - ): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step) throw new BadRequestException("Approval step not found"); - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "REJECTED", - reason, - ); - - await this.bookingsRepository.createReviewNote( - bookingId, - reason, - "REJECTION", - actorId, - ); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "REJECTED", - } as never); - const fresh = await this.bookingsService.findById(updated!.id); - this.notifier.rejected(fresh, reason); - return fresh; - } - async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["CONTRACT_READY"]); @@ -482,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); @@ -973,6 +857,22 @@ export class BookingTransitionService { } } + // Intercity: there is no shipment-day request step — an approved booking + // goes straight to FULLY_EXECUTED, which is what the intercity ride-along + // pool keys on. Staff then accept it onto a passing train (that accept + // opens the pay window). + if (booking.tradeDirection === "DOMESTIC") { + const now = new Date(); + await this.bookingsRepository.update(bookingId, { + status: "FULLY_EXECUTED", + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.intercityDocumentsApproved(fresh); + return fresh; + } + await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); @@ -1036,6 +936,40 @@ export class BookingTransitionService { ); } + // Export is FCFS and never splits — a booking must ride one train whole. So + // the free-space check belongs HERE, the moment the customer commits to a + // shipment day, not later at staff operation-accept. Blocking now stops the + // customer booking more wagons than any single export train that day can + // still carry; `exportSpaceReport` throws a 409 whose message carries the + // largest bookable leftover ("reduce to N wagons or pick another day"). + // Import/domestic bookings are batched + splittable, so they are NOT gated + // here — they get an advisory count below and the batch engine sizes them. + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + if (isExportTrain) { + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } + } + await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, @@ -1045,6 +979,47 @@ export class BookingTransitionService { return fresh; } + /** + * Advisory availability for a shipment day the customer is considering — a + * planning hint for the day picker, computed but never enforced. For EXPORT it + * mirrors the real request-time gate: `fits` is whether a single open train + * that day can carry the WHOLE booking (export never splits), and `freeWagons` + * is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the + * TOTAL room across the day's trains for the booking's wagon type (the batch + * engine may still split or defer a remainder), and `fits` is whether that + * total covers the booking. `trainsForDay` is false when no departure carries + * the leg — the day is unbookable regardless of space. + */ + async dayAvailabilityForBooking( + bookingId: string, + scheduledDate: string, + ): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + const day = eatDay(date); + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + + if (isExportTrain) { + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const report = + await this.bookingBatchService.exportSpaceReport(scheduledBooking); + return { + fits: report.scheduleId != null, + freeWagons: report.bestAvailable?.wagons ?? 0, + trainsForDay: report.trainsForDay && report.corridorMatched, + }; + } + + const { freeWagons, need, trainsForDay } = + await this.bookingBatchService.dayImportAvailability(booking, day); + return { fits: freeWagons >= need, freeWagons, trainsForDay }; + } + /** * Operations team reviews a pending operation request (capacity, documents, * route). Two outcomes: @@ -1222,12 +1197,9 @@ export class BookingTransitionService { } let nextStep: BookingNextStep | null = null; try { - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - nextStep = computeNextStep(booking, nextPending); + // Bookings no longer carry an approval chain, so there is never a pending + // approval step to hint at. + nextStep = computeNextStep(booking, null); } catch (err) { this.logger.warn( `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 53554b2e3..c93c61f13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -52,10 +52,8 @@ 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, @@ -370,6 +368,31 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } + @Get(':id/day-availability') + @ApiOperation({ + summary: + 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + + 'Export: whole-booking fit + largest single-train leftover. ' + + 'Import/domestic: total room across the day for the booking\'s wagon type.', + }) + async dayAvailability( + @Param('id', ParseUUIDPipe) id: string, + @Query('date') date: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.dayAvailabilityForBooking(id, date); + } + @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', @@ -413,18 +436,26 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') - @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + @ApiOperation({ + summary: + 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, + @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } + const extraCopyIndexes = (copies ?? '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id); + await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); @@ -457,6 +488,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( @@ -998,47 +1043,6 @@ export class BookingsController { 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" }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e7806c13c..38d7d2ac6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; @@ -47,6 +46,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; @@ -59,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; Booking, BookingContainer, BookingCargoModifier, - BookingApprovalStep, BookingDocumentReview, BookingRateSnapshot, BookingReviewNote, @@ -106,6 +105,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, + ContractRateScheduleBuilder, ContractRendererService, ContractPdfService, CustomerTruckAssignmentsRepository, 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 72211925f..590bd7262 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,11 +4,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -113,7 +113,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'oy') .leftJoinAndSelect('booking.destinationYard', 'dy') .leftJoinAndSelect('booking.shippingLine', 'sl') - .leftJoinAndSelect('booking.approvalSteps', 'steps') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') @@ -149,7 +148,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +178,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) @@ -431,58 +433,6 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } - /** Lowest-order pending approval step (sequential enforcement). */ - async findNextPendingApprovalStep( - bookingId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, status: 'PENDING' }, - order: { stepOrder: 'ASC' }, - }); - } - - async findApprovalStepById( - bookingId: string, - stepId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, id: stepId }, - }); - } - - /** Get pending approval step for a role (must match next in sequence). */ - async findPendingApprovalStep( - bookingId: string, - requiredRole: string, - ): Promise { - const next = await this.findNextPendingApprovalStep(bookingId); - if (!next || next.requiredRole !== requiredRole) return null; - return next; - } - - /** Mark an approval step complete. */ - async completeApprovalStep( - stepId: string, - actorId: string, - status: 'APPROVED' | 'REJECTED', - remarks?: string, - ): Promise { - await this.dataSource.getRepository(BookingApprovalStep).update(stepId, { - status, - actionedByStaffId: actorId, - actionedAt: new Date(), - remarks, - }); - } - - /** Check if all approval steps are approved. */ - async allApprovalStepsComplete(bookingId: string): Promise { - const pending = await this.dataSource.getRepository(BookingApprovalStep).count({ - where: { bookingId, status: 'PENDING' }, - }); - return pending === 0; - } - // ── Clearance document reviews ──────────────────────────────────────────── findDocumentReviews(bookingId: string): Promise { @@ -669,7 +619,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .where('booking.status IN (:...statuses)', { statuses }); if (options.excludeBulk) { @@ -718,7 +667,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on // Booking → contract, so join the entity by id and select just the @@ -1283,6 +1231,23 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Same as {@link findAllBySchedule} but for a page of schedules at once — + * one query instead of one per schedule (batch monitoring board). */ + findAllBySchedules(scheduleIds: string[]): Promise { + if (!scheduleIds.length) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository @@ -1338,6 +1303,10 @@ export class BookingsRepository extends BaseRepository { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ where: { id: In(bookingIds) }, + // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins + // multiply rows badly in a single join (hot path for every allocation + // preview / assignment validation). + relationLoadStrategy: 'query', relations: { company: true, originYard: true, 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 588e4f1ed..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,12 +12,12 @@ 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'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -142,8 +142,21 @@ export class BookingsService { return this.findById(bookingId); } + /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ + static readonly FREIGHT_ORDER_EXTRA_COPIES = [ + 'Original 1 (for Issuing Carrier)', + 'Original 2 (for Consignee)', + 'Original 3 (for Shipper)', + 'Copy 4 (Delivery Receipt)', + 'Copy 5 (Extra Copy)', + 'Copy 6 (Extra Copy)', + 'Copy 7 (Extra Copy)', + 'Copy 8 (for Agent)', + ] as const; + async customerTruckFreightOrderCopies( bookingId: string, + extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { @@ -171,7 +184,12 @@ export class BookingsService { [bookingId], ); - const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + // The 2 gate copies are ALWAYS printed; the waybill-style copies are + // whatever the customer ticked (indexes into the fixed catalog). + const extraCopies = [...new Set(extraCopyIndexes)] + .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) + .filter(Boolean); + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { @@ -268,6 +286,7 @@ export class BookingsService { arrivedAt: string | null; containers: string | null; }>, + extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt @@ -386,6 +405,7 @@ export class BookingsService { ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} + ${extraCopies.map((label) => copy(label)).join('')} `; } @@ -422,6 +442,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -438,7 +460,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); @@ -467,6 +489,8 @@ export class BookingsService { isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId ?? null, + destinationYardId: dto.destinationYardId ?? null, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, @@ -634,12 +658,9 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // A customer can only book once their company has been approved. - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create bookings yet.", - ); - } + // A customer can only book once their company has been approved; the + // helper names the real status (suspended/blacklisted) when it isn't. + this.companiesService.assertCompanyActiveFor(company, 'bookings'); companyId = company.id; } @@ -745,21 +766,13 @@ export class BookingsService { ); companyProfileId = profile.id; } else if (companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } + // No explicit profile pin: resolve from the booking's trade direction + // (import→importer, export→exporter; otherwise the first profile). A + // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, - fallbackType, ); // A customer booking under their own account may only do so once the @@ -799,6 +812,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -1009,6 +1024,8 @@ export class BookingsService { isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + originYardId: dto.originYardId ?? existing.originYardId, + destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); @@ -1067,9 +1084,6 @@ export class BookingsService { await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, - existing.companyProfileId - ? undefined - : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); @@ -1227,9 +1241,9 @@ export class BookingsService { /** * Batched version of the findById flag: marks each page item whose booking - * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal - * dashboard) can show "Approve delivery" for exactly the generated→signed - * window. One query for the whole page. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list + * rows (portal dashboard) can show "Approve delivery" for exactly the + * generated→signed window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); @@ -1238,8 +1252,7 @@ export class BookingsService { `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) - AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1391,15 +1404,6 @@ export class BookingsService { } } - /** - * Resolve the active company_profile id a customer's bookings should be - * scoped to (importer/exporter mode). Null when not onboarded — callers fall - * back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - return this.companiesService.resolveActiveCompanyProfileId(userId); - } - /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong @@ -1582,14 +1586,12 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned SELF_HAUL handover means the customer must approve - // delivery from the portal (booking-based, one per booking). EDR last-mile - // handovers are per delivering truck and signed by the receiver at the door, - // so they never surface the portal "Approve delivery" action. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 969de5583..0e858e702 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -1,7 +1,10 @@ import { clearanceSettingCode, clearanceOutputSettingCode, + clearanceCodesForBooking, + INTERCITY_DOCUMENTS_SETTING_CODE, } from './clearance.util'; +import type { Booking } from './entities/booking.entity'; describe('clearance.util — clearanceSettingCode', () => { it('resolves import container with/without customs', () => { @@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => { ); }); - it('returns null for DOMESTIC (no clearance gate)', () => { - expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); - expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + }); +}); + +describe('clearance.util — clearanceCodesForBooking (intercity)', () => { + const base = { + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + serviceType: null, + customsClearingEnabled: false, + }; + + it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => { + const general = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'GENERAL', + } as unknown as Booking); + expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + expect(general.outputCode).toBeNull(); + + const direct = clearanceCodesForBooking({ + ...base, + contractId: null, + contractKind: null, + } as unknown as Booking); + expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + }); + + it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + const drawdown = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'ONE_TIME', + } as unknown as Booking); + expect(drawdown.inputCode).toBeNull(); + expect(drawdown.outputCode).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 1cc6503df..5a63beca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity'; type Op = 'import' | 'export'; type Freight = 'container' | 'bulk'; +/** + * The single (admin-configured) document set intercity shipments upload. + * DOMESTIC has no customs, so one shared set serves contracts and bookings: + * ONE_TIME collects it at contract level, GENERAL per booking — Operations + * reviews either way. + */ +export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; + /** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ function operationFor(tradeDirection: string): Op | null { if (tradeDirection === 'IMPORT') return 'import'; if (tradeDirection === 'EXPORT') return 'export'; - return null; // DOMESTIC / intercity — no clearance gate + return null; // DOMESTIC / intercity — no customs operation } function freightFor(freightType: string): Freight { @@ -26,6 +34,9 @@ export function clearanceSettingCode( freightType: string, includesCustoms: boolean, ): string | null { + // Intercity: no customs, but the admin-configured intercity document set is + // still collected and ops-reviewed before the shipment may board a train. + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); @@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); + // Intercity drawdowns under a ONE_TIME contract already cleared the intercity + // document set on the CONTRACT (post-signature); only GENERAL drawdowns and + // direct (contract-less) bookings carry the per-booking set. + if ( + booking.tradeDirection === 'DOMESTIC' && + booking.contractId && + booking.contractKind === 'ONE_TIME' + ) { + return { inputCode: null, outputCode: null, includesCustoms: false }; + } return { inputCode: clearanceSettingCode( booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index f366faa96..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { + EDR_HAULAGE_CONFLICT_MESSAGE, + usesEdrMileService, +} from '../../common/mile-haulage.util'; +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + bookingContainerSizes, + remainingBulkTons, +} from '../../common/truck-load.util'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; @@ -67,39 +78,27 @@ export class CustomerTruckService { if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); + + // Bulk is capped by tonnage, not container count: trucks may be added until + // the booking's declared weight has been hauled away. Container bookings are + // capped below by #trucks <= #containers. + if (isBulk) { + const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); + assertBulkTonnageRemains(totalTons, remainingTons); } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); - // Never assign more trucks than the booking has containers. const existingTrucks = await this.dataSource .getRepository(CustomerTruckAssignment) .count({ where: { bookingId } }); - if (existingTrucks + 1 > bookingNumbers.length) { - throw new BadRequestException( - `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, - ); - } - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - // Size cap: a 40ft container fills the truck. - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length); + assertTruckLoad({ + containers: requested, + bookingContainers: bookingNumbers, + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbers(bookingId), + }); } await this.dataSource.transaction(async (manager) => { @@ -191,28 +190,13 @@ export class CustomerTruckService { if (requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); - } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - // Exclude THIS truck's own containers so re-saving the same set is allowed. - const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (assignedElsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { @@ -340,27 +324,12 @@ export class CustomerTruckService { } // Capacity is size-based: a truck carries at most 2 containers, and a 40ft // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); - } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — load only 1 container onto this truck', - ); - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { @@ -534,18 +503,11 @@ export class CustomerTruckService { } private assertSelfHaulPaid(booking: BookingGuardRow): void { - const hasFirstMile = Boolean(booking.firstMile?.trim()); - const hasLastMile = Boolean(booking.lastMile?.trim()); - const usesMileService = - booking.tradeDirection === 'IMPORT' - ? hasLastMile - : booking.tradeDirection === 'EXPORT' - ? hasFirstMile - : hasFirstMile || hasLastMile; - if (usesMileService) { - throw new BadRequestException( - 'Customer truck assignment is only allowed when first/last mile delivery is not selected', - ); + // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two + // halves of this rule cannot drift apart — they did, and a booking ended up + // with a customer truck and an EDR leg at once. + if (usesEdrMileService(booking)) { + throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { throw new BadRequestException( @@ -614,18 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ - private async containerSizes(bookingId: string, numbers: string[]): Promise { - if (!numbers.length) return []; - const rows: Array<{ size: string | null }> = await this.dataSource.query( - `SELECT bc.container_size AS "size" - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 - AND UPPER(bcu.container_number) = ANY($2) - AND bcu.deleted_at IS NULL`, - [bookingId, numbers], - ); - return rows.map((r) => (r.size ?? '').trim()); + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts deleted file mode 100644 index 68018e883..000000000 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity'; -import { Booking } from './booking.entity'; - -export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const; -export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number]; - -@Entity({ schema: 'freight', name: 'booking_approval_step' }) -@Index(['bookingId']) -@Index(['status']) -@Index(['bookingId', 'stepOrder']) -export class BookingApprovalStep extends BaseEntity { - @Column({ name: 'booking_id', type: 'uuid' }) - bookingId!: string; - - @ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'booking_id' }) - booking?: Booking; - - @Column({ name: 'approval_rule_id', type: 'uuid' }) - approvalRuleId!: string; - - @ManyToOne(() => ApprovalRule) - @JoinColumn({ name: 'approval_rule_id' }) - approvalRule?: ApprovalRule; - - @Column({ name: 'step_order', type: 'smallint' }) - stepOrder!: number; - - @Column({ name: 'required_role', type: 'varchar', length: 30 }) - requiredRole!: string; - - @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) - blocksRole?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) - status!: ApprovalStepStatus; - - @Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true }) - actionedByStaffId?: string | null; - - @Column({ name: 'actioned_at', type: 'timestamptz', nullable: true }) - actionedAt?: Date | null; - - @Column({ name: 'remarks', type: 'text', nullable: true }) - remarks?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** This container ships back empty after unloading (equipment return). */ + @Column({ name: 'is_return', type: 'boolean', default: false }) + isReturn!: boolean; + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index f74cb515e..2aa3ee8c2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Train } from '../../trains/entities/train.entity'; import { FileRecord } from '../../files/entities/file.entity'; -import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingContainerAllocation } from './booking-container-allocation.entity'; @@ -557,8 +556,6 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; - @OneToMany(() => BookingApprovalStep, (s) => s.booking) - approvalSteps?: BookingApprovalStep[]; @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 6eeaba963..3892d2a97 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) grossWeightKg?: number | null; + /** Empty truck weight at the gate, in tonnes. Null until the truck departs. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + /** + * Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown: + * a bulk booking is hauled until the sum of this across departed trucks + * reaches its declared VGM. Mirrors last_mile_vehicle_assignments. + */ + @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + netWeightTons?: number | null; + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index 7f3f06ec2..ac3adb7e8 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView() +@FleetView(FREIGHT_PERMS.cargoes.view) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.create) @ApiOperation({ summary: 'Create a new cargo' }) create(@Body() dto: CreateCargoDto) { return this.cargoesService.create(dto); @@ -43,35 +44,35 @@ export class CargoesController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Update a cargo' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { return this.cargoesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.delete) @ApiOperation({ summary: 'Delete a cargo' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.remove(id); } @Post(':id/load') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Load cargo into a container' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { return this.cargoesService.loadCargo(id, dto); } @Post(':id/unload') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Unload cargo from container' }) unload(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.unloadCargo(id); } @Post(':id/deliver') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Mark cargo as delivered' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { return this.cargoesService.deliverCargo(id, dto); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8db9eba66..43d70ce19 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; -import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -48,6 +47,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -352,21 +352,6 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } - @Patch("active-mode") - @ApiOperation({ - summary: "Switch the current user's active operational mode (importer/exporter)", - }) - async setActiveMode( - @CurrentUser() user: CurrentIamUser, - @Body() dto: SetActiveModeDto, - ): Promise { - const { profile, company } = await this.companiesService.setActiveMode( - user.id, - dto.type, - ); - return new CompanyInfoResponseDto(profile, company); - } - @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) @@ -494,6 +479,9 @@ export class CompaniesController { mimeType: f.mimeType, size: f.size, uploadedAt: f.createdAt, + reviewStatus: f.reviewStatus, + reviewNote: f.reviewNote, + reviewedAt: f.reviewedAt, // Raw `f.url` is an un-signed MinIO path the browser can't open — sign // it so the file previews/downloads in the client. url: f.url ? await this.filesService.signUrl(f.url) : f.url, @@ -501,6 +489,35 @@ export class CompaniesController { ); } + @Post("documents/:fileId/request-change") + @FreightAdmin() + @ApiOperation({ + summary: "Ask the customer to correct one uploaded document", + description: + "Flags a single document with a reason the customer sees, notifies them, " + + "and blocks role approval until they re-upload. Narrower than rejecting " + + "the whole role.", + }) + async requestDocumentChange( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + @Body() dto: RequestDocumentChangeDto, + ) { + const file = await this.companiesService.requestDocumentChange( + fileId, + dto.note, + user.id, + ); + return { + id: file.id, + name: file.name, + code: file.code, + reviewStatus: file.reviewStatus, + reviewNote: file.reviewNote, + reviewedAt: file.reviewedAt, + }; + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,51 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + + /** + * A company waiting on a reviewer to decide an edit it submitted after being + * approved. These rows are `status = active`, so the pending-application filter + * can never surface them — the review queue needs its own predicate. + */ + private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = company.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; + + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +83,25 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + hasPendingChangeRequest, + sortBy = 'review', + sortOrder = 'DESC', + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +116,22 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + + if (hasPendingChangeRequest !== undefined) { + qb.andWhere( + hasPendingChangeRequest + ? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL + : `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -73,8 +148,22 @@ export class CompaniesRepository extends BaseRepository { ); } + // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy('company.name', 'ASC') + // Names are not unique and createdAt can tie on bulk imports; the id + // tiebreaker keeps paging stable instead of dropping/repeating rows. + .addOrderBy('company.id', 'ASC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); @@ -83,23 +172,44 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const pendingChanges = await this.repository + .createQueryBuilder('company') + .where('company.deleted_at IS NULL') + .andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL) + .getCount(); + + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, + pendingChanges, }; } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; +import { DataSource } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -98,6 +99,7 @@ export class CompaniesService { private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, + private readonly dataSource: DataSource, ) { } /** @@ -199,18 +201,6 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); - // Default active mode from the chosen role(s): importer wins when both are - // picked, otherwise the first allowed type chosen. - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - const chosenTypes = (dto.companyProfiles ?? []) - .map((p) => p.type) - .filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; - const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -218,7 +208,6 @@ export class CompaniesService { lastName: identity.lastName, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, - activeProfileType, onboardingStep: "company", }); @@ -291,11 +280,6 @@ export class CompaniesService { const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; const company = await this.companiesRepo.create({ name: identity.firstName @@ -314,7 +298,6 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); @@ -372,6 +355,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -745,7 +731,15 @@ export class CompaniesService { submittedAt: now, note: null, })) ?? existing; + this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { + // Rejecting a request leaves it Rejected rather than reopening it, so a + // customer amending after a rejection lands here with a fresh Pending row. + // That is the resubmission case the reviewer needs flagged. + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); request = await this.changeRequestRepo.create({ companyId: company.id, snapshot: fields, @@ -753,6 +747,11 @@ export class CompaniesService { submittedBy: userId, submittedAt: now, }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); } // Live company is unchanged; surface the pending state for the settings page. @@ -824,6 +823,12 @@ export class CompaniesService { "companies", files, ); + await this.resolveDocumentChangeRequests( + companyId, + "companies", + uploaded.map((f) => f.code), + uploaded.map((f) => f.id), + ); if (company.status === CompanyStatus.Active) { await this.stageDocumentChange( company.id, @@ -834,6 +839,95 @@ export class CompaniesService { return uploaded; } + /** + * Clear the `change_requested` flag from the documents a fresh upload replaces. + * + * Uploading does not overwrite the old row — it adds a new one under the same + * `code` — so the flagged original would otherwise linger and keep the approval + * gate closed even after the customer did exactly what was asked. Only rows of + * the same code are touched, and never the newly uploaded ones. + */ + private async resolveDocumentChangeRequests( + resourceId: string, + resource: string, + codes: string[], + uploadedIds: string[], + ): Promise { + if (codes.length === 0) return; + const replaced = new Set(codes); + const fresh = new Set(uploadedIds); + const open = await this.filesService.findWithOpenChangeRequest( + [resourceId], + resource, + ); + await Promise.all( + open + .filter((f) => replaced.has(f.code) && !fresh.has(f.id)) + .map((f) => this.filesService.clearReview(f.id)), + ); + } + + /** + * Backoffice: ask the customer to correct one specific document, instead of + * rejecting their whole role over it. Mirrors the contract change-request + * flow — a note the customer sees verbatim, plus a block on approval until + * they re-upload. + */ + async requestDocumentChange( + fileId: string, + note: string, + reviewerId?: string, + ): Promise { + const file = await this.filesService.findById(fileId); + const companyId = await this.resolveDocumentCompanyId(file); + const company = await this.findCompanyById(companyId); + + // Flag the document while holding a write lock on its company row. The + // approval gate takes the same lock before it reads the flags, so the two + // serialize: a change request can never land in the window between the gate + // checking "any open corrections?" and writing the profile Active. + const updated = await this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: companyId }, + lock: { mode: "pessimistic_write" }, + }); + return this.filesService.setReviewStatus( + file.id, + "change_requested", + note, + reviewerId, + ); + }); + this.companyNotifier.documentChangeRequested( + company, + file.name, + note, + file.id, + ); + return updated; + } + + /** + * Which company a stored document belongs to. Company documents are keyed by + * the company id directly; profile licences and POA letters hang off a company + * profile, so those resolve through it. + */ + private async resolveDocumentCompanyId(file: FileRecord): Promise { + if (file.resource === "companies") return file.resourceId; + if (file.resource === "company_profiles") { + const profile = await this.companyProfilesRepo.findById(file.resourceId); + if (!profile) { + throw new NotFoundException( + `Company profile ${file.resourceId} not found`, + ); + } + return profile.companyId; + } + throw new BadRequestException( + `Documents on "${file.resource}" do not support change requests`, + ); + } + /** Open or append a pending change request recording staged document uploads. */ private async stageDocumentChange( companyId: string, @@ -844,6 +938,7 @@ export class CompaniesService { const now = new Date(); const existing = await this.changeRequestRepo.findPendingByCompanyId(companyId); + const company = await this.companiesRepo.findById(companyId); if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { @@ -857,8 +952,15 @@ export class CompaniesService { submittedAt: now, note: null, }); + if (company) { + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + } } else { - await this.changeRequestRepo.create({ + const history = await this.changeRequestRepo.findByCompanyId(companyId); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const created = await this.changeRequestRepo.create({ companyId, snapshot: {}, documents: { documentFileIds: fileIds }, @@ -866,6 +968,13 @@ export class CompaniesService { submittedBy: submittedBy ?? null, submittedAt: now, }); + if (company) { + this.companyNotifier.changeRequestSubmitted( + company, + created.id, + resubmitted, + ); + } } } @@ -962,6 +1071,100 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + + // Anything other than approval has no document gate and no concurrency + // hazard — apply it directly. + if (status !== ProfileStatus.Active) { + return this.applyProfileStatus(existing, status, note, reviewerId); + } + + // Approving over an outstanding document correction would silently accept the + // very document a reviewer just rejected, and would strand the customer's + // "please fix this" banner with nothing left to fix. The gate check and the + // status write share a write lock on the company row — `requestDocumentChange` + // takes the same lock, so a fresh correction can never land in the window + // between "any open corrections?" and the profile going Active. Suspend and + // blacklist skip all this — staff must always be able to act against a bad + // account. + return this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: existing.companyId }, + lock: { mode: "pessimistic_write" }, + }); + + const [companyDocs, profileDocs] = await Promise.all([ + this.filesService.findWithOpenChangeRequest( + [existing.companyId], + "companies", + ), + this.filesService.findWithOpenChangeRequest( + [existing.id], + "company_profiles", + ), + ]); + const pending = [...companyDocs, ...profileDocs]; + if (pending.length > 0) { + const names = pending.map((f) => f.name).join(", "); + throw new BadRequestException( + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + + `Approve it once the customer has re-uploaded them, or withdraw the change request first.`, + ); + } + + return this.applyProfileStatus(existing, status, note, reviewerId); + }); + } + + /** + * Write a reviewed profile status (reference minting, note handling, reviewer + * stamp) and promote the company if this is its first approved role. Split out + * of `setCompanyProfileStatus` so the approval path can run it inside the gate + * transaction while every other status skips that overhead. + */ + private async applyProfileStatus( + existing: CompanyProfile, + status: ProfileStatus, + note?: string, + reviewerId?: string, + ): Promise { // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -971,9 +1174,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -983,27 +1190,54 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(profileId, patch); + const updated = await this.companyProfilesRepo.update(existing.id, patch); if (!updated) - throw new NotFoundException(`Company profile ${profileId} not found`); + throw new NotFoundException(`Company profile ${existing.id} not found`); - // Approving any profile promotes a pending company to active, so the - // customer can start working as soon as their first profile is cleared. - if (status === ProfileStatus.Active) { + // Every reviewed transition that changes what the customer can do is told + // to them, carrying the staff message so they know why. Approval has no + // message (the note is cleared); the others require one. + const change = + status === ProfileStatus.Suspended + ? "suspended" + : status === ProfileStatus.Rejected + ? "rejected" + : status === ProfileStatus.Active + ? existing.status === ProfileStatus.Suspended + ? "reactivated" + : "approved" + : null; + if (change) { const company = await this.companiesRepo.findById(updated.companyId); - if (company && company.status === CompanyStatus.Pending) { - await this.companiesRepo.update(updated.companyId, { - status: CompanyStatus.Active, - }); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + change, + note ?? "", + ); + // The first approved role promotes a pending company to active — a + // bigger event (the account itself goes live), so tell them that too. + if ( + status === ProfileStatus.Active && + company.status === CompanyStatus.Pending + ) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + this.companyNotifier.companyApproved(company); + } } } return updated; } /** - * Customer reapplies for a rejected operational role (after fixing whatever the - * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and - * clear the rejection note so it re-enters the approval queue. + * Customer reapplies for a rejected or suspended operational role (after + * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it + * back to Pending and clear the review note so it re-enters the approval + * queue. Suspension is a staff lockout, so resubmitting is an appeal — the + * backoffice still has to approve before the role goes live again. */ async reapplyCompanyProfile( userId: string, @@ -1018,9 +1252,12 @@ export class CompaniesService { if (!target || target.companyId !== companyId) { throw new NotFoundException(`Company profile ${profileId} not found`); } - if (target.status !== ProfileStatus.Rejected) { + if ( + target.status !== ProfileStatus.Rejected && + target.status !== ProfileStatus.Suspended + ) { throw new BadRequestException( - "Only a rejected role can be resubmitted for approval", + "Only a rejected or suspended role can be resubmitted for approval", ); } @@ -1032,6 +1269,13 @@ export class CompaniesService { }); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // The role is back in the pending queue — tell the reviewers, otherwise the + // resubmission is invisible until someone happens to reopen the customer. + const company = await this.companiesRepo.findById(companyId); + if (company) { + this.companyNotifier.roleReapplied(company, updated.id, updated.type); + } return updated; } @@ -1137,10 +1381,9 @@ export class CompaniesService { /** * Create a single operational profile for the current user's company. The new - * role starts Pending, so it deliberately does NOT become the active mode: - * switching onto an unapproved profile would strip the user of `canBook` and - * block them from creating contracts under the role they already had approved. - * Callers switch explicitly via {@link setActiveMode} once the role is Active. + * role starts Pending and carries no reference until a backoffice reviewer + * approves it; a booking/contract resolves its profile from the trade + * direction at creation time, so no "active mode" is stored. */ async createCompanyProfileForUser( userId: string, @@ -1175,40 +1418,6 @@ export class CompaniesService { return created; } - /** - * Switch the user's active operational mode. The target profile must already - * exist — clients create it first via createCompanyProfileForUser. - */ - async setActiveMode( - userId: string, - type: ProfileType, - ): Promise<{ profile: ExternalProfile; company: Company }> { - const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) - throw new NotFoundException(`Profile for user ${userId} not found`); - - const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - if (!allowedTypes.includes(type)) { - throw new BadRequestException( - `Profile type "${type}" is not allowed for company type "${company.type}"`, - ); - } - - const existing = await this.companyProfilesRepo.findByType(companyId, type); - if (!existing) { - throw new ConflictException( - `No ${type} profile exists yet — create it before switching`, - ); - } - - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - - return this.getCompanyInfoByUserId(userId); - } - async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -1399,21 +1608,68 @@ export class CompaniesService { } /** - * Block a customer from booking under a profile that isn't approved yet. - * Called from the booking-create path for self-service bookings; staff- and - * government-initiated bookings bypass this. No-op when the profile can't be - * found (defensive — resolution is best-effort upstream). + * Block a self-service action when the company account isn't active, naming + * the actual status — a suspended customer told "awaiting approval" has no + * idea what happened or who to call. + */ + assertCompanyActiveFor(company: Company, action: string): void { + if (company.status === CompanyStatus.Active) return; + switch (company.status) { + case CompanyStatus.Suspended: + throw new ForbiddenException( + `Your company account is suspended — you can't create ${action} right now. ` + + `Please contact EDR support for details.`, + ); + case CompanyStatus.Blacklisted: + throw new ForbiddenException( + `Your company account is blacklisted — you can't create ${action}. ` + + `Please contact EDR support.`, + ); + default: + throw new ForbiddenException( + `Your company is awaiting approval — you can't create ${action} yet.`, + ); + } + } + + /** + * Block a customer from booking under a profile that isn't approved yet — or + * that a reviewer has since suspended. Called from the booking/contract + * create path for self-service actions; staff- and government-initiated ones + * bypass this. No-op when the profile can't be found (defensive — resolution + * is best-effort upstream). The message names the profile's real status: + * suspension in particular is per-role, so the customer must learn which + * operation is blocked (their other roles still work). */ async assertCompanyProfileApprovedForBooking( companyProfileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(companyProfileId); if (!profile) return; - if (profile.status !== ProfileStatus.Active) { - const role = profile.type.replace(/_/g, " "); - throw new ForbiddenException( - `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, - ); + if (profile.status === ProfileStatus.Active) return; + + const role = profile.type.replace(/_/g, " "); + switch (profile.status) { + case ProfileStatus.Suspended: + throw new ForbiddenException( + `Your ${role} role is suspended${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Your other roles are unaffected. Please contact EDR support to resolve this.`, + ); + case ProfileStatus.Blacklisted: + throw new ForbiddenException( + `Your ${role} role is blacklisted. Please contact EDR support.`, + ); + case ProfileStatus.Rejected: + throw new ForbiddenException( + `Your ${role} role was rejected${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Amend and resubmit it from your settings page.`, + ); + default: + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`, + ); } } @@ -1487,6 +1743,15 @@ export class CompaniesService { ); } + // A fresh licence upload answers any correction the reviewer asked for on the + // previous one, so the old row must stop blocking approval. + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + uploaded.map((r) => r.id), + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1568,6 +1833,13 @@ export class CompaniesService { await this.filesService.remove(fileId); } + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + [created.id], + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1664,6 +1936,8 @@ export class CompaniesService { : pendingRemoveIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -1830,6 +2104,13 @@ export class CompaniesService { for (const r of live) await this.filesService.remove(r.id); } + await this.resolveDocumentChangeRequests( + company.id, + COMPANY_RESOURCE, + [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE], + [created.id], + ); + return this.getPoaDelegationView(company.id); } @@ -1907,6 +2188,8 @@ export class CompaniesService { : removeIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -2005,15 +2288,14 @@ export class CompaniesService { /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → - * exporter profile; for DOMESTIC or a forwarder/single-profile company (or - * when the natural profile doesn't exist) it falls back to the user's active - * profile, then the company's first profile. Returns null when the company - * has no profiles at all. + * exporter profile; for DOMESTIC (or when the natural profile doesn't exist, + * e.g. a freight forwarder) it falls back to the company's first profile. + * Callers that need a specific role (a forwarder) pass an explicit + * companyProfileId instead. Returns null when the company has no profiles. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, - fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; @@ -2025,30 +2307,12 @@ export class CompaniesService { ? ProfileType.exporter : null; - const byType = (type?: ProfileType | null) => - type ? profiles.find((p) => p.type === type) : undefined; - - const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + const match = + (naturalType && profiles.find((p) => p.type === naturalType)) ?? + profiles[0]; return match?.id ?? null; } - /** - * Resolve the company_profile a customer's data should be scoped to, from - * their persisted active mode. Returns null when nothing can be resolved - * (not onboarded yet) so callers can fall back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - try { - const { profile, company } = await this.getCompanyInfoByUserId(userId); - const type = profile.activeProfileType; - if (!type) return null; - const match = company.companyProfiles?.find((p) => p.type === type); - return match?.id ?? null; - } catch { - return null; - } - } - async fetchETradeData(tin: string) { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 167526988..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { @@ -54,23 +59,106 @@ export class CompanyNotifierService { } } + /** SMS + email + in-app account-status item to the company contact. */ + private notifyAccount( + company: Company, + title: string, + body: string, + link = "/settings", + ): void { + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link, + data: { companyId: company.id, status: company.status }, + priority: NotificationPriority.HIGH, + }); + } + /** - * Tell the customer their account was suspended or blacklisted. Called only on - * a real transition into one of those statuses; other status writes are silent. + * Tell the customer their account changed status. Fires on the transitions + * that change what they can do: suspended/blacklisted (locked out) and + * reactivated (back to Active from a lockout). Silent otherwise. */ statusChanged(company: Company, previous: CompanyStatus): void { const status = company.status; if (status === previous) return; + + if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) { + this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`); + this.notifyAccount( + company, + "Account reactivated", + "Your company account has been reactivated. " + + "You can submit new contracts and bookings again.", + ); + return; + } + if (!PUNITIVE_STATUSES.includes(status)) return; const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; - const title = `Account ${label}`; - const body = - `Your company account has been ${label}. ` + - `You will not be able to submit new contracts or bookings. ` + - `Please contact EDR support for assistance.`; - this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + this.notifyAccount( + company, + `Account ${label}`, + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`, + ); + } + + /** + * Tell the customer their company account was approved and is now live — the + * first operational role clearing review promotes a pending company to Active. + */ + companyApproved(company: Company): void { + this.logger.log(`ACCOUNT_APPROVED — ${company.id}`); + this.notifyAccount( + company, + "Account approved", + "Your company account has been approved and is now active. " + + "You can start submitting bookings and contracts.", + "/dashboard", + ); + } + + /** + * Tell the customer one of their operational roles changed review status — + * approved, rejected, suspended, or reactivated — quoting the staff message + * when one was given (rejection/suspension/reactivation require one; approval + * carries none). + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "approved" | "rejected" | "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence: Record = { + approved: "You can now operate under this role.", + rejected: + "You will not be able to operate under this role. Amend the required " + + "documents and resubmit it for approval from your settings page.", + suspended: + "You will not be able to operate under this role until it is " + + "reactivated; your other roles are unaffected.", + reactivated: "You can operate under this role again.", + }; + const message = staffMessage.trim(); + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence[change]}` + + (message ? ` Message from EDR staff: ${message}` : ""); + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); void this.notifyContact(company, `${title}. ${body}`); void this.inbox.notify({ recipients: { companyId: company.id }, @@ -79,7 +167,109 @@ export class CompanyNotifierService { title, body, link: "/settings", - data: { companyId: company.id, status }, + data: { companyId: company.id, profileType, change, staffMessage: message }, + priority: NotificationPriority.HIGH, + }); + } + + // ── Backoffice-facing: work has arrived back in the review queue ──────────── + + /** + * Persist + push an in-app item to every backoffice staff user, deep-linked to + * the customer's detail page. + * + * The recipient resolver has no role/permission targeting (see + * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest + * selector available, so marketing is reached by notifying all staff. + */ + private notifyStaff( + company: Company, + title: string, + body: string, + data: Record = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/customers/${company.id}`, + data: { companyId: company.id, companyName: company.name, ...data }, + }); + } + + /** + * A customer resubmitted an operational role after it was rejected for + * adjustment. Without this the role silently flips back to Pending and nobody + * is told there is anything to look at again. + */ + roleReapplied(company: Company, profileId: string, profileType: string): void { + this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`); + this.notifyStaff( + company, + "Customer resubmitted a role for approval", + `${company.name} has adjusted and resubmitted its ${profileType} role. ` + + `It is back in the pending approval queue for review.`, + { profileId, profileType }, + ); + } + + /** + * A customer submitted (or amended and resubmitted) a profile change request. + * `resubmitted` distinguishes the two so the reviewer knows this is a second + * look at something they already sent back. + */ + changeRequestSubmitted( + company: Company, + changeRequestId: string, + resubmitted: boolean, + ): void { + this.logger.log( + `CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`, + ); + this.notifyStaff( + company, + resubmitted + ? "Customer resubmitted profile changes" + : "Customer submitted profile changes", + resubmitted + ? `${company.name} has adjusted the changes you sent back and resubmitted ` + + `them. They are pending your review.` + : `${company.name} has submitted profile changes that are pending review.`, + { changeRequestId }, + ); + } + + // ── Customer-facing: a specific document needs correcting ────────────────── + + /** + * Tell the customer a reviewer wants one specific document corrected. Mirrors + * the contract `changesRequested` flow: SMS + email out, plus an in-app item + * deep-linked to the documents tab where they can re-upload. + */ + documentChangeRequested( + company: Company, + documentName: string, + note: string, + fileId: string, + ): void { + const title = "Document change requested"; + const body = + `A reviewer has asked you to correct "${documentName}". ` + + `Reason: ${note} ` + + `Please upload a corrected version from your settings page.`; + + this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title, + body, + link: "/settings", + data: { companyId: company.id, fileId, documentName }, priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -24,7 +24,7 @@ export class CompanyInfoResponseDto { company: Company, changeRequest?: CompanyChangeRequest | null, ) { - this.profile = new ResponseExternalProfileDto(profile, company); + this.profile = new ResponseExternalProfileDto(profile); this.company = new ResponseCompanyDto(company); const open = diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..e97a21266 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,16 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; + /** + * Approved customers with an open profile change request. Counted separately + * because they are `active` and so are invisible to the `pending` KPI, even + * though they are just as much waiting on a reviewer. + */ + pendingChanges!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index eb32f72ae..7816572ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() - @MaxLength(10) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index a56ea5ad8..be911b3ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CreateCompanyDto { @IsString() @@ -17,7 +18,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 2eb37c92d..466c03ed6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,8 +1,9 @@ -import { IsString, IsNotEmpty, Length } from "class-validator"; +import { IsString, IsNotEmpty } from "class-validator"; +import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,44 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; + + @ApiPropertyOptional({ + description: + "`true` = only companies with an open (pending) profile change request. " + + "These are already-approved customers, so they never appear under " + + "`status=pending` and would otherwise be invisible in the review queue.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + hasPendingChangeRequest?: boolean; + + @ApiPropertyOptional({ + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", + }) + @IsOptional() + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts new file mode 100644 index 000000000..8119e041d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RequestDocumentChangeDto { + /** What is wrong with this document — shown verbatim to the customer. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,8 +1,6 @@ -import { Company } from '../entities/company.entity'; import { ExternalProfile, } from '../entities/external-profile.entity'; -import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -13,20 +11,12 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType?: ProfileType | null; - /** - * The id of the company_profile matching activeProfileType, resolved - * server-side so the client never re-derives it. Null until a company - * (with profiles) is loaded and a matching profile exists. - */ - activeCompanyProfileId?: string | null; onboardingStep?: string | null; onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile, company?: Company) { + constructor(profile: ExternalProfile) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -35,13 +25,8 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; - this.activeProfileType = profile.activeProfileType ?? null; this.onboardingStep = profile.onboardingStep ?? null; this.onboardingCompleted = profile.onboardingCompleted ?? false; - this.activeCompanyProfileId = - company?.companyProfiles?.find( - (p) => p.type === profile.activeProfileType, - )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9fd8f28ae..ba3e27aeb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,6 +1,8 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class UpdateProfileDto { @IsOptional() @@ -34,7 +36,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() @@ -137,10 +139,14 @@ export class UpdateProfileDto { @MaxLength(50) renewedTo?: string; + // Zone/woreda/kebele below stay free text: there is no authoritative dataset + // of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns + // them uncoded. Only region is a closed set today. @IsOptional() - @IsString() - @MaxLength(100) - region?: string; + @IsIn(ETHIOPIAN_REGIONS as unknown as string[], { + message: "region must be a recognised Ethiopian region", + }) + region?: EthiopianRegion; @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index ebda7a0b9..9bba9396e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -37,8 +37,20 @@ export interface BusinessLicenseFile { */ export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; +/** + * Reviewer verdict on a document, as surfaced to clients. Distinct from + * {@link StagedFileStatus}: that describes where the file sits in the staged + * add/remove workflow, this describes whether a reviewer wants it corrected. + */ +export interface FileReviewView { + /** `change_requested` while the customer still owes a corrected upload. */ + reviewStatus?: "change_requested" | "approved" | null; + /** The reviewer's reason, shown verbatim to the customer. */ + reviewNote?: string | null; +} + /** A business-license file plus its change-review state, surfaced to clients. */ -export interface ProfileLicenseFileView { +export interface ProfileLicenseFileView extends FileReviewView { id: string; name: string; size: number; @@ -47,7 +59,7 @@ export interface ProfileLicenseFileView { } /** A company-level document (e.g. the PoA letter) with its change-review state. */ -export interface CompanyDocumentFileView { +export interface CompanyDocumentFileView extends FileReviewView { id: string; name: string; size: number; diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,7 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; -import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; - /** - * The operational profile the user is currently "in" (importer vs exporter, - * or the single forwarder profile). Drives header switching and scopes the - * customer's bookings / dashboard to that company_profile. Nullable for - * users who haven't picked a role yet. - */ - @Column({ - name: 'active_profile_type', - type: 'varchar', - length: 32, - nullable: true, - enum: ProfileType, - }) - activeProfileType?: ProfileType | null; - /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ @Column({ name: 'onboarding_step', diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index b588c241f..51bdb2df6 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -6,6 +6,7 @@ import { ETradeCompanyInfo, ETradeBusinessInfo, CompanyRegistrationData, + normalizeRegion, } from "@edr/types"; @Injectable() @@ -108,7 +109,11 @@ export class ETradeService { renewedFrom: businessInfo.RenewedFrom, renewalDate: businessInfo.RenewalDate, renewedTo: businessInfo.RenewedTo, - region: businessInfo.AddressInfo?.Region || "", + // eTrade returns uncoded uppercase text and sometimes a zone name in the + // Region slot. Map it onto the canonical list; an unresolved value yields + // "" so the form asks the user to pick rather than failing validation on + // save with a value they never typed. + region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "", zone: businessInfo.AddressInfo?.Zone || "", woreda: businessInfo.AddressInfo?.Woreda || "", kebele: businessInfo.AddressInfo?.Kebele || "", diff --git a/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts new file mode 100644 index 000000000..910b3199a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts @@ -0,0 +1,54 @@ +import { ETHIOPIAN_REGIONS, normalizeRegion } from '@edr/types'; + +/** + * normalizeRegion lives in @edr/types (no jest there), but it exists to keep + * eTrade autofill from feeding UpdateProfileDto a region its @IsIn will reject. + * That contract is an API concern, so it is guarded here. + */ +describe('normalizeRegion', () => { + it('passes through every canonical region unchanged', () => { + for (const region of ETHIOPIAN_REGIONS) { + expect(normalizeRegion(region)).toBe(region); + } + }); + + it.each([ + ['ADDIS ABABA', 'Addis Ababa'], + ['Addis ababa', 'Addis Ababa'], + [' addis ababa ', 'Addis Ababa'], + ['oromoia', 'Oromia'], + ['OROMIYA', 'Oromia'], + ['gambella', 'Gambela'], + ['TIGRAI', 'Tigray'], + ['benishangul gumuz', 'Benishangul-Gumuz'], + ])('resolves the variant %s', (input, expected) => { + expect(normalizeRegion(input)).toBe(expected); + }); + + it('maps a zone name in the region slot back to its parent region', () => { + // eTrade's own placeholder data does this — "EASTERN TIGRAY" is a zone. + expect(normalizeRegion('EASTERN TIGRAY')).toBe('Tigray'); + expect(normalizeRegion('North Wollo')).toBe('Amhara'); + }); + + it.each([ + ['a city, not a region', 'Arba Minch'], + ['unknown text', 'Nowhere Land'], + ['empty', ''], + ['whitespace only', ' '], + ['null', null], + ['undefined', undefined], + ])('returns null for %s rather than guessing', (_label, input) => { + expect(normalizeRegion(input as string | null | undefined)).toBeNull(); + }); + + it('never returns a value outside the canonical set', () => { + const samples = ['ADDIS ABABA', 'oromoia', 'EASTERN TIGRAY', 'garbage', '']; + for (const s of samples) { + const out = normalizeRegion(s); + if (out !== null) { + expect(ETHIOPIAN_REGIONS).toContain(out); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index b107e8935..579b9ee26 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -10,18 +10,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView() +@FleetView(FREIGHT_PERMS.consignments.view) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.consignments.create) @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 1a0cdb14f..0a5e6bb0f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,12 +19,12 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView() +@FleetView(FREIGHT_PERMS.containers.view) export class ContainersController { constructor(private readonly containersService: ContainersService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.create) @ApiOperation({ summary: 'Create a new container' }) create(@Body() dto: CreateContainerDto) { return this.containersService.create(dto); @@ -42,28 +43,28 @@ export class ContainersController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Update a container' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { return this.containersService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.delete) @ApiOperation({ summary: 'Delete a container' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.remove(id); } @Post(':id/assign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Assign container to a wagon' }) assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { return this.containersService.assignToWagon(id, dto); } @Post(':id/unassign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Unassign container from wagon' }) unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.unassignFromWagon(id); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2aea9bc7..d2755fece 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { RateSchedule } from "../../contracts/contract-rate-schedule.builder"; import { getTemplateMeta } from "../../contracts/contract-template.registry"; import { ContractDynamicTemplateView, @@ -177,17 +178,9 @@ export class ContractTemplatesService { const isBulk = code.endsWith("_BULK"); const now = new Date(); - const unitRates = isBulk - ? [ - { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, - { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, - { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, - ] - : [ - { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, - { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, - { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, - ]; + // Representative rate schedule so the admin preview shows the live-rate + // table shape. Real contracts populate this from freight.rates (LIVE). + const rateSchedule = this.mockRateSchedule(code, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -239,13 +232,16 @@ export class ContractTemplatesService { lastMileDeliveryAddress: "—", }, pricing: { - displayMode: "UNIT_RATES", - unitRates, + lineItems: [], + surcharges: [], + totalAmount: 0, currency: "USD", equipmentReturn: isBulk ? "—" : "With empty return", originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", + containerLines: [], } as unknown as ContractViewModel["pricing"], + rateSchedule, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -256,6 +252,43 @@ export class ContractTemplatesService { }; } + /** Static, representative rate schedule for the admin preview only. */ + private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + const dir = code.startsWith("IMPORT") + ? "import" + : code.startsWith("EXPORT") + ? "export" + : "domestic"; + const lane = + dir === "export" + ? "Galaan Multipurpose Port → SGTD" + : dir === "domestic" + ? "Mojo Dry Port → Dire Dawa" + : "Negad → Mojo Dry Port"; + + const freightLanes = isBulk + ? [ + { route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" }, + ] + : [ + { route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" }, + { route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" }, + ]; + + return { + freightLanes, + additionalServices: [ + { route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + { route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + ], + surcharges: [ + { route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" }, + ], + isEmpty: false, + currencyLabel: "USD", + }; + } + private assertCode(code: string): ContractTemplateCode { const upper = code?.toUpperCase() as ContractTemplateCode; if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 59dad2248..810232993 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; @@ -85,6 +88,8 @@ export interface BookingClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -282,6 +287,12 @@ export class BookingClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are + // the current one; this is the trail behind it. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts index 43ab9d22a..8de7aed87 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -161,6 +161,21 @@ export class ClearanceFeeService { return invoice; } + /** + * Retire (idempotently) the unpaid contract-level fee invoice when the + * contract reaches a terminal state — a dead contract must not leave a + * payable clearance invoice open for the customer to settle. No-op when the + * fee was already paid or never invoiced (mirrors the booking cancel path, + * {@link BillingService.expirePayable}). + */ + async expireForContract(contractId: string): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + contractId, + CLEARANCE_CONTRACT_INVOICE_TYPE, + ); + } + /** * Settlement branch point for `clearance`-source invoices: unlock the * document-upload step the fee was gating. Idempotent — a replayed event on diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts index eb77533ac..4ad6dab75 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => { expect(saved.status).toBe('COMPLETED'); expect(saved.metadata?.riskLevel).toBe('YELLOW'); }); + + /** + * The level is customer-visible and stays correctable until duty is advised, + * so a changed level must leave a trail rather than overwrite the last one. + */ + describe('risk history', () => { + it('records the first assignment with no previous level', async () => { + const { service } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedByUserId: 'user-1', + assignedBy: 'Abebe K.', + note: 'initial rating', + }); + expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel'); + }); + + it('keeps the earlier decision when the level is reassigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.'); + const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.'); + + expect(saved.metadata?.riskLevel).toBe('GREEN'); + expect(saved.metadata?.riskHistory).toHaveLength(2); + // The original RED decision survives, with who made it. + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedBy: 'Abebe K.', + }); + expect(saved.metadata?.riskHistory?.[1]).toMatchObject({ + level: 'GREEN', + previousLevel: 'RED', + assignedByUserId: 'user-2', + assignedBy: 'Sara M.', + note: 'downgraded', + }); + }); + + it('keeps the whole chain across several reassignments, oldest first', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + await service.assignRisk('b-1', 'YELLOW'); + const saved = await service.assignRisk('b-1', 'RED'); + + expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([ + 'GREEN', + 'YELLOW', + 'RED', + ]); + }); + + it('does not record a repeat of the level already assigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + const saved = await service.assignRisk('b-1', 'GREEN'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + }); + + it('always leaves riskLevel equal to the last history entry', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED'); + const saved = await service.assignRisk('b-1', 'YELLOW'); + + const history = saved.metadata?.riskHistory ?? []; + expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index ed3597d57..b6d57263a 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -210,15 +210,50 @@ export class ClearanceMilestoneService { * Customs cannot risk-rate cargo still moving under transit: the T1 must be * closed (accepted by GL Ethiopia after the train arrives) first, which is the * catalog order T1_CLOSED → RISK_ASSIGNED. + * + * The level stays correctable until duty is advised off it, so each assignment + * is appended to `riskHistory` instead of silently replacing the last one — a + * customer-visible level that changes needs a trail of who changed it and when. */ async assignRisk( bookingId: string, riskLevel: CustomsRiskLevel, userId?: string, note?: string, + actor?: string, ): Promise { await this.assertT1Closed(bookingId); - return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); + + const existing = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'RISK_ASSIGNED' }, + }); + const previousLevel = existing?.metadata?.riskLevel; + const history = existing?.metadata?.riskHistory ?? []; + + // A repeat of the level already assigned is not a decision — recording it + // would pad the trail with entries that changed nothing. + const entries = + previousLevel === riskLevel + ? history + : [ + ...history, + { + level: riskLevel, + ...(previousLevel ? { previousLevel } : {}), + assignedAt: new Date().toISOString(), + assignedByUserId: userId ?? null, + assignedBy: actor ?? null, + note: note ?? null, + }, + ]; + + return this.completeWithMetadata( + bookingId, + 'RISK_ASSIGNED', + { riskLevel, riskHistory: entries }, + userId, + note, + ); } /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 4f3deb513..7f0aaabea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // workflowService {} as never, // invoiceService {} as never, // clearanceFeeService + { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index f28468936..68c02fa7d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // workflowService invoiceService as never, {} as never, // clearanceFeeService + { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService 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 a0c2b42a4..96357c8c4 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 @@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; @@ -25,6 +26,8 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -38,7 +41,10 @@ import { ContractsRepository } from './contracts.repository'; import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingContainerLineDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; @@ -53,6 +59,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -80,6 +98,7 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly clearanceFeeService: ClearanceFeeService, + private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, @@ -179,11 +198,12 @@ export class ContractBookingService { // GENERAL without customs (Path A) ALSO clears per booking: the customer // uploads his own clearance proof on each booking and Operations reviews it // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → - // requestOperation machine). DOMESTIC has no border, so no gate. + // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same + // per-booking gate with the intercity document set — ops finalize then puts + // the booking straight into the ride-along pool (FULLY_EXECUTED), since + // intercity has no shipment-day request step. const generalSelfClear = - contract.contractKind === 'GENERAL' && - !contract.customsClearingEnabled && - contract.tradeDirection !== 'DOMESTIC'; + contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at @@ -269,8 +289,9 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + cargoFreeText: dto.cargoFreeText?.trim() || null, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, @@ -281,48 +302,63 @@ export class ContractBookingService { } as never), ); - // Persist container lines + per-unit container numbers (container freight only). - if (freightType === 'CONTAINER') { - await this.persistContainers(booking.id, contract, dto); - } - - // Reload with containers to compute the total from contract unit rates × qty. - const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); - if (loaded) { + // Everything between the insert and the priced update must be all-or-nothing: + // a throw part-way (container persist, weight rules, pricing) would otherwise + // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that + // occupies the one-time contract's single active-booking slot until the + // doc-review sweep expires it — and the clearance cycle still points at the + // previous booking, so the hub keeps offering "Rebook" against a dead draft. + try { + // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { - await this.applyWeightResults(loaded); + await this.persistContainers(booking.id, contract, dto); } - const computed = await this.bookingPricingService.computePriceForBooking(loaded); - // Reject a zero-price booking outright. A total of 0 means no contract rate - // matched the route/container (or the rate is unset), so the booking is not - // valid to ship or invoice. Roll back the just-inserted row + its lines so it - // does NOT occupy the one-time contract's single active-booking slot — else - // the customer's retry hits "already has an active booking" against a broken - // draft. The customer must fix the contract's rates, then rebook. - if (!(computed.totalAmount > 0)) { - await this.bookingsRepository.deleteContainers(booking.id); - await this.bookingsRepository.hardDelete(booking.id); - throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', - ); - } - await this.bookingsRepository.update(booking.id, { - totalAmount: computed.totalAmount, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems: computed.lineItems, + + // Reload with containers to compute the total from contract unit rates × qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has + // a positive total, so the zero-price gate below misses it — enforce + // the pricing hard blocks first. The catch below rolls everything back. + if (computed.hardBlocked.length > 0) { + throw new BadRequestException(computed.hardBlocked.join('; ')); + } + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. The catch below rolls back the row + its lines. + if (!(computed.totalAmount > 0)) { + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - await this.bookingPricingService.createPricingSnapshots( - booking.id, - computed.usedRates, - computed.appliedModifiers, - ); - warnings.push(...computed.warnings); + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + } catch (err) { + await this.bookingsRepository + .deleteContainers(booking.id) + .catch(() => undefined); + await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined); + throw err; } // Wagon consolidation gate. A container drawdown whose lines leave a partial @@ -335,6 +371,12 @@ export class ContractBookingService { const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); + + // Tell staff the booking exists. Placed after the zero-price rollback (which + // hard-deletes the row) and before the consolidation gate, so it fires + // exactly once whether the booking parks for a partner or finalizes inline. + this.bookingNotifier.createdToStaff(withContainers ?? booking); + const intendedStatus = generalCustoms || generalSelfClear ? 'AWAITING_DOCUMENTS' @@ -464,6 +506,7 @@ export class ContractBookingService { ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } @@ -551,7 +594,10 @@ export class ContractBookingService { await this.clearanceFeeService.issueForBooking(booking, contract); } - return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + const created = + (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + this.bookingNotifier.createdToStaff(created); + return created; } /** @@ -579,11 +625,40 @@ export class ContractBookingService { if (!booking || booking.contractId !== contract.id) { throw new NotFoundException(`Booking ${bookingId} not found on this contract`); } - if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + if ( + !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes( + booking.status, + ) + ) { throw new BadRequestException( 'Clearance must be finalized before the booking can be completed.', ); } + // An unpaid booking that expired at train dispatch keeps its finished + // per-booking clearance — GL rebooks it onto a new shipment day instead of + // forcing the customer through a new shipment request + clearance fee. + if (booking.status === 'EXPIRED') { + // Only a booking that completed once (it has a price, so its clearance + // finished and cargo is persisted) can be rebooked after expiry. + if (!(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + 'Only a previously completed booking can be rebooked after it expires.', + ); + } + // Expiry released the booking's contract-capacity hold; if the payload + // re-states the cargo, make sure the released share is still free. + if (dto.containers?.length || dto.bulkLines?.length) { + await this.assertWithinQuantityCap(contract, dto); + } + // Drop the departed train's link and fall into the day-only resubmit + // path below — same machinery as OPERATION_CHANGES_REQUESTED. + await this.bookingsRepository.update(booking.id, { + status: 'OPERATION_CHANGES_REQUESTED', + trainScheduleId: null, + } as never); + booking.status = 'OPERATION_CHANGES_REQUESTED'; + booking.trainScheduleId = null; + } // Path B: only GL Ethiopia completes a customs instance — the customer // never enters shipment data on a customs contract. if (contract.customsClearingEnabled) { @@ -664,6 +739,7 @@ export class ContractBookingService { } await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), } as never); @@ -676,15 +752,20 @@ export class ContractBookingService { const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once - // the contract rates are fixed (the clearance work is not lost). - if (!(computed.totalAmount > 0)) { + // the contract rates are fixed (the clearance work is not lost). A + // pricing hard block (e.g. one of two container sizes has no rate) + // rolls back the same way: a partially-priced total is positive but + // the booking must not proceed. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join('; ') + : 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { @@ -965,9 +1046,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1014,6 +1097,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1053,7 +1155,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1416,6 +1518,53 @@ export class ContractBookingService { ); } + /** + * Per-line handling counts. Each physical container carries its own hazardous + * / reefer / return switch (entered next to its VGM), so the count is however + * many units opted in. Forms that predate per-unit switches send line-level + * counts and no unit flags — those are honoured as-is. + */ + private handlingCounts(line: CreateBookingContainerLineDto): { + hazardousQuantity: number; + reeferQuantity: number; + returnQuantity: number; + } { + const units = line.units ?? []; + const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); + if (!flagged) { + return { + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + returnQuantity: Number(line.returnQuantity ?? 0), + }; + } + return { + hazardousQuantity: units.filter((u) => u.isHazardous).length, + reeferQuantity: units.filter((u) => u.isReefer).length, + returnQuantity: units.filter((u) => u.isReturn).length, + }; + } + + /** + * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the + * per-container opt-ins decide whether THIS shipment actually uses it. A + * container contract that allows hazardous but a booking where nobody ticked + * the switch is not a hazardous booking, and must not fire the surcharge. + * Bulk keeps the contract flag — it has its own bulk*Quantity fields. + */ + private resolveShipmentHandlingFlag( + contract: Contract, + dto: CreateBookingUnderContractDto, + field: 'hazardousQuantity' | 'reeferQuantity', + ): boolean { + const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; + if (!gated) return false; + if (contract.freightType !== 'CONTAINER') return true; + const lines = dto.containers ?? []; + if (!lines.length) return Boolean(gated); + return lines.some((l) => this.handlingCounts(l)[field] > 0); + } + /** * Resolve the booking's equipment return from the per-line return quantities * (container freight). The CONTRACT gates the service — like hazardous: @@ -1435,7 +1584,7 @@ export class ContractBookingService { const lines = dto.containers ?? []; for (const line of lines) { - const qty = Number(line.returnQuantity ?? 0); + const qty = this.handlingCounts(line).returnQuantity; if (qty === 0) continue; if (contract.equipmentReturn !== 'WITH_RETURN') { throw new BadRequestException( @@ -1451,7 +1600,7 @@ export class ContractBookingService { } if (contract.equipmentReturn === 'WITH_RETURN') { - const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0); + const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; } return legacy; @@ -1473,25 +1622,32 @@ export class ContractBookingService { throw new BadRequestException('At least one container line is required.'); } - const allowedSizes = new Set( + // Size strings arrive in mixed formats ("20ft" from the contract scope, + // bare "20" from the rebook seed) — compare numerically so format never + // fails a size that IS in scope. + const allowedSizesFt = new Set( (contract.cargoScope ?? []) - .map((c) => c.containerSize) - .filter((s): s is string => !!s), + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { - if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + if ( + allowedSizesFt.size && + !allowedSizesFt.has(parseInt(line.containerSize, 10)) + ) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); } + const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, - contract.isReefer || (line.reeferQuantity ?? 0) > 0, + contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length @@ -1505,15 +1661,13 @@ export class ContractBookingService { containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: counts.hazardousQuantity, + reeferQuantity: counts.reeferQuantity, returnQuantity: - contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) - : 0, + contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1529,6 +1683,8 @@ export class ContractBookingService { vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, + isReturn: + contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); @@ -1619,22 +1775,46 @@ export class ContractBookingService { }), ); + // Same size-scope gate persistContainers enforces at create, surfaced as a + // blocking preview error so the form can't confirm a size the contract does + // not cover. Numeric compare — "20" and "20ft" are the same size. + const allowedSizesFt = new Set( + (contract.cargoScope ?? []) + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), + ); + const scopeErrors = allowedSizesFt.size + ? [ + ...new Set( + lines + .map((l) => l.containerSize) + .filter((s) => !allowedSizesFt.has(parseInt(s, 10))), + ), + ].map((s) => `Container size ${s} is outside the contract scope.`) + : []; + // The unsaved twin of the booking createUnderContract would write: same // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. const route = await this.resolveRoute(contract, dto.contractRouteId); const previewBooking = Object.assign(new Booking(), { + // contractId makes the preview price off the contract's frozen rate + // snapshots exactly like the persisted booking will — without it the + // preview total is 0 on a leg with no live rate and the form blocks. + contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, @@ -1643,15 +1823,15 @@ export class ContractBookingService { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: this.handlingCounts(line).hazardousQuantity, + reeferQuantity: this.handlingCounts(line).reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) + ? this.handlingCounts(line).returnQuantity : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; @@ -1729,7 +1909,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors, + // Pricing hard blocks (missing rate for a container size / requested + // service) ride the capacity-errors channel so the form hard-blocks in + // the preview instead of failing at the create call. + capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d3bbaf098..029952f45 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -102,6 +105,8 @@ export interface ContractClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -365,6 +370,11 @@ export class ContractClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first — see booking-clearance.service. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', @@ -446,7 +456,7 @@ export class ContractClearanceService { const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; if (!allowed.includes(contract.status)) { throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, + `Cannot finalize document approval on status "${contract.status}".`, ); } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index bc1b4ad49..2d26f51dc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,4 +1,5 @@ import { Contract } from './entities/contract.entity'; +import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; /** * Resolves which seeded clearance FileUploadSetting applies to a contract during @@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight { * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, * reviewed by Operations rather than GL. * - * DOMESTIC/intercity has no border, so no clearance gate applies on either path. + * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still + * collects the admin-configured intercity document set after both signatures + * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract + * gate and collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, freightType: string, includesCustoms: boolean, ): string | null { + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts new file mode 100644 index 000000000..154777aea --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -0,0 +1,168 @@ +import type { + ContractDocumentArticle, + ContractDocumentSnapshot, +} from './entities/contract.entity'; + +/** + * One recorded change between two document snapshots. Granularity is per + * article: a body edit is reported as "the body changed", not as a text diff. + */ +export type ContractDocumentChange = + | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } + | { kind: 'ARTICLE_REMOVED'; articleId: string; title: string } + | { + kind: 'ARTICLE_RENAMED'; + articleId: string; + title: string; + fromTitle: string; + } + | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_REORDERED'; + articleId: string; + title: string; + fromOrder: number; + toOrder: number; + } + | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + +type SnapshotLike = Pick< + ContractDocumentSnapshot, + 'documentTitle' | 'whereasClauses' | 'articles' +> | null; + +/** Match on id when present, else on normalized title (editors may omit ids). */ +function articleKey(article: ContractDocumentArticle): string { + return article.id || `title:${article.title.trim().toLowerCase()}`; +} + +function indexArticles( + articles: ContractDocumentArticle[] | undefined, +): Map { + const map = new Map(); + for (const article of articles ?? []) { + map.set(articleKey(article), article); + } + return map; +} + +/** + * Compare two document snapshots and describe what changed, article by article. + * Returns an empty array when the snapshots are equivalent, so callers can skip + * recording a no-op revision. + */ +export function diffSnapshots( + before: SnapshotLike, + after: SnapshotLike, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + const beforeTitle = before?.documentTitle ?? null; + const afterTitle = after?.documentTitle ?? null; + if (beforeTitle !== afterTitle && afterTitle !== null) { + changes.push({ + kind: 'DOCUMENT_TITLE_CHANGED', + title: afterTitle, + fromTitle: beforeTitle, + }); + } + + const beforeWhereas = before?.whereasClauses ?? []; + const afterWhereas = after?.whereasClauses ?? []; + const beforeWhereasSet = new Set(beforeWhereas); + const afterWhereasSet = new Set(afterWhereas); + const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length; + const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length; + if (whereasAdded > 0 || whereasRemoved > 0) { + changes.push({ + kind: 'WHEREAS_CHANGED', + added: whereasAdded, + removed: whereasRemoved, + }); + } + + const beforeArticles = indexArticles(before?.articles); + const afterArticles = indexArticles(after?.articles); + + for (const [key, article] of afterArticles) { + const previous = beforeArticles.get(key); + if (!previous) { + changes.push({ + kind: 'ARTICLE_ADDED', + articleId: article.id, + title: article.title, + }); + continue; + } + + if (previous.title !== article.title) { + changes.push({ + kind: 'ARTICLE_RENAMED', + articleId: article.id, + title: article.title, + fromTitle: previous.title, + }); + } + if (previous.body !== article.body) { + changes.push({ + kind: 'ARTICLE_BODY_CHANGED', + articleId: article.id, + title: article.title, + }); + } + if (previous.order !== article.order) { + changes.push({ + kind: 'ARTICLE_REORDERED', + articleId: article.id, + title: article.title, + fromOrder: previous.order, + toOrder: article.order, + }); + } + } + + for (const [key, article] of beforeArticles) { + if (afterArticles.has(key)) continue; + changes.push({ + kind: 'ARTICLE_REMOVED', + articleId: article.id, + title: article.title, + }); + } + + return changes; +} + +/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ +export function summarizeChanges(changes: ContractDocumentChange[]): string { + if (changes.length === 0) return 'No changes'; + + const articleVerbs: Record = { + ARTICLE_ADDED: 'added', + ARTICLE_REMOVED: 'removed', + ARTICLE_RENAMED: 'renamed', + ARTICLE_BODY_CHANGED: 'edited', + ARTICLE_REORDERED: 'reordered', + }; + + const counts = new Map(); + const parts: string[] = []; + + for (const change of changes) { + const verb = articleVerbs[change.kind]; + if (verb) { + counts.set(verb, (counts.get(verb) ?? 0) + 1); + } else if (change.kind === 'DOCUMENT_TITLE_CHANGED') { + parts.push('document title changed'); + } else if (change.kind === 'WHEREAS_CHANGED') { + parts.push('recitals changed'); + } + } + + const articleParts = [...counts.entries()].map( + ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, + ); + + return [...articleParts, ...parts].join(', '); +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts new file mode 100644 index 000000000..2808ea6cf --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import type { ContractDocumentSnapshot } from './entities/contract.entity'; + +export interface RecordRevisionInput { + contractId: string; + before: ContractDocumentSnapshot | null; + after: ContractDocumentSnapshot | null; + actorId?: string | null; + actorRole?: string | null; + stepId?: string | null; +} + +@Injectable() +export class ContractDocumentHistoryService { + private readonly logger = new Logger(ContractDocumentHistoryService.name); + + constructor( + @InjectRepository(ContractDocumentRevision) + private readonly revisionRepo: Repository, + ) {} + + /** + * Append a revision describing what an edit changed. Best-effort: recording + * history must never break the edit that triggered it, so failures are logged + * and swallowed. A no-op edit records nothing. + */ + async record(input: RecordRevisionInput): Promise { + try { + const changes = diffSnapshots(input.before, input.after); + if (changes.length === 0) return; + + await this.revisionRepo.save( + this.revisionRepo.create({ + contractId: input.contractId, + actorId: input.actorId ?? null, + actorRole: input.actorRole ?? null, + stepId: input.stepId ?? null, + summary: summarizeChanges(changes), + changes, + }), + ); + } catch (err) { + this.logger.error( + `Failed to record document revision for contract ${input.contractId}: ${String(err)}`, + ); + } + } + + /** Revision history for a contract, newest first. */ + list(contractId: string): Promise { + return this.revisionRepo.find({ + where: { contractId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 575767a87..ac4fe2a0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { @@ -136,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 235e1051f..d40d5dd6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -194,17 +194,49 @@ export class ContractPricingService { contract.freightType === 'CONTAINER' && contract.equipmentReturn === 'WITH_RETURN' ) { - const withReturn = liveRates.find( - (r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD', - ); - if (withReturn && Number(withReturn.rateValue) > 0) { - lineItems.push({ - code: 'RETURN_SURCHARGE', - label: 'Empty container return', - unit: toContractUnit(withReturn.rateUnit), - unitPrice: convert(Number(withReturn.rateValue)), - conditionalOn: 'with_return', + // Return is sold per direction + route + container type (import-only) — + // one display line per contract size that has a configured rate. A size + // with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'RETURN_SURCHARGE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'RETURN_SURCHARGE', + label: `Empty container return (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'with_return', + }); + } } } @@ -213,12 +245,24 @@ export class ContractPricingService { // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. // A customs contract may not proceed without a configured live rate. if (contract.customsClearingEnabled) { - const clearance = liveRates.find( - (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', - ); + // The fee is sold per direction + route — strict, no route-less fallback. + // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const clearance = route + ? liveRates.find( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : undefined; if (!clearance || Number(clearance.rateValue) <= 0) { throw new UnprocessableEntityException( - 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.', + 'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.', ); } lineItems.push({ 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 60db03ff1..a8e2f5b62 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 @@ -3,7 +3,10 @@ import { ConflictException, Injectable, Logger, + ServiceUnavailableException, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; @@ -15,7 +18,16 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { + assertCanApproveContractStep, + assertFreightPermission, + canEditContractStep, +} from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + forFreightType, +} from '../../seed/freight-permissions.registry'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; @@ -37,6 +49,7 @@ import { ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ @@ -46,8 +59,12 @@ export interface ContractDocumentDraft { articles: ContractDocumentArticle[]; code: string | null; name: string | null; - /** True once the document may no longer be edited/regenerated. */ + /** True when THIS caller may not edit — the inverse of `editableByMe`. */ locked: boolean; + /** Whether the requesting user is the approver whose turn it is to edit. */ + editableByMe: boolean; + /** Role holding editing rights right now, for "locked because…" messaging. */ + nextApproverRole: string | null; generatedAt: Date | null; status: string; } @@ -60,6 +77,28 @@ export interface ContractDocumentDraft { */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; +/** + * Approval chains are configured in IAM position types, so a step's role no + * longer maps onto the contract's fixed approver columns. These sets keep those + * legacy columns populated for the roles that still correspond to one — both the + * original role strings on historical rows and the position types that replaced + * them. Steps outside these sets are recorded only in `contract_approval_steps`, + * which is the source of truth. + */ +const LEGACY_STAFF_ROLES = new Set([ + 'LINE_STAFF', + 'employee', + 'teamLeader', + 'officeHead', + 'recordOfficer', +]); +const LEGACY_DIRECTOR_ROLES = new Set([ + 'DIRECTOR', + 'director', + 'operation-director', +]); +const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']); + /** * Mask a phone for display — keep the last 4 digits, star the rest * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing @@ -71,6 +110,27 @@ function maskPhone(phone: string): string { return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`; } +/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */ +function maskEmail(email: string): string { + const [local, domain] = email.trim().split('@'); + if (!domain) return email.trim(); + return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`; +} + +/** + * Where the signing code went, for the "we sent a code to …" line in the UI. + * Both contacts are listed when both were used — a signer who only watches their + * handset otherwise has no idea the email carries the same code. + */ +function maskSignerContacts(contacts: { phone?: string; email?: string }): string { + return [ + contacts.email ? maskEmail(contacts.email) : null, + contacts.phone ? maskPhone(contacts.phone) : null, + ] + .filter(Boolean) + .join(' and '); +} + /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { @@ -85,6 +145,7 @@ export class ContractTransitionService { private readonly logger = new Logger(ContractTransitionService.name); constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, @@ -102,8 +163,46 @@ export class ContractTransitionService { private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, private readonly clearanceFeeService: ClearanceFeeService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The contacts the signing OTP is sent to and verified against: the signer's + * own IAM account phone AND email. One code goes to both and either delivery + * verifies it, so a signer whose SMS is delayed can still complete from their + * inbox instead of abandoning a ready contract. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — caller-supplied contacts would let an attacker point the code + * at their own phone or mailbox. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerContacts( + signerUserId?: string, + ): Promise<{ phone?: string; email?: string }> { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another account if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null; email: string | null }> = + await this.dataSource.query( + `SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + const email = rows[0]?.email?.trim(); + if (!phone && !email) { + throw new BadRequestException( + 'Your account has no registered phone number or email. Add one in Settings → Account before signing.', + ); + } + return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) }; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -145,8 +244,15 @@ export class ContractTransitionService { actorId: string, validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + // The route guard passes on either arm; the contract's freight type decides + // which one is actually required (accept bulk ≠ accept container). + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { @@ -194,18 +300,22 @@ export class ContractTransitionService { */ async getContractDocumentDraft( contractId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); const snapshot = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? (await this.resolveDocumentSnapshot(contract)); + const editableByMe = await this.documentIsEditableBy(contract, user); return { documentTitle: snapshot?.documentTitle ?? null, whereasClauses: snapshot?.whereasClauses ?? [], articles: snapshot?.articles ?? [], code: snapshot?.code ?? null, name: snapshot?.name ?? null, - locked: !this.documentIsEditable(contract), + locked: !editableByMe, + editableByMe, + nextApproverRole: await this.nextApproverRole(contract), generatedAt: contract.contractGeneratedAt ?? null, status: contract.status, }; @@ -220,10 +330,12 @@ export class ContractTransitionService { async updateContractDocument( contractId: string, input: ContractDocumentSnapshotInput, + user?: TCurrentUser | null, + actorId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL']); - this.assertDocumentEditable(contract); + await this.assertDocumentEditable(contract, user); const current = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? @@ -235,9 +347,25 @@ export class ContractTransitionService { whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [], articles: input.articles ?? current?.articles ?? [], }; + const next = this.normalizeSnapshot(merged); await this.contractsRepository.update(contractId, { - documentSnapshot: this.normalizeSnapshot(merged), + documentSnapshot: next, } as never); + + // Audit the edit after it lands. Recording history must never break the + // edit itself, so the history service swallows its own failures. + const step = await this.contractsRepository.findNextPendingApprovalStep( + contractId, + ); + await this.documentHistory.record({ + contractId, + before: current, + after: next, + actorId: actorId ?? null, + actorRole: step?.requiredRole ?? null, + stepId: step?.id ?? null, + }); + return this.contractsService.findById(contractId); } @@ -299,23 +427,53 @@ export class ContractTransitionService { } /** - * The per-contract document may be edited/regenerated while the contract is at - * the accept stage (SUBMITTED) or in approval with NO approver having acted - * yet. The first approval action freezes it. + * The contract document stays editable for the whole approval chain, but only + * by the approver whose turn it is: whoever can action the next pending step. + * Approving therefore hands editing rights to the next approver in the chain. + * + * Edits never reset approvals already given — earlier approvers stay approved. */ - private documentIsEditable(contract: Contract): boolean { + private async documentIsEditableBy( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { if (contract.status === 'SUBMITTED') return true; if (contract.status !== 'PENDING_APPROVAL') return false; - return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING'); + + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + if (!next) return false; + if (!user) return false; + + // Strict match: ONLY the approver whose turn it is (the next pending step's + // role) may edit. Using the looser approve gate here let any approver who + // held a contract-approve permission keep the edit button after acting — + // approval must hand edit rights to the next approver, not share them. + return canEditContractStep(user, next.requiredRole); } - private assertDocumentEditable(contract: Contract): void { - if (!this.documentIsEditable(contract)) { - throw new ConflictException( - 'The contract document is locked — an approver has already acted or the ' + - 'contract has advanced. It can no longer be edited or regenerated.', - ); - } + /** The role that currently holds editing rights, for UI messaging. */ + private async nextApproverRole(contract: Contract): Promise { + if (contract.status !== 'PENDING_APPROVAL') return null; + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + return next?.requiredRole ?? null; + } + + private async assertDocumentEditable( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { + if (await this.documentIsEditableBy(contract, user)) return; + + const role = await this.nextApproverRole(contract); + throw new ConflictException( + role + ? `The contract document can only be edited by the current approver (${role}).` + : 'The contract document is locked — the contract has advanced beyond approval.', + ); } /** @@ -389,8 +547,13 @@ export class ContractTransitionService { contractId: string, note: string, actorId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( @@ -408,8 +571,17 @@ export class ContractTransitionService { return updated; } - async reject(contractId: string, reason: string, actorId: string): Promise { + async reject( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( @@ -419,6 +591,10 @@ export class ContractTransitionService { actorId, 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -429,17 +605,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -447,6 +630,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -457,6 +654,10 @@ export class ContractTransitionService { 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -465,30 +666,77 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: no clearance-fee expiry (the contract + * is still alive) and no customer-facing REJECTION note — the trail is a + * staff note plus a backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, stepId: string, actorId: string, - requiredRole: string, authUser?: TCurrentUser, ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); - // Approvers review the generated contract document, so it must exist before - // the first approval can be recorded. Staff generate it (from the frozen, - // optionally-edited snapshot) at the accept stage. - if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) { - throw new BadRequestException( - 'Generate the contract document before it can be approved.', - ); - } - const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step || step.status !== 'PENDING') { throw new BadRequestException('Approval step not found or already actioned'); @@ -498,31 +746,34 @@ export class ContractTransitionService { if (!next || next.id !== step.id) { throw new BadRequestException('Approval steps must be completed in order'); } - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - if (step.blocksRole && step.blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + + // The role is the step's own — never the caller's claim about themselves. + const requiredRole = step.requiredRole; + if (authUser) { + assertCanApproveContractStep(authUser, requiredRole); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); // Record who acted on this step, but DO NOT advance the contract status here — - // approving one step (e.g. LINE_STAFF) must not finalize the chain while later - // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once - // every step in the chain is complete; until then the contract stays in - // PENDING_APPROVAL so the next required role can act. + // approving one step must not finalize the chain while later steps are still + // pending. Status only moves to APPROVED once every step in the chain is + // complete; until then the contract stays in PENDING_APPROVAL so the next + // required approver can act. + // + // `contract_approval_steps` is the source of truth for who approved what — a + // chain is an arbitrary sequence of position types and cannot be represented + // by fixed columns. The legacy columns below are still stamped, best-effort, + // for the three roles that map onto them so older readers keep working. const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { + if (LEGACY_STAFF_ROLES.has(requiredRole)) { updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (LEGACY_CEO_ROLES.has(requiredRole)) { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } @@ -536,14 +787,19 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); - // Every step approved → CONTRACT_READY. The document was already generated - // (and reviewed) at the accept stage, so we reuse it rather than - // re-rendering. Best-effort: a hiccup must not roll back the approval. + // Final approval is what produces the contract PDF — until now there was + // only a live preview. The approval steps are already committed, so a + // render failure must not roll them back; surface it instead of swallowing + // it, since an APPROVED contract with no document needs operator action. try { return await this.finalizeApprovedContract(contractId); } catch (err) { - this.logger.warn( - `Finalizing contract after final approval failed for ${updated.reference}: ${err}`, + this.logger.error( + `Contract PDF generation failed after final approval for ${updated.reference}: ${err}`, + ); + throw new ServiceUnavailableException( + 'All approvals were recorded, but generating the contract PDF failed. ' + + 'Retry generation from the contract page.', ); } } @@ -551,24 +807,13 @@ export class ContractTransitionService { } /** - * Staff (re)generate the contract PDF. Two stages: - * - PENDING_APPROVAL: render from the frozen (optionally staff-edited) - * snapshot so approvers review the real document. Status is UNCHANGED, and - * it is blocked once an approver has acted (the document is then locked). - * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to - * CONTRACT_READY. - * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the - * transition — the document re-renders lazily on view/download. + * Retry path for a contract that finished approval but whose PDF failed to + * render (Chromium unavailable, etc.). The normal flow generates the document + * automatically on the final approval — there is no manual generate step + * before that, only the live preview. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); - - if (contract.status === 'PENDING_APPROVAL') { - this.assertDocumentEditable(contract); - await this.renderContractDocument(contract); - return this.contractsService.findById(contractId); - } - assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); await this.renderContractDocument(contract); await this.contractsRepository.update(contractId, { @@ -583,11 +828,17 @@ export class ContractTransitionService { * changes status. Rendering is best-effort — a Chromium hiccup defers the file * (it re-renders on view/download) but the timestamp is still stamped. */ - private async renderContractDocument(contract: Contract): Promise { + private async renderContractDocument( + contract: Contract, + options: { strict?: boolean } = {}, + ): Promise { const { view } = await this.documentViewModelBuilder.build(contract.id); try { await this.upsertContractPdf(contract.id, contract.reference, view); } catch (err) { + // Strict callers (final approval) need to know the PDF is missing — it is + // the artifact of the completed chain, not a cache that can refill later. + if (options.strict) throw err; this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); @@ -599,15 +850,14 @@ export class ContractTransitionService { } /** - * Every approval step landed → CONTRACT_READY. The document was already - * generated (and reviewed) at the accept stage, so reuse it; render now only - * if it was somehow never generated. Never re-renders over an existing file. + * Every approval step landed → generate the contract PDF, then CONTRACT_READY. + * This is the only point at which the document is produced: approvers review a + * live preview, and the final approval is what turns it into a PDF. Renders + * unconditionally so the file reflects every edit made during the chain. */ private async finalizeApprovedContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); - if (!contract.contractGeneratedAt) { - await this.renderContractDocument(contract); - } + await this.renderContractDocument(contract, { strict: true }); await this.contractsRepository.update(contractId, { status: 'CONTRACT_READY', } as never); @@ -796,11 +1046,11 @@ export class ContractTransitionService { } /** - * Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone — - * the same number {@link sign} verifies against. The client never picks the - * number (that is the H12(b) trust property): it only asks us to send, and we - * resolve the phone from the contract. Returns a masked hint so the UI can - * say where the code went without exposing the full number. + * Send the sudo-mode signing OTP to the SIGNER's own registered phone and + * email — the same contacts {@link sign} verifies against. The client never + * picks them (that is the H12(b) trust property): it only asks us to send, and + * we resolve them from the authenticated user id. Returns a masked hint so the + * UI can say where the code went without exposing the full values. */ async sendSigningOtp( contractId: string, @@ -815,14 +1065,9 @@ export class ContractTransitionService { ); assertContractStatus(contract, ['CONTRACT_READY']); - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to send the signing OTP to', - ); - } - await this.otpService.sendOtp({ phone: companyPhone }); - return { sentTo: maskPhone(companyPhone) }; + const signerContacts = await this.resolveSignerContacts(options.signerUserId); + await this.otpService.sendOtp(signerContacts); + return { sentTo: maskSignerContacts(signerContacts) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ @@ -848,20 +1093,18 @@ export class ContractTransitionService { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the - // signature is applied. H12(b): verify against the CONTRACT COMPANY's - // registered phone — never the caller-supplied dto.otpPhone, which an - // attacker could point at their own phone to sign someone else's - // contract. The OTP is issued to the company's registered number. - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to verify the signing OTP against', - ); - } + // signature is applied. H12(b): verify against the SIGNER's own registered + // contacts, resolved server-side from the authenticated user id — never + // caller-supplied ones, which an attacker could point at their own phone + // or mailbox. Ownership is already asserted above, so this proves the + // specific person holding the account is present, not merely that someone + // reached a shared company line. Must resolve identically to + // sendSigningOtp, or send and verify would target different contacts. + const signerContacts = await this.resolveSignerContacts(options.signerUserId); if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp); + await this.otpService.verifyOtpForAction(signerContacts, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', @@ -903,8 +1146,8 @@ export class ContractTransitionService { }; // A clearance gate applies whenever a clearance doc set resolves — Path B - // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC - // resolves to null on both paths and skips straight to executed. + // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the + // intercity document set (DOMESTIC, ops-reviewed like Path A). const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index b62b89e32..7e4c10d6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -31,8 +31,14 @@ import { ApiTags, } from '@nestjs/swagger'; +import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; +import { + FREIGHT_PERMS, + bothFreightTypes, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, @@ -60,7 +66,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { - ApproveStepDto, RejectContractDto, RejectStepDto, RequestChangesDto, @@ -90,6 +95,7 @@ import { @ApiBearerAuth() export class ContractsController { constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, @@ -182,7 +188,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { - assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), + ); } return this.contractsService.create(dto, files ?? [], user?.id); } @@ -194,7 +203,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. - if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { return this.contractsService.findAll(filter); } const userId = user?.id; @@ -271,7 +283,8 @@ export class ContractsController { if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } @@ -331,33 +344,49 @@ export class ContractsController { } @Post(':id/staff/accept') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + // One-of guard; the service then requires the arm matching the contract's freight type. + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, dto.documentSnapshot, + user, ); } @Get(':id/document/draft') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', }) - getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) { - return this.transitionService.getContractDocumentDraft(id); + getContractDocumentDraft( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Editability depends on WHO is asking — only the approver whose turn it is + // may edit — so the caller is part of the draft lookup. + return this.transitionService.getContractDocumentDraft(id, user); + } + + @Get(':id/document/revisions') + @BookingStaff(FREIGHT_PERMS.contracts.view) + @ApiOperation({ + summary: 'Audit trail of edits to this contract\'s document (newest first)', + }) + getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) { + return this.documentHistory.list(id); } @Put(':id/document/articles') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -365,54 +394,63 @@ export class ContractsController { updateContractDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDocumentDto, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.updateContractDocument(id, dto); + return this.transitionService.updateContractDocument( + id, + dto, + user, + resolveAuthUserId(user), + ); } @Post(':id/staff/request-changes') - @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), + user, ); } @Post(':id/staff/reject') - @BookingStaff(FREIGHT_PERMS.contracts.reject) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + return this.transitionService.reject( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); } @Post(':id/approval-steps/:stepId/approve') - @BookingStaff([ - FREIGHT_PERMS.contracts.approveLineStaff, - FREIGHT_PERMS.contracts.approveDirector, - FREIGHT_PERMS.contracts.approveCeo, - ]) + @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, - @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { + // Whether this caller may approve depends on the step's own required role + // (an IAM position type), so the service resolves the step and authorizes + // against it — the client never declares its own role. return this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), - dto.requiredRole, user, ); } @@ -423,7 +461,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -435,6 +476,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } @@ -452,7 +494,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = @@ -487,7 +532,10 @@ export class ContractsController { @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); @@ -543,9 +591,12 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // H12(c): a customer may only renew a contract their company owns. Staff - // with bookings.view bypass, mirroring getContractView/downloadContractDocument. + // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.transitionService.renew(id, resolveAuthUserId(user)); @@ -569,9 +620,12 @@ export class ContractsController { @UploadedFiles() files: Express.Multer.File[], ) { // H12(c): only the owning company's customer may upload clearance docs. - // Staff with bookings.view bypass, mirroring the other contract handlers. + // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.clearanceService.uploadDocuments(id, files ?? []); @@ -968,13 +1022,16 @@ export class ContractsController { assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.milestoneService.assignRisk( bookingId, dto.riskLevel, resolveAuthUserId(user), dto.note, + // Risk history is read by people, so resolve the name now — the id alone + // would render as a UUID in the trail. + actorLabel(user), ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 5177b6a39..050417a45 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; import { ContractSignature } from './entities/contract-signature.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractReviewNote } from './entities/contract-review-note.entity'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ContractDocumentReview } from './entities/contract-document-review.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; @@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractSignature, ContractApprovalStep, ContractReviewNote, + ContractDocumentRevision, ContractClearanceCycle, ContractDocumentReview, ClearanceMilestone, @@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ClearanceFeeService, ContractNotifierService, ContractTransitionService, + ContractDocumentHistoryService, ContractClearanceService, ClearanceWorkflowService, BookingClearanceService, 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 533b51b3b..67a3bd101 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository { // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); await this.attachClearancePhases(items); + await this.attachRejectionNotes(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach the latest REJECTION review-note body to each REJECTED contract so + * list consumers (portal rows, backoffice queues) can show why without a + * per-contract detail fetch. One query per page, like `attachContractFiles`. + */ + private async attachRejectionNotes(contracts: Contract[]): Promise { + const rejected = contracts.filter((c) => c.status === 'REJECTED'); + if (rejected.length === 0) return; + const ids = rejected.map((c) => c.id); + const rows: Array<{ contract_id: string; body: string }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, body + FROM freight.contract_review_notes + WHERE contract_id = ANY($1) + AND note_type = 'REJECTION' + AND deleted_at IS NULL + ORDER BY contract_id, created_at DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.body])); + for (const contract of rejected) { + contract.latestRejectionNote = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') @@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ 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 224745cc4..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; -import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; @@ -181,11 +179,7 @@ export class ContractsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create contracts yet.", - ); - } + this.companiesService.assertCompanyActiveFor(company, 'contracts'); companyId = company.id; } @@ -193,31 +187,31 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); - // Stamp the operational profile (importer/exporter) for portal scoping. + // Stamp the operational profile for portal scoping. A forwarder contract + // pins its profile explicitly (trade direction can't tell it apart from a + // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } - companyProfileId = - await this.companiesService.resolveCompanyProfileIdForBooking( - companyId, - dto.tradeDirection, - fallbackType, - ); + if (dto.companyProfileId) { + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else { + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + ); - const customerSelfBooking = !dto.companyId && !!userId; - if (customerSelfBooking && companyProfileId) { - await this.companiesService.assertCompanyProfileApprovedForBooking( - companyProfileId, - ); + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } } @@ -374,23 +368,18 @@ export class ContractsService { if (companyProfileId) { // Business-license files are FileRecords (resource "company_profiles"); // carry the live ones by reference. Staged/pending uploads are excluded by - // code. Codes are slugged from each document name so they group under - // "Profile documents" on the contract detail page. + // code. The `business_license` prefix is preserved so the portal groups + // them under "Business license" instead of the clearance catch-all — the + // index suffix keeps multiple licences distinct. const records = await this.filesService.findByResource( companyProfileId, 'company_profiles', ); - const slug = (name: string) => - name - .toLowerCase() - .replace(/\.[a-z0-9]+$/, '') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'profile_document'; records .filter((r) => r.code === 'business_license') .forEach((r, i) => { - const code = `${slug(r.name)}_${i + 1}`; + const code = `business_license_${i + 1}`; if (existingCodes.has(code)) return; docs.push({ code, @@ -647,6 +636,47 @@ export class ContractsService { } } + // Surface the rejection reason. The approval-step note is wiped on + // send-back resets, so the review-note trail is the only durable source. + if (contract.status === 'REJECTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'REJECTION', + ); + contract.latestRejectionNote = note?.body ?? null; + } catch { + contract.latestRejectionNote = null; + } + } + + // Surface the send-back reason to the returned-to approver, but only while + // it is still actionable: once any step acts after the send-back the note + // is stale and stays out of the response (the trail keeps it in the DB). + if (contract.status === 'PENDING_APPROVAL') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'STAFF_NOTE', + ); + // Stale when any step acted after it (send-back resolved) or when the + // chain itself is newer than the note (fresh cycle after a resubmit). + const staleAfter = Math.max( + 0, + ...(contract.approvalSteps ?? []).flatMap((s) => [ + s.actedAt ? new Date(s.actedAt).getTime() : 0, + s.createdAt ? new Date(s.createdAt).getTime() : 0, + ]), + ); + contract.latestSendBackNote = + note && new Date(note.createdAt).getTime() > staleAfter + ? note.body + : null; + } catch { + contract.latestSendBackNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..93a0e9e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -50,6 +50,15 @@ export class CreateContainerUnitDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'This container ships back empty (equipment return).', + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReturn?: boolean; } export class CreateBookingContainerLineDto { @@ -173,6 +182,13 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + description: 'What the containers carry — captured per booking (container freight).', + }) + @IsOptional() + @IsString() + cargoFreeText?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 688e0b4c7..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -124,6 +124,16 @@ export class CreateContractDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile to stamp the contract to (a forwarder contract); ' + + 'commercial contracts otherwise auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts index 6d2afce3c..2ece44f12 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [ 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', + 'OTHER', ] as const; export type IncidentType = (typeof INCIDENT_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index d4676b8cd..14e3b86dd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number]; export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const; export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; +/** + * One customs risk decision. Risk stays correctable until duty is advised off + * it, and the level is customer-visible, so every assignment is kept rather than + * overwritten — a disputed level needs to show what was set, by whom, and when. + */ +export interface RiskAssignmentRecord { + level: CustomsRiskLevel; + /** The level this replaced; absent on the first assignment. */ + previousLevel?: CustomsRiskLevel; + assignedAt: string; + assignedByUserId?: string | null; + /** Display name resolved at assignment time, so the trail never shows a UUID. */ + assignedBy?: string | null; + note?: string | null; +} + /** * Structured payload some milestones carry beyond a plain note (doc §11.3): - * - RISK_ASSIGNED → `riskLevel` + * - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment) * - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial` * Stored on the milestone so the timeline can render the value inline. */ export interface MilestoneMetadata { riskLevel?: CustomsRiskLevel; + /** + * Append-only, oldest first. `riskLevel` is the current value and always + * equals the last entry's `level`. + */ + riskHistory?: RiskAssignmentRecord[]; dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts index 3c8c7fd5f..0a47dd3e1 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts @@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity { @Column({ name: 'step_order', type: 'smallint', default: 0 }) stepOrder!: number; - @Column({ name: 'required_role', type: 'varchar', length: 40 }) + @Column({ name: 'required_role', type: 'varchar', length: 64 }) requiredRole!: string; - @Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true }) + @Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true }) blocksRole?: string | null; @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts new file mode 100644 index 000000000..bc7e12e3e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import type { ContractDocumentChange } from '../contract-document-diff.util'; +import { Contract } from './contract.entity'; + +/** + * Append-only audit of contract document edits. The document stays editable + * through the whole approval chain, so this records who changed which article + * and when — the contract itself only ever holds the current snapshot. + */ +@Entity({ schema: 'freight', name: 'contract_document_revisions' }) +@Index(['contractId']) +export class ContractDocumentRevision extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId?: string | null; + + /** The approval step's required role at the time of the edit. */ + @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) + actorRole?: string | null; + + @Column({ name: 'step_id', type: 'uuid', nullable: true }) + stepId?: string | null; + + @Column({ name: 'summary', type: 'varchar', length: 255, nullable: true }) + summary?: string | null; + + @Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` }) + changes!: ContractDocumentChange[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b5e0b8fb1..4838d3f52 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -326,4 +326,19 @@ export class Contract extends BaseEntity { * asked them to fix. Lives in contract_review_notes, not a column here. */ latestChangeRequestNote?: string | null; + + /** + * Body of the most recent REJECTION review note, attached by + * ContractsService.findById when status is REJECTED so both backoffice and + * portal can show why. Lives in contract_review_notes, not a column here. + */ + latestRejectionNote?: string | null; + + /** + * Body of the most recent send-back STAFF_NOTE, attached by + * ContractsService.findById while the contract is PENDING_APPROVAL and no + * approval step has acted since the send-back. Lives in + * contract_review_notes, not a column here. + */ + latestSendBackNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index aa1c956e0..f155a27be 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal contracts stay on the list — the clearance hub is GL's history of + // everything that passed through, not just the live work queue. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ @@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'ROAD_DISPATCH_PENDING', + // Payment phase — the booking is selected/awaiting the customer's payment. + 'SELECTED_FOR_BATCH', + 'PNR_GENERATED', + 'AWAITING_PAYMENT', + 'PAYMENT_VERIFICATION_IN_PROGRESS', 'IN_TRANSIT', 'ARRIVED', 'PAID', 'COMPLETED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal bookings stay on the list — EXPIRED especially: GL rebooks it + // from here, and the hub doubles as clearance history. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 221b7c29b..1fbd1459f 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -1,6 +1,16 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; +/** + * Reviewer verdict on a single stored document. + * + * `null` (the default) means "not reviewed" — the state every file starts in and + * the only state the customer is not blocked by. `change_requested` is raised by + * a backoffice reviewer against one specific document and is what the customer + * must clear by re-uploading; `approved` records an explicit sign-off. + */ +export type FileReviewStatus = "change_requested" | "approved"; + @Entity({ schema: "freight", name: "files" }) export class FileRecord extends BaseEntity { @Column({ name: "resource_id", type: "uuid" }) @@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity { @Column({ name: "mime_type", type: "varchar", length: 255 }) mimeType!: string; + + /** Reviewer verdict, or `null` while the document has never been reviewed. */ + @Column({ + name: "review_status", + type: "varchar", + length: 32, + nullable: true, + default: null, + }) + reviewStatus!: FileReviewStatus | null; + + /** Why a change was requested — shown verbatim to the customer. */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote!: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy!: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt!: Date | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 307e24985..6978ad446 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -1,12 +1,19 @@ +import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types"; import { Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, Query, Res, } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; import { Response } from "express"; import { FilesService } from "./files.service"; @@ -23,13 +30,16 @@ export class FilesController { // Browser inline previews (/