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/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 5a9ae9ef9..3ca5f6cb1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -44,6 +44,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up 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"; @@ -166,6 +167,7 @@ import { LoggerMiddleware } from "./logger.middleware"; DropdownSettingsModule, ContractTemplatesModule, OtpModule, + HealthModule, RuleEngineModule, BackofficeModule, DemoPermissionsModule, @@ -275,6 +277,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/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 69596c21d..ced6e3c46 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,110 @@ 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.`, + ); +} + export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, 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/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/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/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-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/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..91ddaf1a9 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,31 @@ 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"; + +/** The account a staff-triggered reset would land on. */ +export interface CustomerResetTarget { + userId: string; + name: string; + email: string | null; + phone: string | null; +} + +export interface SentResetLink { + channel: ResetChannel; + maskedTarget: string; + expiresAt: string; +} @Injectable() export class CustomerResetService { @@ -14,19 +35,110 @@ 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, + }; + } + + /** + * 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; + + // 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 +148,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/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 b357c2cfb..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,7 +4,7 @@ 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"; @@ -22,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); @@ -81,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; } @@ -90,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); @@ -120,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 @@ -134,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); @@ -147,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, }); @@ -157,6 +212,63 @@ 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 { 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 1a375d86f..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 @@ -7,6 +7,7 @@ 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'; @@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service'; Employee, ]), OtpModule, + // Reset links go out over email/SMS directly, not through the OTP service. + NotificationsModule, ], controllers: [ FreightMeController, 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 index 213a14656..81d49a522 100644 --- a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -1,16 +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 { - 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)}`; + 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/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 1cef9528c..1bdde71be 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 @@ -157,13 +157,13 @@ 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, }); } @@ -253,6 +253,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-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index a978fd22b..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[] { @@ -134,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.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index aacc68012..29fec7997 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,7 @@ import { Logger, Optional, } from "@nestjs/common"; -import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -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'; @@ -248,16 +246,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, @@ -283,21 +271,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; } @@ -324,140 +324,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"]); @@ -1298,12 +1164,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 56e788b93..bc8c80982 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, @@ -1023,47 +1021,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 15c5e751d..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'; @@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; Booking, BookingContainer, BookingCargoModifier, - BookingApprovalStep, BookingDocumentReview, BookingRateSnapshot, BookingReviewNote, 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 c93ebd9c7..590bd7262 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -9,7 +9,6 @@ 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, @@ -114,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') @@ -435,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 { @@ -673,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) { @@ -722,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 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..eb4699008 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,4 @@ 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()); - } } 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.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/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 6a0365854..b02f38380 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + sortBy = 'name', + sortOrder = 'ASC', } = query; const qb = this.repository @@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository { ); } + // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. const [items, total] = await qb - .orderBy('company.name', 'ASC') + .orderBy(`company.${sortBy}`, sortOrder) + // 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(); 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 adaa12479..8b5083e5f 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 @@ -47,4 +47,19 @@ export class ListCompaniesQueryDto { @Transform(({ value }: { value: unknown }) => value === "true" || value === true) @IsBoolean() onboardingCompleted?: boolean; + + @ApiPropertyOptional({ + enum: ["name", "createdAt", "updatedAt"], + default: "name", + description: "Column to order by. Defaults to name for backwards compatibility.", + }) + @IsOptional() + @IsIn(["name", "createdAt", "updatedAt"]) + sortBy?: "name" | "createdAt" | "updatedAt"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @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/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/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/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-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 fd1f530b8..e230380f0 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'; @@ -97,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, @@ -352,6 +354,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' @@ -481,6 +489,7 @@ export class ContractBookingService { ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } @@ -568,7 +577,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; } /** 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-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-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index bba044c85..ba825dfaa 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,6 +3,7 @@ import { ConflictException, Injectable, Logger, + ServiceUnavailableException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -17,7 +18,8 @@ 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 } from '../../common/freight-permission.util'; +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'; @@ -48,8 +50,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; } @@ -62,6 +68,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 @@ -73,6 +101,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)) { @@ -87,6 +136,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, @@ -109,34 +159,39 @@ export class ContractTransitionService { ) {} /** - * The phone the signing OTP is sent to and verified against: the signer's own - * IAM account number. + * 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 — a caller-supplied number would let an attacker point the code - * at their own phone. Ownership is already gated separately by + * 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 resolveSignerPhone(signerUserId?: string): Promise { + 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 number if it ever changes. + // 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 }> = + const rows: Array<{ phone_number: string | null; email: string | null }> = await this.dataSource.query( - `SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`, + `SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`, [signerUserId], ); const phone = rows[0]?.phone_number?.trim(); - if (!phone) { + const email = rows[0]?.email?.trim(); + if (!phone && !email) { throw new BadRequestException( - 'Your account has no registered phone number. Add one in Settings → Account before signing.', + 'Your account has no registered phone number or email. Add one in Settings → Account before signing.', ); } - return phone; + return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) }; } /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -229,18 +284,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, }; @@ -255,10 +314,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) ?? @@ -270,9 +331,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); } @@ -334,23 +411,54 @@ 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; + + try { + assertCanApproveContractStep(user, next.requiredRole); + return true; + } catch { + return false; + } } - 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.', + ); } /** @@ -513,25 +621,11 @@ export class ContractTransitionService { 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'); @@ -541,31 +635,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; } @@ -579,14 +676,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.', ); } } @@ -594,24 +696,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, { @@ -626,11 +717,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.`, ); @@ -642,15 +739,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); @@ -839,11 +935,11 @@ export class ContractTransitionService { } /** - * Send the sudo-mode signing OTP to the SIGNER's own 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 authenticated user id. 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, @@ -858,9 +954,9 @@ export class ContractTransitionService { ); assertContractStatus(contract, ['CONTRACT_READY']); - const signerPhone = await this.resolveSignerPhone(options.signerUserId); - await this.otpService.sendOtp({ phone: signerPhone }); - return { sentTo: maskPhone(signerPhone) }; + const signerContacts = await this.resolveSignerContacts(options.signerUserId); + await this.otpService.sendOtp(signerContacts); + return { sentTo: maskSignerContacts(signerContacts) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ @@ -887,17 +983,17 @@ export class ContractTransitionService { } // Sudo-mode gate: a fresh, single-use OTP must be verified before the // signature is applied. H12(b): verify against the SIGNER's own registered - // phone, resolved server-side from the authenticated user id — never a - // caller-supplied number, which an attacker could point at their own - // phone. 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 numbers. - const signerPhone = await this.resolveSignerPhone(options.signerUserId); + // 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: signerPhone }, dto.otp); + await this.otpService.verifyOtpForAction(signerContacts, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', 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..249568046 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -31,7 +31,9 @@ import { ApiTags, } from '@nestjs/swagger'; +import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { assertFreightPermission, @@ -60,7 +62,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 +91,7 @@ import { @ApiBearerAuth() export class ContractsController { constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, @@ -352,8 +354,22 @@ export class ContractsController { 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') @@ -365,8 +381,14 @@ 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') @@ -396,23 +418,20 @@ export class ContractsController { } @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, ); } @@ -968,13 +987,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.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 224745cc4..c98e24b19 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -374,23 +374,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, 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/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 (/