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/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/common/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/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/2430000000000-AddFileReviewStatus.ts b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts new file mode 100644 index 000000000..833d49fea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-document review state, so a backoffice reviewer can request a correction + * on one specific onboarding document instead of rejecting the whole role. + * + * Until now `freight.files` carried no status at all: the `pending_add` / + * `pending_remove` badges the portal shows are derived by diffing live rows + * against an open company change request, which says nothing about whether a + * reviewer is happy with a given document. `review_status` is that missing + * verdict — NULL means never reviewed, which is the state every existing row + * correctly starts in, so no backfill is needed. + * + * The partial index serves the approval gate, which asks "does this company (or + * profile) still have any document with an open change request?" on every + * role-status write. + */ +export class AddFileReviewStatus2430000000000 implements MigrationInterface { + name = 'AddFileReviewStatus2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL, + ADD COLUMN IF NOT EXISTS review_note text NULL, + ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL, + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request" + ON freight.files (resource, resource_id) + WHERE review_status = 'change_requested' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`, + ); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS review_status, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS reviewed_at + `); + } +} diff --git a/apps/edr-freight-api/src/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 e8ad4620e..caa41f5e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -264,6 +264,19 @@ export class BookingLifecycleNotifierService { // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + /** + * A booking was created under a contract. Contract drawdowns never pass + * through submit, so this is the only point at which staff learn the booking + * exists — {@link submittedToStaff} covers the direct-booking flow instead. + */ + createdToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking created', + `Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`, + ); + } + /** Customer submitted a booking for review. */ submittedToStaff(b: Booking): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/bookings/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/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.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8db9eba66..6111bd32a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -494,6 +495,9 @@ export class CompaniesController { mimeType: f.mimeType, size: f.size, uploadedAt: f.createdAt, + reviewStatus: f.reviewStatus, + reviewNote: f.reviewNote, + reviewedAt: f.reviewedAt, // Raw `f.url` is an un-signed MinIO path the browser can't open — sign // it so the file previews/downloads in the client. url: f.url ? await this.filesService.signUrl(f.url) : f.url, @@ -501,6 +505,35 @@ export class CompaniesController { ); } + @Post("documents/:fileId/request-change") + @FreightAdmin() + @ApiOperation({ + summary: "Ask the customer to correct one uploaded document", + description: + "Flags a single document with a reason the customer sees, notifies them, " + + "and blocks role approval until they re-upload. Narrower than rejecting " + + "the whole role.", + }) + async requestDocumentChange( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + @Body() dto: RequestDocumentChangeDto, + ) { + const file = await this.companiesService.requestDocumentChange( + fileId, + dto.note, + user.id, + ); + return { + id: file.id, + name: file.name, + code: file.code, + reviewStatus: file.reviewStatus, + reviewNote: file.reviewNote, + reviewedAt: file.reviewedAt, + }; + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 6a0365854..3ac12b11a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository { ) )`; + /** + * A company waiting on a reviewer to decide an edit it submitted after being + * approved. These rows are `status = active`, so the pending-application filter + * can never surface them — the review queue needs its own predicate. + */ + private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = company.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -67,6 +79,9 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + hasPendingChangeRequest, + sortBy = 'name', + sortOrder = 'ASC', } = query; const qb = this.repository @@ -97,6 +112,14 @@ export class CompaniesRepository extends BaseRepository { ); } + if (hasPendingChangeRequest !== undefined) { + qb.andWhere( + hasPendingChangeRequest + ? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL + : `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -113,8 +136,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(); @@ -137,6 +164,12 @@ export class CompaniesRepository extends BaseRepository { .addGroupBy(CompaniesRepository.DRAFT_SQL) .getRawMany(); + const pendingChanges = await this.repository + .createQueryBuilder('company') + .where('company.deleted_at IS NULL') + .andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL) + .getCount(); + const map = new Map(); let onboarding = 0; let total = 0; @@ -154,6 +187,7 @@ export class CompaniesRepository extends BaseRepository { onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, + pendingChanges, }; } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 04b8790cd..df0e14998 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; +import { DataSource } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -98,6 +99,7 @@ export class CompaniesService { private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, + private readonly dataSource: DataSource, ) { } /** @@ -748,7 +750,15 @@ export class CompaniesService { submittedAt: now, note: null, })) ?? existing; + this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { + // Rejecting a request leaves it Rejected rather than reopening it, so a + // customer amending after a rejection lands here with a fresh Pending row. + // That is the resubmission case the reviewer needs flagged. + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); request = await this.changeRequestRepo.create({ companyId: company.id, snapshot: fields, @@ -756,6 +766,11 @@ export class CompaniesService { submittedBy: userId, submittedAt: now, }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); } // Live company is unchanged; surface the pending state for the settings page. @@ -827,6 +842,12 @@ export class CompaniesService { "companies", files, ); + await this.resolveDocumentChangeRequests( + companyId, + "companies", + uploaded.map((f) => f.code), + uploaded.map((f) => f.id), + ); if (company.status === CompanyStatus.Active) { await this.stageDocumentChange( company.id, @@ -837,6 +858,95 @@ export class CompaniesService { return uploaded; } + /** + * Clear the `change_requested` flag from the documents a fresh upload replaces. + * + * Uploading does not overwrite the old row — it adds a new one under the same + * `code` — so the flagged original would otherwise linger and keep the approval + * gate closed even after the customer did exactly what was asked. Only rows of + * the same code are touched, and never the newly uploaded ones. + */ + private async resolveDocumentChangeRequests( + resourceId: string, + resource: string, + codes: string[], + uploadedIds: string[], + ): Promise { + if (codes.length === 0) return; + const replaced = new Set(codes); + const fresh = new Set(uploadedIds); + const open = await this.filesService.findWithOpenChangeRequest( + [resourceId], + resource, + ); + await Promise.all( + open + .filter((f) => replaced.has(f.code) && !fresh.has(f.id)) + .map((f) => this.filesService.clearReview(f.id)), + ); + } + + /** + * Backoffice: ask the customer to correct one specific document, instead of + * rejecting their whole role over it. Mirrors the contract change-request + * flow — a note the customer sees verbatim, plus a block on approval until + * they re-upload. + */ + async requestDocumentChange( + fileId: string, + note: string, + reviewerId?: string, + ): Promise { + const file = await this.filesService.findById(fileId); + const companyId = await this.resolveDocumentCompanyId(file); + const company = await this.findCompanyById(companyId); + + // Flag the document while holding a write lock on its company row. The + // approval gate takes the same lock before it reads the flags, so the two + // serialize: a change request can never land in the window between the gate + // checking "any open corrections?" and writing the profile Active. + const updated = await this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: companyId }, + lock: { mode: "pessimistic_write" }, + }); + return this.filesService.setReviewStatus( + file.id, + "change_requested", + note, + reviewerId, + ); + }); + this.companyNotifier.documentChangeRequested( + company, + file.name, + note, + file.id, + ); + return updated; + } + + /** + * Which company a stored document belongs to. Company documents are keyed by + * the company id directly; profile licences and POA letters hang off a company + * profile, so those resolve through it. + */ + private async resolveDocumentCompanyId(file: FileRecord): Promise { + if (file.resource === "companies") return file.resourceId; + if (file.resource === "company_profiles") { + const profile = await this.companyProfilesRepo.findById(file.resourceId); + if (!profile) { + throw new NotFoundException( + `Company profile ${file.resourceId} not found`, + ); + } + return profile.companyId; + } + throw new BadRequestException( + `Documents on "${file.resource}" do not support change requests`, + ); + } + /** Open or append a pending change request recording staged document uploads. */ private async stageDocumentChange( companyId: string, @@ -847,6 +957,7 @@ export class CompaniesService { const now = new Date(); const existing = await this.changeRequestRepo.findPendingByCompanyId(companyId); + const company = await this.companiesRepo.findById(companyId); if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { @@ -860,8 +971,15 @@ export class CompaniesService { submittedAt: now, note: null, }); + if (company) { + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + } } else { - await this.changeRequestRepo.create({ + const history = await this.changeRequestRepo.findByCompanyId(companyId); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const created = await this.changeRequestRepo.create({ companyId, snapshot: {}, documents: { documentFileIds: fileIds }, @@ -869,6 +987,13 @@ export class CompaniesService { submittedBy: submittedBy ?? null, submittedAt: now, }); + if (company) { + this.companyNotifier.changeRequestSubmitted( + company, + created.id, + resubmitted, + ); + } } } @@ -987,6 +1112,61 @@ export class CompaniesService { } } + // Anything other than approval has no document gate and no concurrency + // hazard — apply it directly. + if (status !== ProfileStatus.Active) { + return this.applyProfileStatus(existing, status, note, reviewerId); + } + + // Approving over an outstanding document correction would silently accept the + // very document a reviewer just rejected, and would strand the customer's + // "please fix this" banner with nothing left to fix. The gate check and the + // status write share a write lock on the company row — `requestDocumentChange` + // takes the same lock, so a fresh correction can never land in the window + // between "any open corrections?" and the profile going Active. Suspend and + // blacklist skip all this — staff must always be able to act against a bad + // account. + return this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: existing.companyId }, + lock: { mode: "pessimistic_write" }, + }); + + const [companyDocs, profileDocs] = await Promise.all([ + this.filesService.findWithOpenChangeRequest( + [existing.companyId], + "companies", + ), + this.filesService.findWithOpenChangeRequest( + [existing.id], + "company_profiles", + ), + ]); + const pending = [...companyDocs, ...profileDocs]; + if (pending.length > 0) { + const names = pending.map((f) => f.name).join(", "); + throw new BadRequestException( + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + + `Approve it once the customer has re-uploaded them, or withdraw the change request first.`, + ); + } + + return this.applyProfileStatus(existing, status, note, reviewerId); + }); + } + + /** + * Write a reviewed profile status (reference minting, note handling, reviewer + * stamp) and promote the company if this is its first approved role. Split out + * of `setCompanyProfileStatus` so the approval path can run it inside the gate + * transaction while every other status skips that overhead. + */ + private async applyProfileStatus( + existing: CompanyProfile, + status: ProfileStatus, + note?: string, + reviewerId?: string, + ): Promise { // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -1008,9 +1188,9 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(profileId, patch); + const updated = await this.companyProfilesRepo.update(existing.id, patch); if (!updated) - throw new NotFoundException(`Company profile ${profileId} not found`); + throw new NotFoundException(`Company profile ${existing.id} not found`); // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. @@ -1057,6 +1237,13 @@ export class CompaniesService { }); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // The role is back in the pending queue — tell the reviewers, otherwise the + // resubmission is invisible until someone happens to reopen the customer. + const company = await this.companiesRepo.findById(companyId); + if (company) { + this.companyNotifier.roleReapplied(company, updated.id, updated.type); + } return updated; } @@ -1512,6 +1699,15 @@ export class CompaniesService { ); } + // A fresh licence upload answers any correction the reviewer asked for on the + // previous one, so the old row must stop blocking approval. + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + uploaded.map((r) => r.id), + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1593,6 +1789,13 @@ export class CompaniesService { await this.filesService.remove(fileId); } + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + [created.id], + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1689,6 +1892,8 @@ export class CompaniesService { : pendingRemoveIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -1855,6 +2060,13 @@ export class CompaniesService { for (const r of live) await this.filesService.remove(r.id); } + await this.resolveDocumentChangeRequests( + company.id, + COMPANY_RESOURCE, + [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE], + [created.id], + ); + return this.getPoaDelegationView(company.id); } @@ -1932,6 +2144,8 @@ export class CompaniesService { : removeIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 43d4e9905..f71a67976 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -88,4 +88,106 @@ export class CompanyNotifierService { priority: NotificationPriority.HIGH, }); } + + // ── Backoffice-facing: work has arrived back in the review queue ──────────── + + /** + * Persist + push an in-app item to every backoffice staff user, deep-linked to + * the customer's detail page. + * + * The recipient resolver has no role/permission targeting (see + * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest + * selector available, so marketing is reached by notifying all staff. + */ + private notifyStaff( + company: Company, + title: string, + body: string, + data: Record = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/customers/${company.id}`, + data: { companyId: company.id, companyName: company.name, ...data }, + }); + } + + /** + * A customer resubmitted an operational role after it was rejected for + * adjustment. Without this the role silently flips back to Pending and nobody + * is told there is anything to look at again. + */ + roleReapplied(company: Company, profileId: string, profileType: string): void { + this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`); + this.notifyStaff( + company, + "Customer resubmitted a role for approval", + `${company.name} has adjusted and resubmitted its ${profileType} role. ` + + `It is back in the pending approval queue for review.`, + { profileId, profileType }, + ); + } + + /** + * A customer submitted (or amended and resubmitted) a profile change request. + * `resubmitted` distinguishes the two so the reviewer knows this is a second + * look at something they already sent back. + */ + changeRequestSubmitted( + company: Company, + changeRequestId: string, + resubmitted: boolean, + ): void { + this.logger.log( + `CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`, + ); + this.notifyStaff( + company, + resubmitted + ? "Customer resubmitted profile changes" + : "Customer submitted profile changes", + resubmitted + ? `${company.name} has adjusted the changes you sent back and resubmitted ` + + `them. They are pending your review.` + : `${company.name} has submitted profile changes that are pending review.`, + { changeRequestId }, + ); + } + + // ── Customer-facing: a specific document needs correcting ────────────────── + + /** + * Tell the customer a reviewer wants one specific document corrected. Mirrors + * the contract `changesRequested` flow: SMS + email out, plus an in-app item + * deep-linked to the documents tab where they can re-upload. + */ + documentChangeRequested( + company: Company, + documentName: string, + note: string, + fileId: string, + ): void { + const title = "Document change requested"; + const body = + `A reviewer has asked you to correct "${documentName}". ` + + `Reason: ${note} ` + + `Please upload a corrected version from your settings page.`; + + this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title, + body, + link: "/settings", + data: { companyId: company.id, fileId, documentName }, + priority: NotificationPriority.HIGH, + }); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index c054b3531..e97a21266 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -7,4 +7,10 @@ export class CompanyStatsResponseDto { onboarding!: number; suspended!: number; blacklisted!: number; + /** + * Approved customers with an open profile change request. Counted separately + * because they are `active` and so are invisible to the `pending` KPI, even + * though they are just as much waiting on a reviewer. + */ + pendingChanges!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index eb32f72ae..7816572ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() - @MaxLength(10) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index a56ea5ad8..be911b3ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CreateCompanyDto { @IsString() @@ -17,7 +18,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 2eb37c92d..466c03ed6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,8 +1,9 @@ -import { IsString, IsNotEmpty, Length } from "class-validator"; +import { IsString, IsNotEmpty } from "class-validator"; +import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index adaa12479..8d4910ded 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,30 @@ export class ListCompaniesQueryDto { @Transform(({ value }: { value: unknown }) => value === "true" || value === true) @IsBoolean() onboardingCompleted?: boolean; + + @ApiPropertyOptional({ + description: + "`true` = only companies with an open (pending) profile change request. " + + "These are already-approved customers, so they never appear under " + + "`status=pending` and would otherwise be invisible in the review queue.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + hasPendingChangeRequest?: boolean; + + @ApiPropertyOptional({ + enum: ["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/request-document-change.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts new file mode 100644 index 000000000..8119e041d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RequestDocumentChangeDto { + /** What is wrong with this document — shown verbatim to the customer. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9fd8f28ae..ba3e27aeb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,6 +1,8 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class UpdateProfileDto { @IsOptional() @@ -34,7 +36,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() @@ -137,10 +139,14 @@ export class UpdateProfileDto { @MaxLength(50) renewedTo?: string; + // Zone/woreda/kebele below stay free text: there is no authoritative dataset + // of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns + // them uncoded. Only region is a closed set today. @IsOptional() - @IsString() - @MaxLength(100) - region?: string; + @IsIn(ETHIOPIAN_REGIONS as unknown as string[], { + message: "region must be a recognised Ethiopian region", + }) + region?: EthiopianRegion; @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index ebda7a0b9..9bba9396e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -37,8 +37,20 @@ export interface BusinessLicenseFile { */ export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; +/** + * Reviewer verdict on a document, as surfaced to clients. Distinct from + * {@link StagedFileStatus}: that describes where the file sits in the staged + * add/remove workflow, this describes whether a reviewer wants it corrected. + */ +export interface FileReviewView { + /** `change_requested` while the customer still owes a corrected upload. */ + reviewStatus?: "change_requested" | "approved" | null; + /** The reviewer's reason, shown verbatim to the customer. */ + reviewNote?: string | null; +} + /** A business-license file plus its change-review state, surfaced to clients. */ -export interface ProfileLicenseFileView { +export interface ProfileLicenseFileView extends FileReviewView { id: string; name: string; size: number; @@ -47,7 +59,7 @@ export interface ProfileLicenseFileView { } /** A company-level document (e.g. the PoA letter) with its change-review state. */ -export interface CompanyDocumentFileView { +export interface CompanyDocumentFileView extends FileReviewView { id: string; name: string; size: number; diff --git a/apps/edr-freight-api/src/modules/companies/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 b7484275d..3c3f81461 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, @@ -353,6 +355,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' @@ -482,6 +490,7 @@ export class ContractBookingService { ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } @@ -569,7 +578,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 4b179814c..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', 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 c3e03c4eb..ba4fe442f 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 @@ -102,6 +102,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)) { @@ -139,34 +160,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. */ @@ -992,11 +1018,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, @@ -1011,9 +1037,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. */ @@ -1040,17 +1066,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 8b7a62536..6ca4473cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -31,6 +31,7 @@ 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'; @@ -990,13 +991,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/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/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 221b7c29b..1fbd1459f 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -1,6 +1,16 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; +/** + * Reviewer verdict on a single stored document. + * + * `null` (the default) means "not reviewed" — the state every file starts in and + * the only state the customer is not blocked by. `change_requested` is raised by + * a backoffice reviewer against one specific document and is what the customer + * must clear by re-uploading; `approved` records an explicit sign-off. + */ +export type FileReviewStatus = "change_requested" | "approved"; + @Entity({ schema: "freight", name: "files" }) export class FileRecord extends BaseEntity { @Column({ name: "resource_id", type: "uuid" }) @@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity { @Column({ name: "mime_type", type: "varchar", length: 255 }) mimeType!: string; + + /** Reviewer verdict, or `null` while the document has never been reviewed. */ + @Column({ + name: "review_status", + type: "varchar", + length: 32, + nullable: true, + default: null, + }) + reviewStatus!: FileReviewStatus | null; + + /** Why a change was requested — shown verbatim to the customer. */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote!: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy!: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt!: Date | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 307e24985..6978ad446 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -1,12 +1,19 @@ +import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types"; import { Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, Query, Res, } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; import { Response } from "express"; import { FilesService } from "./files.service"; @@ -23,13 +30,16 @@ export class FilesController { // Browser inline previews (/