Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:20:36 +00:00
226 changed files with 13854 additions and 2973 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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<void> {
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.',
);
}
}

View File

@@ -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<Parameters<typeof usesEdrMileService>[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);
});
});

View File

@@ -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 customers 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.';

View File

@@ -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();
});
});

View File

@@ -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<string[]> {
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());
}

View File

@@ -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<ReturnType<typeof errorsFor>>) =>
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');
});
});

View File

@@ -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,
});
};
}

View File

@@ -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('');
});
});

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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 = <message 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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS handles_container,
DROP COLUMN IF EXISTS handles_bulk
`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
// 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<void> {
// 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.
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<CustomerResetTarget> {
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;
}
}

View File

@@ -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<ExternalProfile>,
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<CustomerResetTarget | null> {
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<string | null> {
): Promise<SentResetLink | null> {
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<string>("app.portalBaseUrl");
return `${base}/reset-password?uid=${encodeURIComponent(
userId,
)}&token=${encodeURIComponent(token)}`;
}
}

View File

@@ -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;
}

View File

@@ -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<ResetTicket> {
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<ResetLinkAccount> {
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
}
}

View File

@@ -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<OtpTarget | null> {
const target = this.targetFor(user, channel);
async requestReset(user: User): Promise<OtpTarget | null> {
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<ResetTicket> {
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<ResetTicket> {
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<ResetLinkAccount> {
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);

View File

@@ -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,

View File

@@ -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 ");
}

View File

@@ -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(

View File

@@ -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<string[]> {
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());
}
}

View File

@@ -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;

View File

@@ -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")

View File

@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
)
)`;
/**
* 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<Company>,
@@ -67,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
kind,
status,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'name',
sortOrder = 'ASC',
} = query;
const qb = this.repository
@@ -97,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
);
}
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<Company> {
);
}
// 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<Company> {
.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<string, number>();
let onboarding = 0;
let total = 0;
@@ -154,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
pendingChanges,
};
}
}

View File

@@ -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<void> {
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<FileRecord> {
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<string> {
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<CompanyProfile> {
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { 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,
}));
}

View File

@@ -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<string, unknown> = {},
): 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,
});
}
}

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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;

View File

@@ -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 || "",

View File

@@ -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);
}
}
});
});

View File

@@ -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',

View File

@@ -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);
});
});
});

View File

@@ -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<ClearanceMilestone> {
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. */

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
/**

View File

@@ -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',

View File

@@ -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<string> {
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',

View File

@@ -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),
);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 (<img>/<iframe>/<a>) that can't carry the Bearer
// token should use a short-lived signed URL instead (FilesService.signUrl).
// TODO: enforce ownership-by-resource here next (scope the file to the
// caller's booking/company before streaming).
// caller's booking/company before streaming). Until that lands, any resource
// whose files are cross-tenant sensitive must opt OUT of this route and expose
// its own checked endpoint — see the support_message case below.
@ApiOperation({
summary: "Stream a file by ID",
description:
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
"No resource context (e.g. booking ID) required. Serves inline by default so " +
"the browser can preview it; pass ?download=1 to force a download.",
"the browser can preview it; pass ?download=1 to force a download. " +
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
})
@ApiQuery({
name: "download",
@@ -41,7 +51,19 @@ export class FilesController {
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const { stream, record } = await this.filesService.streamById(fileId);
const record = await this.filesService.findById(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
// another. SupportAttachmentController scopes the caller to the owning
// thread; refuse here rather than quietly serving the bytes.
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
throw new ForbiddenException(
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
);
}
const { stream } = await this.filesService.streamById(fileId);
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { FileRecord } from "./entities/file.entity";
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
return this.repository.find({ where: { resourceId, resource } });
}
/**
* Batch sibling of {@link findByResource} for hydrating a page of resources at
* once (a thread of chat messages, say) instead of one query per row.
*/
async findByResourceIds(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
if (resourceIds.length === 0) return [];
return this.repository.find({
where: { resourceId: In(resourceIds), resource },
order: { createdAt: "ASC" },
});
}
findByCode(
resourceId: string,
resource: string,
@@ -33,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of
* resource ids (a company plus each of its company profiles) in one query.
*/
async findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
if (resourceIds.length === 0) return [];
return this.repository.find({
where: {
resourceId: In(resourceIds),
resource,
reviewStatus: "change_requested",
},
order: { createdAt: "ASC" },
});
}
}

View File

@@ -3,11 +3,12 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord } from "./entities/file.entity";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
export interface CreateFileInput {
resourceId: string;
@@ -78,8 +79,19 @@ export class FilesService {
// percent-encoded in the URL and no longer match the MinIO key). The
// human-readable name is preserved separately on the record below.
const safeName = sanitizeObjectName(file.originalname);
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
// The random segment is load-bearing, not decoration. `Date.now()` alone is
// NOT unique across a batch: callers upload with Promise.all, every callback
// runs to its first await in the same tick, so they all read the same
// millisecond. Two files with one name in one batch — e.g. pasting two
// screenshots, which browsers both call "image.png" — would build identical
// keys, and the second putObject would overwrite the first while both rows
// persisted pointing at the same object.
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
const url = await this.minioService.uploadFile(
objectName,
file.buffer,
file.mimetype,
);
return this.filesRepository.create({
resourceId,
@@ -157,6 +169,53 @@ export class FilesService {
return record;
}
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
* reason can never outlive the request it explained.
*/
async setReviewStatus(
id: string,
status: FileReviewStatus,
note: string | null,
reviewerId?: string,
): Promise<FileRecord> {
const record = await this.findById(id);
const updated = await this.filesRepository.update(record.id, {
reviewStatus: status,
reviewNote: status === "change_requested" ? (note ?? null) : null,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
});
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Drop any reviewer verdict from a document, returning it to "not reviewed".
* Called when a customer re-uploads: the new bytes have not been looked at, so
* carrying the old `change_requested` forward would keep them blocked forever.
*/
async clearReview(id: string): Promise<void> {
await this.filesRepository.update(id, {
reviewStatus: null,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
}
/** Documents across these resources still awaiting a customer correction. */
findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findWithOpenChangeRequest(
resourceIds,
resource,
);
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
@@ -175,6 +234,27 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).
*/
async findByResourceIdsGrouped(
resourceIds: string[],
resource: string,
): Promise<Map<string, FileRecord[]>> {
const records = await this.filesRepository.findByResourceIds(
resourceIds,
resource,
);
const grouped = new Map<string, FileRecord[]>();
for (const record of records) {
const bucket = grouped.get(record.resourceId);
if (bucket) bucket.push(record);
else grouped.set(record.resourceId, [record]);
}
return grouped;
}
/**
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
* `url` is an un-signed object path that a browser cannot fetch directly;
@@ -190,7 +270,11 @@ export class FilesService {
resource: string,
code: string,
): Promise<FileRecord> {
const record = await this.filesRepository.findByCode(resourceId, resource, code);
const record = await this.filesRepository.findByCode(
resourceId,
resource,
code,
);
if (!record)
throw new NotFoundException(
`File with code "${code}" not found for ${resource} ${resourceId}`,
@@ -198,7 +282,9 @@ export class FilesService {
return record;
}
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
async streamById(
id: string,
): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);

View File

@@ -333,10 +333,12 @@ export class FirstMileService {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
// The pickup address is the only record of what the contract chose.
// `serviceType.includesFirstMile` used to satisfy this too, but every
// service type ships with it set to true, so the OR made the address check
// dead and admitted every paid export booking into the queue.
return Boolean(
booking.tradeDirection === 'EXPORT' &&
(booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile),
booking.tradeDirection === 'EXPORT' && booking.firstMilePickupAddress?.trim(),
);
}

View File

@@ -0,0 +1,108 @@
// health.controller.ts
import { Controller, Get, HttpStatus, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { InjectDataSource } from "@nestjs/typeorm";
import { Public } from "@edr/api-common";
import { Response } from "express";
import { DataSource } from "typeorm";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
type CheckStatus = "ok" | "error" | "unknown";
/**
* Readiness normally stays green when only the broker is down.
*
* A 503 pulls the pod out of the load balancer, which would take booking,
* tracking and billing offline because SMS is unreachable — a strictly worse
* outcome than degraded notifications. The broker check is therefore reported,
* not enforced, and `READINESS_REQUIRES_BROKER=true` opts into hard-failing for
* deployments where a silent OTP black hole is the greater risk.
*/
const READINESS_REQUIRES_BROKER =
process.env.READINESS_REQUIRES_BROKER === "true";
@ApiTags("Health")
@Controller("health")
export class HealthController {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly smsClient: SmsClientService,
private readonly emailClient: EmailClientService,
) {}
@Get()
@Public()
@ApiOperation({ summary: "Liveness probe" })
liveness() {
return { status: "ok", timestamp: new Date().toISOString() };
}
@Get("ready")
@Public()
@ApiOperation({
summary:
"Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.",
})
async readiness(@Res() res: Response) {
const startedAt = Date.now();
let database: { status: CheckStatus; latencyMs: number; error?: string };
try {
await this.dataSource.query("SELECT 1");
database = { status: "ok", latencyMs: Date.now() - startedAt };
} catch (error) {
database = {
status: "error",
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : "Unknown error",
};
}
// `null` from the client means the connection manager was not reachable
// through Nest's internals — surfaced as "unknown" so a shape change in
// @nestjs/microservices degrades to honest ignorance, not a false "ok".
const toStatus = (connected: boolean | null): CheckStatus =>
connected === null ? "unknown" : connected ? "ok" : "error";
const broker = {
sms: { status: toStatus(this.smsClient.brokerConnected) },
email: { status: toStatus(this.emailClient.brokerConnected) },
// Every OTP, and every booking/billing notification, publishes through
// these. `error` here means codes are being generated and silently dropped.
enabled: process.env.RABBITMQ_ENABLED !== "false",
};
const brokerDown =
broker.sms.status === "error" || broker.email.status === "error";
const failed =
database.status === "error" ||
(READINESS_REQUIRES_BROKER && brokerDown);
const status = failed ? "error" : brokerDown ? "degraded" : "ok";
return res
.status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
.json({
status,
timestamp: new Date().toISOString(),
checks: { database, broker },
});
}
@Get("info")
@Public()
@ApiOperation({ summary: "App info — version, environment, uptime" })
info() {
return {
name: "edr-freight-api",
version: process.env.npm_package_version ?? "1.0.0",
environment: process.env.NODE_ENV ?? "development",
uptimeSeconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,14 @@
// health.module.ts
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
// NotificationsModule exports the SMS/email clients; the readiness probe reads
// their broker connection state rather than opening a second connection.
imports: [NotificationsModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,99 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { LastMileService } from './last-mile.service';
/**
* A booking reaches the last-mile queue only if its contract bought EDR
* delivery, and never if the customer is hauling it themselves. Creation used
* to check payment alone, so any paid booking could be accepted — which put a
* self-haul booking and an EDR leg on the same shipment at once.
*/
type BookingRow = { tradeDirection: string; firstMile: string | null; lastMile: string | null };
function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) {
const booking = opts.booking ?? {
tradeDirection: 'IMPORT',
firstMile: null,
lastMile: 'Bole, Addis Ababa',
};
const query = jest.fn((sql: string) => {
if (sql.includes('customer_truck_assignments')) {
return Promise.resolve(opts.hasCustomerTruck ? [{ '?column?': 1 }] : []);
}
if (sql.includes('FROM freight.bookings')) {
return Promise.resolve([booking]);
}
return Promise.resolve([]);
});
const lastMileRepository = {
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
};
const service = new LastMileService(
lastMileRepository as never,
{} as never, // bookingsRepository
{ setAvailability: jest.fn() } as never, // vehiclesService
{} as never, // driversService
{} as never, // smsClient
{ query } as unknown as DataSource,
{ record: jest.fn() } as never, // history
{} as never, // billing
{} as never, // filesService
);
return { service, lastMileRepository, query };
}
describe('LastMileService.create — haulage guard', () => {
it('accepts a booking whose contract chose EDR delivery', async () => {
const { service, lastMileRepository } = makeService({});
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(lastMileRepository.create).toHaveBeenCalled();
});
it('rejects a booking that chose no road legs on its contract', async () => {
const { service, lastMileRepository } = makeService({
booking: { tradeDirection: 'IMPORT', firstMile: null, lastMile: null },
});
await expect(
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(lastMileRepository.create).not.toHaveBeenCalled();
});
it('rejects a booking already hauled by the customers own truck', async () => {
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
await expect(
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(lastMileRepository.create).not.toHaveBeenCalled();
});
it('rejects an import that only chose collection — that is the export leg', async () => {
const { service } = makeService({
booking: { tradeDirection: 'IMPORT', firstMile: 'Modjo', lastMile: null },
});
await expect(
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('returns the existing leg without re-checking, so the queue stays idempotent', async () => {
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
lastMileRepository.findAll.mockResolvedValue([{ id: 'lm-existing' }]);
const result = await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(result).toEqual({ id: 'lm-existing' });
expect(lastMileRepository.create).not.toHaveBeenCalled();
});
});

View File

@@ -7,6 +7,18 @@ import {
} from '@nestjs/common';
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import {
NO_MILE_SERVICE_MESSAGE,
SELF_HAUL_CONFLICT_MESSAGE,
usesEdrMileService,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
bookingContainerSizes,
remainingBulkTons,
} from '../../common/truck-load.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { SmsClientService } from '../notifications/sms-client.service';
@@ -126,6 +138,47 @@ export class LastMileService {
}
}
/**
* Only a booking that actually bought EDR delivery belongs in the last-mile
* queue, and a booking hauled by the customer's own truck must never also get
* an EDR leg.
*
* Both halves were missing: creation checked payment alone, so any paid
* booking could be accepted into the queue — including one whose contract
* chose no road legs at all, and one already carrying a customer truck. The
* mirror rule existed on the truck side only
* (CustomerTruckService.assertSelfHaulPaid), so whichever side acted second
* silently opened a competing delivery on the same booking.
*/
private async assertEdrHaulsThisBooking(bookingId?: string | null): Promise<void> {
if (!bookingId) return;
const [booking] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
// The road legs are chosen on the contract and copied onto the booking, so
// the booking's own addresses answer this without a join.
if (booking && !usesEdrMileService(booking)) {
throw new BadRequestException(NO_MILE_SERVICE_MESSAGE);
}
const [truck] = await this.dataSource.query(
`SELECT 1
FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (truck) {
throw new BadRequestException(SELF_HAUL_CONFLICT_MESSAGE);
}
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -352,6 +405,8 @@ export class LastMileService {
return existing;
}
await this.assertEdrHaulsThisBooking(dto.bookingId);
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -573,20 +628,6 @@ export class LastMileService {
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
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());
}
/**
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
@@ -599,26 +640,9 @@ export class LastMileService {
remainingTons: number;
complete: boolean;
}> {
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
await this.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) 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 };
// Counts customer trucks as well as EDR ones — a booking hauls by one path
// or the other, and "until no tonnage is left" means the same either way.
return remainingBulkTons(this.dataSource, bookingId);
}
/**
@@ -643,11 +667,7 @@ export class LastMileService {
);
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
if (totalTons > 0 && remainingTons <= 0) {
throw new BadRequestException(
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
);
}
assertBulkTonnageRemains(totalTons, remainingTons);
return;
}
@@ -657,33 +677,41 @@ export class LastMileService {
const seen = new Set<string>();
for (const vehicleId of desired) {
const load = loads.get(vehicleId) ?? [];
if (load.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
for (const n of load) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
if (seen.has(n)) {
throw new ConflictException(`Container ${n} is already assigned to another truck`);
}
seen.add(n);
}
// A 40ft container fills the truck; only two 20ft share one.
if (load.length > 1) {
const sizes = await this.containerSizes(bookingId, load);
if (sizes.some((s) => s.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
assertTruckLoad({
containers: load,
bookingContainers: bookingNumbers,
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
assignedElsewhere: [...seen],
});
load.forEach((n) => seen.add(n));
}
if (desired.length > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
}
/**
* A truck that has already reached the customer cannot have its load rewritten
* — the containers on it are a delivered fact, not a plan. The customer side
* has locked this since it was built (`Cannot edit a truck that has already
* arrived`); the EDR side let a reassignment silently rewrite history.
*/
private async assertNoArrivedVehicleChanged(
current: LastMileVehicleAssignment[],
desiredMap: Map<string, string[]>,
): Promise<void> {
const loadKey = (list: string[]) => [...list].sort().join('|');
for (const assignment of current) {
if (!assignment.arrivedAt) continue;
const stillPresent = desiredMap.has(assignment.vehicleId);
const load = desiredMap.get(assignment.vehicleId) ?? [];
const currentLoad = (assignment.containers ?? []).map((c) =>
c.containerNumber.trim().toUpperCase(),
);
if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
throw new ConflictException(
'This truck has already arrived — its load can no longer be changed or removed',
);
}
}
}
@@ -718,6 +746,8 @@ export class LastMileService {
where: { lastMileId: id },
relations: { containers: true },
});
await this.assertNoArrivedVehicleChanged(current, desiredMap);
const junctionSet = new Set(current.map((a) => a.vehicleId));
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
// old single-vehicle path has no junction row but must still be freed.

View File

@@ -0,0 +1,90 @@
import { Logger } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { NEVER, Observable, throwError } from 'rxjs';
import { isBrokerConnected, publishConfirmed } from './broker.util';
/**
* `ClientProxy.emit()` returns a cold Observable that, for RMQ, completes without
* emitting once `dispatchEvent` settles — and rejects if the publish fails. These
* fakes reproduce each of those three shapes.
*/
function clientEmitting(source: Observable<unknown>): ClientProxy {
return { emit: jest.fn().mockReturnValue(source) } as unknown as ClientProxy;
}
describe('publishConfirmed', () => {
const logger = { error: jest.fn() } as unknown as Logger;
beforeEach(() => jest.clearAllMocks());
it('is true when the publish completes (broker confirmed)', async () => {
// Completes with no value — the success shape, and the case that throws
// EmptyError without a defaultIfEmpty.
const client = clientEmitting(new Observable<never>((s) => s.complete()));
await expect(publishConfirmed(client, 'send-sms', {}, logger)).resolves.toBe(true);
});
it('is false when the publish never settles, rather than hanging', async () => {
// A broker that is down: amqp-connection-manager buffers the publish and the
// promise would never resolve. The timeout is what stops one dead broker from
// hanging every caller of sendSms/sendEmail.
const client = clientEmitting(NEVER);
await expect(publishConfirmed(client, 'send-sms', {}, logger, 20)).resolves.toBe(
false,
);
expect(logger.error).toHaveBeenCalled();
});
it('is false when the publish errors', async () => {
const client = clientEmitting(throwError(() => new Error('channel closed')));
await expect(publishConfirmed(client, 'send-email', {}, logger)).resolves.toBe(
false,
);
expect(logger.error).toHaveBeenCalled();
});
});
describe('isBrokerConnected', () => {
/** Stands in for `ClientProxy.unwrap()`, which returns the AmqpConnectionManager. */
function clientUnwrapping(manager: unknown): ClientProxy {
return { unwrap: () => manager } as unknown as ClientProxy;
}
it('reports the connection manager state', () => {
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => true }))).toBe(
true,
);
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => false }))).toBe(
false,
);
});
it('is false when unwrap throws — the client never connected', () => {
// ClientRMQ.unwrap() throws "Not initialized" while its internal client is
// null, which is what a failed boot-time connect leaves behind. That is a
// real down signal and must not be softened to "unknown".
const uninitialised = {
unwrap: () => {
throw new Error('Not initialized. Please call the "connect" method first.');
},
} as unknown as ClientProxy;
expect(isBrokerConnected(uninitialised)).toBe(false);
});
it('is null — not a guess — when the manager lacks isConnected or it throws', () => {
// Guards the health endpoint against reporting "ok" if amqp-connection-manager
// or Nest changes shape and the accessor we rely on disappears.
expect(isBrokerConnected(clientUnwrapping(null))).toBeNull();
expect(isBrokerConnected(clientUnwrapping({}))).toBeNull();
expect(
isBrokerConnected(
clientUnwrapping({
isConnected: () => {
throw new Error('boom');
},
}),
),
).toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
// broker.util.ts
import { Logger } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { defaultIfEmpty, lastValueFrom, timeout } from "rxjs";
/**
* How long to wait for a publisher confirm before giving up on a message.
*
* Load-bearing, not a nicety: when the broker is unreachable
* amqp-connection-manager buffers the publish and retries it on reconnect, so the
* underlying promise never settles. Without a bound, one dead broker turns every
* caller of sendSms/sendEmail into a hung request.
*/
export const PUBLISH_CONFIRM_TIMEOUT_MS = Number(
process.env.RABBITMQ_PUBLISH_TIMEOUT_MS ?? 5000,
);
/**
* Publish an event and wait for RabbitMQ to confirm it.
*
* `ClientProxy.emit()` returns a *cold* Observable. Called without subscribing —
* as this codebase did everywhere — nothing forces the publish to be observed, so
* the caller reports success whether or not the broker ever accepted the message.
* Awaiting it drives `dispatchEvent`, which resolves only once
* amqp-connection-manager's ChannelWrapper has a publisher confirm.
*
* So `true` here means the broker took ownership of the message. It still says
* nothing about the consumer, the SMS gateway, or delivery to a handset — those
* remain outside this process's knowledge.
*/
export async function publishConfirmed(
client: ClientProxy,
pattern: string,
payload: unknown,
logger: Logger,
timeoutMs: number = PUBLISH_CONFIRM_TIMEOUT_MS,
): Promise<boolean> {
try {
// `emit` completes without emitting a value, so lastValueFrom needs a default
// or it rejects with EmptyError on the success path.
await lastValueFrom(
client
.emit(pattern, payload)
.pipe(timeout(timeoutMs), defaultIfEmpty(undefined)),
);
return true;
} catch (error) {
logger.error(
`broker.publish.failed pattern='${pattern}' timeoutMs=${timeoutMs}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
return false;
}
}
/**
* Whether the client's connection manager currently believes it is connected.
*
* Uses `ClientProxy.unwrap()` — Nest's public accessor for the underlying
* transport client, which for `ClientRMQ` is the `AmqpConnectionManager`. Calling
* `connect()` instead cannot answer this: it resolves against a *disconnected*
* manager too, so it never distinguishes up from down.
*
* Three outcomes, deliberately distinct:
* - `false` when the manager reports disconnected, or when `unwrap()` throws
* because the client was never initialised (a failed boot-time connect leaves
* it null — genuinely down, not unknown);
* - `null` when the manager exists but has no `isConnected`, i.e. the library
* shape changed under us — the health endpoint reports "unknown" rather than
* quietly claiming health;
* - `true` only on an explicit positive from the manager.
*/
export function isBrokerConnected(client: ClientProxy): boolean | null {
let manager: unknown;
try {
manager = client.unwrap<unknown>();
} catch {
// "Not initialized. Please call the connect method first." — no connection
// was ever established, which is a real down signal, not an unknown one.
return false;
}
const probe = manager as { isConnected?: () => boolean } | null;
if (!probe || typeof probe.isConnected !== "function") return null;
try {
return probe.isConnected();
} catch {
return null;
}
}

View File

@@ -6,6 +6,7 @@ import {
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmailDto } from "./dtos/email.dto";
import { isBrokerConnected, publishConfirmed } from "./broker.util";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
@@ -33,19 +34,34 @@ export class EmailClientService implements OnApplicationBootstrap {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
return { queued: false };
}
this.emailClient.emit("send-email", {
to: dto.to,
subject: dto.subject,
text: dto.text,
html: dto.html,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
const queued = await publishConfirmed(
this.emailClient,
"send-email",
{
to: dto.to,
subject: dto.subject,
text: dto.text,
html: dto.html,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
},
this.logger,
);
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
// delivery — the consumer and the SMTP hop are downstream and invisible here.
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
return { queued: true };
return { queued };
}
/**
* Connection state for the health endpoint. `null` means the broker client did
* not expose its manager — reported as "unknown" rather than assumed healthy.
*/
get brokerConnected(): boolean | null {
if (!this.enabled) return false;
return isBrokerConnected(this.emailClient);
}
}

View File

@@ -6,6 +6,7 @@ import {
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
import { isBrokerConnected, publishConfirmed } from "./broker.util";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
@@ -14,7 +15,7 @@ export class SmsClientService implements OnApplicationBootstrap {
constructor(
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
) { }
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
@@ -26,7 +27,7 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
this.logger.error("Error happened at SMS service", err);
});
}
@@ -35,34 +36,61 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
return { queued: false };
}
this.smsClient.emit("send-sms", {
to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
const queued = await publishConfirmed(
this.smsClient,
"send-sms",
{
to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
},
this.logger,
);
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
// delivery — the consumer, the SMS gateway and the carrier are all downstream
// of this and invisible from here.
this.logger.log(
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued: true };
return { queued };
}
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
this.logger.warn(
`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`,
);
return { queued: false };
}
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
this.smsClient.emit("ozeking-bulk-sms", {
messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
const messages = (dto.messages ?? []).map((m) => ({
to: m.to,
text: m.message,
from: m.from,
}));
const queued = await publishConfirmed(
this.smsClient,
"ozeking-bulk-sms",
{
messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
},
this.logger,
);
this.logger.log(
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
);
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued: true };
return { queued };
}
/**
* Connection state for the health endpoint. `null` means the broker client did
* not expose its manager — reported as "unknown" rather than assumed healthy.
*/
get brokerConnected(): boolean | null {
if (!this.enabled) return false;
return isBrokerConnected(this.smsClient);
}
}

View File

@@ -11,12 +11,17 @@ import {
import { OtpService, OtpTarget } from "./otp.service";
import { Public } from "@edr/api-common";
// Exactly one of phone/email must be present per request — the channel the
// code is sent through / checked against.
// At least one of phone/email must be present. When BOTH are given the code is
// sent to both and either one verifies it — the caller no longer picks a single
// channel, it just states every address it knows for the account.
function toTarget(phone?: string, email?: string): OtpTarget {
if (email) return { email };
if (phone) return { phone };
throw new BadRequestException("phone or email is required");
const target: OtpTarget = {};
if (email?.trim()) target.email = email;
if (phone?.trim()) target.phone = phone;
if (!target.email && !target.phone) {
throw new BadRequestException("phone or email is required");
}
return target;
}
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
@@ -41,7 +46,13 @@ export class OtpController {
@Body("email")
email?: string
) {
return this.otpService.sendOtp(toTarget(phone, email));
// `delivered` stays server-side: this route is @Public(), and whether our
// broker accepted the publish is infrastructure state an anonymous caller has
// no need for. It is on the `otp.dispatch` log line instead.
const { success, message } = await this.otpService.sendOtp(
toTarget(phone, email)
);
return { success, message };
}
// ---------------------------------------------------------------------------

View File

@@ -4,10 +4,12 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { FindOptionsWhere, Repository } from "typeorm";
import { OtpVerification } from "./otp.entity";
type Target = { phone?: string; email?: string };
@Injectable()
export class OtpRepository {
constructor(
@@ -46,54 +48,112 @@ export class OtpRepository {
}
// ---------------------------------------------------------------------------
// Find By Target (either channel)
// Find By Target (any named channel)
// ---------------------------------------------------------------------------
async findByTarget(
target: { phone?: string; email?: string }
) {
return target.email
? this.findByEmail(target.email)
: this.findByPhone(target.phone!);
}
/**
* OR across every channel the target names. A code sent to both phone and
* email lives in ONE row carrying both values, so a verify that quotes either
* one resolves the same row — that is what makes "sent to both, verify with
* either" work.
*/
private whereForTarget(
target: Target
): FindOptionsWhere<OtpVerification>[] {
const where: FindOptionsWhere<OtpVerification>[] =
[];
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
target: { phone?: string; email?: string },
otp: string
) {
const entity =
this.repository.create({
phone: target.phone,
if (target.email)
where.push({
email: target.email,
otp,
verified: false,
});
return this.repository.save(
entity
if (target.phone)
where.push({
phone: target.phone,
});
return where;
}
async findAllByTarget(
target: Target
) {
const where =
this.whereForTarget(target);
if (!where.length) return [];
// Newest first: a target that somehow overlaps two legacy single-channel
// rows should resolve to the most recently issued code, not an arbitrary one.
return this.repository.find({
where,
order: { updatedAt: "DESC" },
});
}
async findByTarget(
target: Target
) {
const [
newest,
] = await this.findAllByTarget(
target
);
return newest ?? null;
}
// ---------------------------------------------------------------------------
// Update OTP
// Replace OTP (upsert across every channel the target names)
// ---------------------------------------------------------------------------
async updateOtp(
otpVerification: OtpVerification,
/**
* Drop every row this target overlaps and write a single fresh one holding
* all its channels.
*
* `phone` and `email` are each UNIQUE, so a dual-channel send can collide with
* up to two pre-existing single-channel rows (say an old signup code on the
* phone and a reset code on the email). Merging into one row instead of
* updating in place is what keeps that from raising a unique violation, and it
* preserves the single-use guarantee: consuming the code deletes one row and
* kills every channel it was sent to at once.
*
* "Last code sent wins" was already the behaviour between any two flows
* sharing this table — this only widens it from one channel to all of them.
*/
async replaceOtp(
target: Target,
otp: string
) {
otpVerification.otp = otp;
): Promise<{
record: OtpVerification;
rotated: boolean;
}> {
const existing =
await this.findAllByTarget(
target
);
otpVerification.verified =
false;
if (existing.length) {
await this.repository.remove(
existing
);
}
return this.repository.save(
otpVerification
);
const record =
await this.repository.save(
this.repository.create({
phone: target.phone,
email: target.email,
otp,
verified: false,
})
);
return {
record,
rotated: existing.length > 0,
};
}
// ---------------------------------------------------------------------------
@@ -115,8 +175,8 @@ export class OtpRepository {
// Delete OTP (single-use consume)
// ---------------------------------------------------------------------------
// Hard delete so the unique `phone` row is freed and a fresh code can be
// requested for the same number on the next action.
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
// be requested for the same target on the next action.
async deleteOtp(
otpVerification: OtpVerification
) {
@@ -124,4 +184,4 @@ export class OtpRepository {
otpVerification
);
}
}
}

View File

@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
});
it('passes email targets through untouched', () => {
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
it('canonicalises email case and surrounding whitespace to one key', () => {
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
});
it('keeps an already-normalised email stable (idempotent)', () => {
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
});
it('keeps an already-normalised number stable (idempotent)', () => {
@@ -21,41 +28,216 @@ describe('normalizeOtpTarget', () => {
});
});
describe('OtpService — send/verify agree across phone formats', () => {
// In-memory fake keyed by the exact phone string the service stores under, so
// the test proves normalisation makes send and verify collide on one key.
function makeService() {
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
const repo = {
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
rows.get(t.email ?? t.phone!) ?? null,
),
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
existing.otp = otp;
}),
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
}),
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
rows.delete(row.phone ?? row.email!);
}),
};
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
const service = new OtpService(repo as never, sms as never, email as never);
return { service, rows };
}
interface FakeRow {
id: string;
phone?: string;
email?: string;
otp: string;
updatedAt: Date;
}
/**
* In-memory stand-in for OtpRepository, mirroring the two properties the service
* depends on: rows are matched by OR across every channel named, and a send
* replaces all overlapping rows with one row carrying every channel.
*/
function makeService(
transports: {
sms?: () => Promise<{ queued: boolean }>;
email?: () => Promise<{ queued: boolean }>;
} = {},
) {
let rows: FakeRow[] = [];
let nextId = 1;
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
const repo = {
findByTarget: jest.fn(
async (t: { phone?: string; email?: string }) =>
rows.filter((row) => matches(row, t))[0] ?? null,
),
replaceOtp: jest.fn(
async (t: { phone?: string; email?: string }, otp: string) => {
const overlapping = rows.filter((row) => matches(row, t));
rows = rows.filter((row) => !overlapping.includes(row));
const record: FakeRow = {
id: String(nextId++),
...t,
otp,
updatedAt: new Date(),
};
rows.push(record);
return { record, rotated: overlapping.length > 0 };
},
),
deleteOtp: jest.fn(async (row: FakeRow) => {
rows = rows.filter((r) => r !== row);
}),
};
// Both clients return `{ queued }` — the service reads it to tell a published
// code apart from one the transport silently dropped.
const sms = {
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
};
const email = {
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
};
const service = new OtpService(repo as never, sms as never, email as never);
return { service, sms, email, rows: () => rows };
}
describe('OtpService — send/verify agree across phone formats', () => {
it('verifies a code sent to +251… when verify is called with 09…', async () => {
const { service, rows } = makeService();
await service.sendOtp({ phone: '+251986680099' });
const stored = [...rows.values()][0]!.otp;
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
[...rows.values()][0]!.updatedAt = new Date();
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, stored),
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
const { service, rows } = makeService();
await service.sendOtp({ email: ' User@Example.COM ' });
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
});
describe('OtpService — dual-channel send', () => {
const both = { phone: '0986680099', email: 'User@Example.COM' };
it('sends ONE code to both transports', async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).toHaveBeenCalledTimes(1);
// Same secret on both messages — the user types whichever arrives first.
expect(sms.sendSms).toHaveBeenCalledWith(
expect.objectContaining({
to: '+251986680099',
message: expect.stringContaining(otp),
}),
);
expect(email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
text: expect.stringContaining(otp),
}),
);
// One row, both channels canonicalised.
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({
phone: '+251986680099',
email: 'user@example.com',
});
});
it.each([
['phone alone', { phone: '0986680099' }],
['email alone', { email: 'user@example.com' }],
['both', both],
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
const { service, rows } = makeService();
await service.sendOtp(both);
await expect(
service.verifyOtpForAction(target, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('consuming the code via one channel kills the other', async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
// Single-use is per-code, not per-channel: the phone half must be dead too.
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, otp),
).rejects.toThrow(/No verification code was requested/);
});
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
const { service, rows } = makeService();
// A pending signup code on the phone only, then a dual-channel send.
await service.sendOtp({ phone: '0986680099' });
await service.sendOtp(both);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
});
it('degrades to one channel when the account has only one contact', async () => {
const { service, sms, email } = makeService();
await service.sendOtp({ phone: '0986680099' });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).not.toHaveBeenCalled();
});
it('still succeeds when one transport throws', async () => {
const { service, rows } = makeService({
sms: async () => {
throw new Error('broker down');
},
});
await expect(service.sendOtp(both)).resolves.toMatchObject({
success: true,
delivered: true,
});
// The code is live and verifiable on the channel that worked.
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('fails the request when every transport throws', async () => {
const { service } = makeService({
sms: async () => {
throw new Error('broker down');
},
email: async () => {
throw new Error('broker down');
},
});
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
});
it('shares one brute-force budget across both channels', async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
// Alternating channels must not hand the attacker two independent budgets:
// 5 wrong guesses in total burn the code regardless of how they are split.
for (const target of [
{ phone: '0986680099' },
{ email: 'user@example.com' },
{ phone: '0986680099' },
{ email: 'user@example.com' },
]) {
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
'Invalid verification code',
);
}
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
).rejects.toThrow(/Too many incorrect attempts/);
// Burned: even the correct code no longer works.
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
/No verification code was requested/,
);
});
});

View File

@@ -8,10 +8,24 @@ import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service";
import { EmailClientService } from "../notifications/email-client.service";
// Exactly one of phone/email is set — enforced by the controller before it
// reaches here.
/**
* Where a code goes. At least one of phone/email must be set — enforced by the
* controller and re-checked here. When BOTH are set the same code is sent to
* both and either one can be used to verify it: a user who never receives the
* SMS can still finish from their inbox, and vice versa. Callers that resolve
* contacts from IAM pass whatever the account actually has, so an account with
* only one of the two silently degrades to a single channel.
*/
export type OtpTarget = { phone?: string; email?: string };
/** Which transports a target resolves to, in a stable order for logging. */
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
const channels: Array<"email" | "sms"> = [];
if (target.email) channels.push("email");
if (target.phone) channels.push("sms");
return channels;
}
/**
* Canonicalise a phone to E.164 so the code stored on send and the one looked
* up on verify collide regardless of how the number was typed. Without this,
@@ -19,19 +33,51 @@ export type OtpTarget = { phone?: string; email?: string };
* a code sent to one is invisible to the others — the send/verify halves must
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
* Email targets pass through untouched.
*/
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
if (target.email || !target.phone) return target;
const raw = target.phone.trim();
function normalizePhone(rawPhone: string): string {
const raw = rawPhone.trim();
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return { phone: digits };
if (digits.startsWith('+')) return digits;
const bare = digits.replace(/^0+/, '');
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return { phone: digits.length >= 11 ? `+${digits}` : raw };
return digits.length >= 11 ? `+${digits}` : raw;
}
/**
* Canonicalise every channel present on the target. Each field is normalised
* independently — a dual-channel target must end up with both halves in their
* canonical form, since verify may arrive naming either one.
*/
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
const normalized: OtpTarget = {};
if (target.email?.trim()) {
// Same contract as the phone branch: the string stored on send and the one
// looked up on verify must be byte-identical, or the code is invisible to
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
// or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
// trailing space are three different keys for one mailbox. Domains are
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
// matching what users expect beats matching the letter of the spec here.
normalized.email = target.email.trim().toLowerCase();
}
if (target.phone?.trim()) {
normalized.phone = normalizePhone(target.phone);
}
return normalized;
}
/** One transport's hand-off outcome. Never thrown — collected and reported. */
interface DispatchOutcome {
channel: "email" | "sms";
queued: boolean;
error?: string;
}
@Injectable()
@@ -58,25 +104,36 @@ export class OtpService {
// ---------------------------------------------------------------------------
async sendOtp(rawTarget: OtpTarget) {
// Store under the canonical E.164 key so verify (which normalises the same
// way) always finds this row regardless of how either side typed the number.
// Store under the canonical keys so verify (which normalises the same way)
// always finds this row regardless of how either side typed the number.
const target = normalizeOtpTarget(rawTarget);
const channels = channelsOf(target);
const label = this.targetLabel(target);
const startedAt = Date.now();
if (channels.length === 0) {
throw new BadRequestException("phone or email is required");
}
try {
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS/email.
// recipient of the SMS/email. ONE code covers every channel: the user
// types whichever message reaches them first.
const otp = this.generateOtp();
// find existing row for this channel
const existing = await this.otpRepository.findByTarget(target);
// Replaces every row this target overlaps with, so a dual-channel send
// leaves exactly one row holding both halves — verify then resolves the
// same row whichever channel it is given.
const { rotated } = await this.otpRepository.replaceOtp(target, otp);
// update existing otp
if (existing) {
await this.otpRepository.updateOtp(existing, otp);
} else {
// create new otp
await this.otpRepository.createOtp(target, otp);
}
// `rotate` means a code already existed for this target and was replaced —
// the previous one is now dead. A user holding a slow-to-arrive SMS and
// typing its code will fail against the row; this line is how that shows up
// in the log rather than as an unexplained "invalid OTP" report.
this.logger.log(
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
);
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
// here let an attacker wipe the per-target guess budget just by calling
@@ -86,40 +143,180 @@ export class OtpService {
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
// in the codebase yet.
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
to: target.email,
subject: "Your EDR Freight verification code",
text: `Your verification code is ${otp}`,
});
} else {
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: target.phone as string,
message: `Your verification code is ${otp}`,
});
// Fan out to every channel the target has, independently: one transport
// being down must not suppress the other, which is the whole point of
// sending to both. Each helper swallows its own failure so a rejected
// email publish still leaves the SMS delivered (and the code valid).
const outcomes = (
await Promise.all([
target.email ? this.dispatchEmail(target.email, otp) : null,
target.phone ? this.dispatchSms(target.phone, otp) : null,
])
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
for (const outcome of outcomes) {
this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
outcome.queued
} latencyMs=${Date.now() - startedAt}${
outcome.error ? ` error=${outcome.error}` : ""
}`,
);
}
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
// Every channel threw. Nothing can arrive and there is no partial success
// to preserve — fail the request the way a single-channel send always did.
if (outcomes.every((outcome) => outcome.error)) {
throw new Error(
outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
);
}
// Both clients report hand-off, not delivery — capture it rather than
// discarding it, so "delivered=false" is distinguishable from a code that
// was published fine and lost downstream at the carrier.
const delivered = outcomes.some((outcome) => outcome.queued);
if (!delivered) {
// The row is committed and we are about to answer "OTP sent successfully",
// but nothing left this process. Without this line the only symptom is a
// user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked.
this.logger.error(
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — no transport reported hand-off; no code will arrive for this send`,
);
}
// SECURITY: this logs a live credential in cleartext. Anyone with read
// access to the log stream can complete a password reset or a contract
// signature for the address on the same line. Kept deliberately (log
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
// is ever revisited, gate on an env flag rather than deleting the line, so
// dev keeps its workflow.
this.logger.log(`OTP send for ${label}: ${otp}`);
return {
success: true,
// Distinguishes "we published it" from "the transport is a no-op". The
// HTTP response shape is unchanged; the controller drops this field.
delivered,
message: "OTP sent successfully",
};
} catch (error) {
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error(
`Failed to send OTP to ${target.email ?? target.phone}: ${
error instanceof Error ? error.message : String(error)
}`,
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
Date.now() - startedAt
}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw new BadRequestException("Failed to send OTP");
}
}
/**
* Publish to one transport, converting a throw into a reported outcome. A
* broker error on one channel must not abort the other — with dual-channel
* sends the user still has a working route to the code.
*/
private async dispatchEmail(
email: string,
otp: string,
): Promise<DispatchOutcome> {
try {
const { queued } = await this.emailClient.sendEmail({
to: email,
subject: "Your EDR Freight verification code",
text: `Your verification code is ${otp}`,
});
return { channel: "email", queued };
} catch (error) {
return {
channel: "email",
queued: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
private async dispatchSms(
phone: string,
otp: string,
): Promise<DispatchOutcome> {
try {
const { queued } = await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${otp}`,
});
return { channel: "sms", queued };
} catch (error) {
return {
channel: "sms",
queued: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Correlation key shared by every `otp.*` line for one target, so a send and
* its later verify can be joined with a single grep. The raw values are used
* because the code itself is already logged in cleartext above — hashing the
* address while printing the credential next to it would buy nothing.
*/
private targetLabel(target: OtpTarget): string {
return (
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
);
}
/**
* One line per verify exit path. `result` is a closed set — ok | invalid |
* expired | exhausted | not_found — so failures can be counted by reason
* instead of inferred from error strings that the frontend also depends on.
*/
private logVerify(
target: OtpTarget,
mode: "simple" | "action",
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
detail?: string,
) {
const line = `otp.verify channels=${channelsOf(target).join(
"+",
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
detail ? ` ${detail}` : ""
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);
}
/**
* "No code for this target" phrased for whichever channels were named. A
* dual-channel caller gets a neutral message — naming one channel would be
* misleading when the code went to both.
*/
private notFoundMessage(target: OtpTarget, requested: boolean): string {
const channels = channelsOf(target);
if (channels.length !== 1) {
return requested
? "No verification code was requested for this account"
: "No verification code found for this account";
}
if (target.email) {
return requested
? "No verification code was requested for this email"
: "Email address not found";
}
return requested
? "No verification code was requested for this phone"
: "Phone number not found";
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
@@ -128,23 +325,36 @@ export class OtpService {
// Same canonicalisation as sendOtp so a code stored under +2519… is found
// when verify is called with 09… (or any equivalent form).
const target = normalizeOtpTarget(rawTarget);
// find the channel's row
// Matches on ANY channel the caller named, so a code sent to both phone and
// email verifies whichever one the user quotes back.
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
// not found
if (!otpData) {
throw new BadRequestException(
target.email ? "Email address not found" : "Phone number not found",
);
// No row for this target. Most often a normalisation mismatch or a code
// that was already consumed/burned — not necessarily a caller who never
// asked.
this.logVerify(target, "simple", "not_found");
throw new BadRequestException(this.notFoundMessage(target, false));
}
// Key the attempt budget on the ROW, not on the channels the caller happened
// to name — otherwise guessing alternately by phone and by email would hand
// an attacker two independent budgets against the same code.
const key = otpData.id;
// TTL: reuse the same age window as the hardened action verifier — an old
// code can't be verified.
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(
target,
"simple",
"expired",
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
);
@@ -157,24 +367,36 @@ export class OtpService {
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(
target,
"simple",
"exhausted",
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
this.logVerify(
target,
"simple",
"invalid",
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
);
throw new BadRequestException("Invalid OTP");
}
// single-use: consume the code on success so it can't be replayed.
// single-use: consume the code on success so it can't be replayed. One row
// covers every channel it was sent to, so this kills all of them at once.
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
return {
success: true,
message: target.email
? "Email verified successfully"
: "Phone verified successfully",
message: "Verification successful",
};
}
@@ -196,10 +418,6 @@ export class OtpService {
private readonly MAX_ACTION_ATTEMPTS = 5;
private readonly actionAttempts = new Map<string, number>();
private targetKey(target: OtpTarget): string {
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
}
async verifyOtpForAction(
rawTarget: OtpTarget,
otp: string,
@@ -207,22 +425,21 @@ export class OtpService {
) {
const target = normalizeOtpTarget(rawTarget);
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
if (!otpData) {
throw new BadRequestException(
target.email
? "No verification code was requested for this email"
: "No verification code was requested for this phone",
);
this.logVerify(target, "action", "not_found");
throw new BadRequestException(this.notFoundMessage(target, true));
}
// Row-keyed for the same reason as verifyOtp: one code, one budget.
const key = otpData.id;
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > ttlMs) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
);
@@ -235,18 +452,31 @@ export class OtpService {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(
target,
"action",
"exhausted",
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
this.logVerify(
target,
"action",
"invalid",
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
);
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
return { success: true };
}

View File

@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
hasWarehouse!: boolean;
/**
* Containers need a reach stacker or gantry, so only the equipped facilities
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
* everywhere.
*/
@Column({ name: 'handles_container', type: 'boolean', default: true })
handlesContainer!: boolean;
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
handlesBulk!: boolean;
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null;

View File

@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
hasFacility: boolean;
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
hasWarehouse: boolean;
/** Containers need a reach stacker/gantry — not every facility has one. */
handlesContainer: boolean;
handlesBulk: boolean;
}
/**
* Which yards can handle cargo, and how.
* Which yards can handle cargo, and what kind.
*
* A yard is a load/unload point when `yards.has_facility` is set; the matching
* `yard_facilities` record says whether it also stores cargo. Facilities without a
* warehouse move cargo on and off the train and nothing more — no storage, no
* demurrage. This is the single resolver the journey and handling flows use, so
* they can't drift on what a facility is.
* `yard_facilities` record says what it can actually do — whether it stores cargo
* (storage/demurrage), and which freight types its equipment can lift. Containers
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
* bulk is handled at all five.
*
* This is the single resolver the journey and handling flows use, so they can't
* drift on what a facility is or what it can lift.
*/
@Injectable()
export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row]: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
if (!row) return null;
private readonly SELECT = `
SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse",
f.handles_container AS "handlesContainer",
f.handles_bulk AS "handlesBulk"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
private toInfo(row: {
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
handlesContainer: boolean | null;
handlesBulk: boolean | null;
}): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
return {
yardId: row.yardId,
yardCode: row.yardCode,
yardLabel: row.yardLabel,
hasFacility: Boolean(row.hasFacility),
// No facility record means no warehouse, whatever the flag says.
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
hasFacility,
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
handlesContainer: hasFacility && Boolean(row.handlesContainer),
handlesBulk: hasFacility && Boolean(row.handlesBulk),
};
}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row] = await this.dataSource.query(
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
return row ? this.toInfo(row) : null;
}
/** Every yard that can load/unload, for pickers and the intercity queues. */
async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.deleted_at IS NULL
AND y.is_active = true
AND y.has_facility = true
const rows = await this.dataSource.query(
`${this.SELECT}
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
ORDER BY y.display_order ASC, y.label ASC`,
);
return rows.map((r) => ({
yardId: r.yardId,
yardCode: r.yardCode,
yardLabel: r.yardLabel,
hasFacility: true,
hasWarehouse: Boolean(r.hasWarehouse),
}));
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
}
/**
* Can this facility lift this cargo? Keeps the freight-type rule in one place
* so callers can't get it subtly wrong.
*/
canHandleFreight(
facility: YardFacilityInfo | null,
freightType: string | null | undefined,
): boolean {
if (!facility?.hasFacility) return false;
return String(freightType).toUpperCase() === 'CONTAINER'
? facility.handlesContainer
: facility.handlesBulk;
}
}

View File

@@ -0,0 +1,25 @@
import {
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
} from "@edr/types";
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
/** Multipart field name carrying chat files. */
export const SUPPORT_ATTACHMENT_FIELD = "attachments";
/**
* Multer-level caps for the chat send routes.
*
* These duplicate the checks in `SupportChatService.assertSendable` on purpose,
* and are not a substitute for them: Multer stops reading the socket once a part
* exceeds `fileSize`, so an oversized upload is cut off mid-stream instead of
* being buffered into memory and rejected after the fact. The service-level
* check is what produces the readable error message and covers callers that
* don't come through this interceptor.
*/
export const supportAttachmentMulterOptions: MulterOptions = {
limits: {
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
},
};

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
/** Default page size for a thread — roughly two screens of bubbles. */
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
export class ListMessagesQueryDto {
@ApiPropertyOptional({
description:
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
"of messages immediately OLDER than the cursor. Omit for the newest page.",
})
@IsOptional()
@IsString()
before?: string;
@ApiPropertyOptional({
minimum: 1,
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
limit?: number;
}

View File

@@ -1,11 +1,15 @@
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, MaxLength } from "class-validator";
export class SendMessageDto implements ISendSupportMessageDto {
@ApiProperty({ description: "Message text." })
@ApiPropertyOptional({
description:
"Message text. Optional only when the request carries attachments — the " +
"service rejects a message that is neither text nor files.",
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(4000)
body!: string;
body?: string;
}

View File

@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
authorName?: string | null;
@Column({ name: "body", type: "text" })
body!: string;
/**
* NULL for an attachment-only message. Nullable rather than "" so the absence
* of text is representable instead of guessed at; the DTO maps NULL → "".
*/
@Column({ name: "body", type: "text", nullable: true })
body?: string | null;
}

View File

@@ -0,0 +1,54 @@
import { BadRequestException } from "@nestjs/common";
/**
* Keyset cursor for paging a thread backwards from newest.
*
* The cursor is just a message id. The sort key is the pair `(created_at, id)` —
* two messages can share a timestamp, and a cursor on a non-unique key either
* re-serves or skips the tied rows — but the *timestamp half is never sent over
* the wire*, because it cannot survive the trip.
*
* `support_messages.created_at` is `timestamptz(6)`; a JS `Date` holds only
* milliseconds, so the value TypeORM hands back is already truncated. Encoding
* that into the cursor and comparing against it would silently skip every row
* sharing the cursor's millisecond but earlier within it (`.254100` is not
* `< .254000`) — those rows would never appear on any page. Sending the id alone
* and letting Postgres look the real `(created_at, id)` up keeps the comparison
* at full precision on the server, where it was never lossy.
*
* Opaque on purpose (base64): clients must treat it as a token, so the sort key
* can change without a contract change.
*
* The passenger API's twin encodes a timestamp because its column is
* `TIMESTAMP(3)` — millisecond, matching JS exactly — so it has no such loss.
* The two formats are deliberately NOT interchangeable; each app reads only its
* own cursors.
*/
export function encodeMessageCursor(id: string): string {
return Buffer.from(id, "utf8").toString("base64url");
}
/**
* Parse a client-supplied cursor. Rejects anything malformed rather than
* silently falling back to "first page" — a corrupted cursor that degrades to
* page 1 makes an infinite scroll loop forever over the same rows.
*/
export function decodeMessageCursor(raw: string): string {
let id: string;
try {
id = Buffer.from(raw, "base64url").toString("utf8");
} catch {
throw new BadRequestException("Malformed pagination cursor.");
}
// The id goes into a parameterized query, but validate the shape anyway: a
// non-uuid can only be a mangled cursor, and failing loudly here beats an
// empty page that reads as "start of conversation".
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
) {
throw new BadRequestException("Malformed pagination cursor.");
}
return id;
}

View File

@@ -0,0 +1,100 @@
import { CurrentUser } from "@edr/api-common";
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { Response } from "express";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FilesService } from "../files/files.service";
import { SupportChatService } from "./support-chat.service";
/**
* Authenticated download for chat attachments.
*
* This exists instead of reusing `GET /files/:fileId` because that route streams
* any file to any authenticated caller who knows its UUID — fine-ish for a
* booking document the caller already had a link to, not fine for chat, where
* one customer guessing another's file id would be a cross-tenant leak. That
* route now refuses `support_message` files outright and points here.
*
* Inline previews still use the short-lived signed URL on the message DTO — a
* browser `<img>` can't send a Bearer token. This route is for explicit
* downloads and for clients that would rather stream through the API.
*/
@ApiTags("support-chat")
@ApiBearerAuth()
@Controller("support/attachments")
export class SupportAttachmentController {
constructor(
private readonly files: FilesService,
private readonly chat: SupportChatService,
) {}
@Get(":fileId")
@ApiOperation({
summary: "Download a support chat attachment",
description:
"Streams the file only if the caller is backoffice staff or belongs to the " +
"company that owns the thread the attachment was posted in.",
})
@ApiQuery({
name: "download",
required: false,
description: "Set to 1/true to force a download instead of inline preview.",
})
async download(
@CurrentUser() user: AuthUserPayload,
@Param("fileId", ParseUUIDPipe) fileId: string,
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const record = await this.files.findById(fileId);
// Don't let this route become a second general-purpose file endpoint: it can
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
// no reason to confirm the id exists).
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
throw new NotFoundException(`File ${fileId} not found`);
}
const allowed = await this.chat.canUserAccessMessage(
record.resourceId,
resolveAuthUserId(user),
);
if (!allowed) {
throw new ForbiddenException(
"This attachment belongs to another company's conversation.",
);
}
const { stream } = await this.files.streamById(fileId);
const forceDownload = download === "1" || download === "true";
res.setHeader("Content-Type", record.mimeType);
res.setHeader(
"Content-Disposition",
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
);
// Private only — this response is scoped to one caller's authorization, so a
// shared cache must never reuse it for the next person asking.
res.setHeader("Cache-Control", "private, max-age=300");
stream.pipe(res);
}
}

View File

@@ -1,5 +1,8 @@
import { CurrentUser } from "@edr/api-common";
import { SupportAuthorRole } from "@edr/types";
import {
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SupportAuthorRole,
} from "@edr/types";
import {
Body,
Controller,
@@ -8,14 +11,22 @@ import {
ParseUUIDPipe,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { StartConversationDto } from "./dto/start-conversation.dto";
import { SupportChatService } from "./support-chat.service";
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
}
@Get("conversations/:id/messages")
@ApiOperation({ summary: "List messages in a thread" })
messages(@Param("id", ParseUUIDPipe) id: string) {
return this.service.getMessages(id);
@ApiOperation({
summary: "List messages in a thread (newest page first)",
description:
"Keyset-paginated backwards from the newest message. Omit `before` for " +
"the newest page, then pass the previous response's `nextCursor` to walk " +
"back through history. `nextCursor: null` means the thread's start.",
})
messages(
@Param("id", ParseUUIDPipe) id: string,
@Query() query: ListMessagesQueryDto,
) {
return this.service.getMessages(id, query);
}
@Post("conversations/:id/messages")
@ApiOperation({ summary: "Reply as an agent" })
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
supportAttachmentMulterOptions,
),
)
// Accepts multipart (text + files) or plain JSON (text only) — Multer passes
// non-multipart requests straight through, so existing JSON clients are
// unaffected.
@ApiConsumes("multipart/form-data", "application/json")
@ApiBody({
schema: {
type: "object",
properties: {
body: { type: "string" },
attachments: {
type: "array",
items: { type: "string", format: "binary" },
},
},
},
})
@ApiOperation({ summary: "Reply as an agent, optionally with attachments" })
send(
@CurrentUser() user: AuthUserPayload,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
return this.service.sendAsAgent(
id,
resolveAuthUserId(user),
body.body,
attachments ?? [],
);
}
@Post("conversations/:id/read")

View File

@@ -1,12 +1,29 @@
import { CurrentUser } from "@edr/api-common";
import { SupportAuthorRole } from "@edr/types";
import { Body, Controller, Get, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SupportAuthorRole,
} from "@edr/types";
import {
Body,
Controller,
Get,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { SupportChatService } from "./support-chat.service";
@@ -29,17 +46,55 @@ export class SupportChatController {
}
@Get("conversation/messages")
@ApiOperation({ summary: "Messages in my company's support thread" })
messages(@CurrentUser() user: AuthUserPayload) {
return this.service.getCustomerMessages(resolveAuthUserId(user));
@ApiOperation({
summary: "Messages in my company's support thread (newest page first)",
description:
"Keyset-paginated backwards from the newest message. Omit `before` for " +
"the newest page, then pass the previous response's `nextCursor` to walk " +
"back through history. `nextCursor: null` means the thread's start.",
})
messages(
@CurrentUser() user: AuthUserPayload,
@Query() query: ListMessagesQueryDto,
) {
return this.service.getCustomerMessages(resolveAuthUserId(user), query);
}
@Post("conversation/messages")
@ApiOperation({
summary: "Send a message as the customer, opening the thread if needed",
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
supportAttachmentMulterOptions,
),
)
@ApiConsumes("multipart/form-data", "application/json")
@ApiBody({
schema: {
type: "object",
properties: {
body: { type: "string" },
attachments: {
type: "array",
items: { type: "string", format: "binary" },
},
},
},
})
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
@ApiOperation({
summary:
"Send a message as the customer (optionally with attachments), opening the thread if needed",
})
send(
@CurrentUser() user: AuthUserPayload,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendAsCustomer(
resolveAuthUserId(user),
body.body,
attachments ?? [],
);
}
@Post("conversation/read")

View File

@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { SupportAttachmentController } from "./support-attachment.controller";
import { SupportChatAgentController } from "./support-chat-agent.controller";
import { SupportChatController } from "./support-chat.controller";
import { SupportChatGateway } from "./support-chat.gateway";
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
BackofficeModule,
// WsAuthService — reused handshake authentication for the gateway.
NotificationInboxModule,
// FilesService — chat attachments are stored as polymorphic file records.
FilesModule,
],
controllers: [
SupportChatController,
SupportChatAgentController,
SupportAttachmentController,
],
controllers: [SupportChatController, SupportChatAgentController],
providers: [
SupportConversationRepository,
SupportMessageRepository,
SupportChatGateway,
SupportChatService,
],
// FilesController's ownership check for `support_message` files defers to this
// service — see SupportAttachmentAccess.
exports: [SupportChatService],
})
export class SupportChatModule {}

View File

@@ -1,22 +1,37 @@
import {
isSupportAttachmentAllowed,
SendSupportMessageResult,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SUPPORT_ATTACHMENT_RESOURCE,
SupportAttachmentDto,
SupportAuthorRole,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { QueryFailedError } from "typeorm";
import { BackofficeService } from "../backoffice/backoffice.service";
import { CompaniesService } from "../companies/companies.service";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
import { FileRecord } from "../files/entities/file.entity";
import { FilesService } from "../files/files.service";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import {
ListMessagesQueryDto,
SUPPORT_MESSAGES_DEFAULT_LIMIT,
} from "./dto/list-messages-query.dto";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
import { SupportChatGateway } from "./support-chat.gateway";
import { SupportConversationRepository } from "./support-conversation.repository";
import { SupportMessageRepository } from "./support-message.repository";
@@ -30,6 +45,9 @@ interface CustomerContext {
/** Postgres unique_violation — the one-thread-per-company index fired. */
const PG_UNIQUE_VIOLATION = "23505";
/** Stand-in preview for a message that is nothing but files. */
const ATTACHMENT_ONLY_PREVIEW = "📎";
@Injectable()
export class SupportChatService {
constructor(
@@ -38,6 +56,8 @@ export class SupportChatService {
private readonly gateway: SupportChatGateway,
private readonly externalProfiles: ExternalProfileRepository,
private readonly companies: CompaniesService,
private readonly files: FilesService,
private readonly backoffice: BackofficeService,
) {}
// ---- customer (portal) -------------------------------------------------
@@ -51,7 +71,9 @@ export class SupportChatService {
userId: string,
): Promise<SupportConversationDto | null> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return null;
const unread = await this.messages.unreadCountsByConversation(
[conversation.id],
@@ -63,17 +85,23 @@ export class SupportChatService {
);
}
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
async getCustomerMessages(
userId: string,
query: ListMessagesQueryDto = {},
): Promise<SupportMessageListResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
if (!conversation) return [];
return this.listMessages(conversation.id);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return { items: [], nextCursor: null };
return this.listMessages(conversation.id, query);
}
/** Send as the customer, opening the thread if this is the first message. */
async sendAsCustomer(
userId: string,
body: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SendSupportMessageResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.getOrCreate(
@@ -87,16 +115,19 @@ export class SupportChatService {
SupportAuthorRole.CUSTOMER,
body,
ctx.authorName,
attachments,
);
return {
conversation: this.toConversationDto(updated, 0),
message: this.toMessageDto(message),
message: message,
};
}
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (conversation) {
await this.conversations.update(conversation.id, {
customerLastReadAt: new Date(),
@@ -145,7 +176,8 @@ export class SupportChatService {
async sendAsAgent(
conversationId: string,
userId: string,
body: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
const { message } = await this.appendMessage(
@@ -153,8 +185,10 @@ export class SupportChatService {
userId,
SupportAuthorRole.AGENT,
body,
undefined,
attachments,
);
return this.toMessageDto(message);
return message;
}
async markAgentRead(
@@ -171,18 +205,56 @@ export class SupportChatService {
// ---- shared ------------------------------------------------------------
/**
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
* company owns it (portal route); omit for agents, who see every thread.
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
* to enforce that the caller's company owns it (portal route); omit for
* agents, who see every thread.
*/
async getMessages(
conversationId: string,
query: ListMessagesQueryDto = {},
asCustomerUserId?: string,
): Promise<SupportMessageDto[]> {
): Promise<SupportMessageListResult> {
const conversation = await this.requireConversation(conversationId);
if (asCustomerUserId) {
await this.assertCustomerOwns(conversation, asCustomerUserId);
}
return this.listMessages(conversationId);
return this.listMessages(conversationId, query);
}
/**
* May `userId` read the message that a chat attachment hangs off? Backstop for
* the file-download route, which otherwise streams any file to any
* authenticated caller who knows its UUID.
*
* Backoffice staff see every thread (they work a shared inbox); a portal user
* sees only their own company's. Fails **closed** — an unresolvable message,
* conversation, or staff list denies rather than falls through, since the
* caller uses this to decide whether to hand over raw bytes.
*/
async canUserAccessMessage(
messageId: string,
userId: string,
): Promise<boolean> {
const message = await this.messages.findById(messageId);
if (!message) return false;
const conversation = await this.conversations.findById(
message.conversationId,
);
if (!conversation) return false;
try {
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
if (staffIds.includes(userId)) return true;
} catch {
// Staff lookup is best-effort for room-joining in the gateway, but here it
// gates bytes: on failure fall through to the (stricter) company check
// rather than assuming staff.
}
const profile = await this.externalProfiles.findByUserId(userId);
return Boolean(
profile?.companyId && profile.companyId === conversation.companyId,
);
}
async unreadCount(
@@ -237,29 +309,86 @@ export class SupportChatService {
private async listMessages(
conversationId: string,
): Promise<SupportMessageDto[]> {
const rows = await this.messages.listByConversation(conversationId);
return rows.map((m) => this.toMessageDto(m));
query: ListMessagesQueryDto,
): Promise<SupportMessageListResult> {
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
const before = query.before ? decodeMessageCursor(query.before) : undefined;
// The repo returns newest-first and over-fetches by one to probe for a
// further page.
const rows = await this.messages.listByConversation(
conversationId,
limit,
before,
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const oldest = page[page.length - 1];
const nextCursor =
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
// Flip to oldest-first so the client can prepend a page as one block.
const items = await this.toMessageDtos([...page].reverse());
return { items, nextCursor };
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
/**
* Persist a message (plus any attachments), bump the conversation's
* denormalized fields, emit live.
*
* Files are validated *before* the row is written: a rejected upload should
* leave no message behind, and a half-uploaded batch is worse than none.
*/
private async appendMessage(
conversation: SupportConversation,
userId: string,
role: SupportAuthorRole,
body: string,
body: string | undefined,
authorName?: string | null,
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
attachments: Express.Multer.File[] = [],
): Promise<{
conversation: SupportConversation;
message: SupportMessageDto;
}> {
const text = (body ?? "").trim();
this.assertSendable(text, attachments);
const message = await this.messages.create({
conversationId: conversation.id,
authorUserId: userId,
authorRole: role,
authorName: authorName ?? null,
body,
// NULL, not "", so "this message has no text" is representable rather than
// inferred. The DTO flattens it back to "" for rendering.
body: text || null,
});
// The row has to exist before the files, since each one is stored against
// `resourceId = message.id`. That leaves a window: if a upload fails here,
// the message is already committed. Undo it rather than leave the thread
// with a permanently blank bubble — there is no delete flow, so an orphan
// would be unremovable, and an attachment-only message that lost its files
// has no content at all.
let stored: FileRecord[];
try {
stored = await Promise.all(
attachments.map((file) =>
this.files.upload({
resourceId: message.id,
resource: SUPPORT_ATTACHMENT_RESOURCE,
code: "attachment",
file,
}),
),
);
} catch (error) {
await this.messages.softDelete(message.id);
throw error;
}
conversation.lastMessageAt = message.createdAt;
conversation.lastMessagePreview = body.slice(0, 280);
conversation.lastMessagePreview = this.buildPreview(text, stored);
conversation.lastMessageAuthorRole = role;
await this.conversations.update(conversation.id, {
lastMessageAt: conversation.lastMessageAt,
@@ -267,13 +396,55 @@ export class SupportChatService {
lastMessageAuthorRole: role,
});
const messageDto = await this.toMessageDto(message, stored);
const dto = this.toConversationDto(conversation, 0);
this.gateway.emitMessage(
conversation.companyId,
dto,
this.toMessageDto(message),
);
return { conversation, message };
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
return { conversation, message: messageDto };
}
/**
* Guard the chat-specific upload rules. These are tighter than
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
* which exist for scanned business documents — chat files are pushed at
* another human, so the allowlist is narrower and SVG is excluded outright.
*/
private assertSendable(
text: string,
attachments: Express.Multer.File[],
): void {
if (!text && attachments.length === 0) {
throw new BadRequestException(
"A message needs text or at least one attachment.",
);
}
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
throw new BadRequestException(
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
}
for (const file of attachments) {
if (!isSupportAttachmentAllowed(file.mimetype)) {
throw new BadRequestException(
`Unsupported attachment type: ${file.mimetype}`,
);
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
throw new BadRequestException(
`"${file.originalname}" exceeds the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB attachment limit.`,
);
}
}
}
/** Inbox preview line — falls back to the filenames when there's no text. */
private buildPreview(text: string, attachments: FileRecord[]): string {
if (text) return text.slice(0, 280);
if (attachments.length === 1) {
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
}
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
}
private async buildListResult(
@@ -320,7 +491,9 @@ export class SupportChatService {
): Promise<CustomerContext> {
const ctx = await this.resolveCustomer(userId);
if (conversation.companyId !== ctx.companyId) {
throw new ForbiddenException("This conversation belongs to another company.");
throw new ForbiddenException(
"This conversation belongs to another company.",
);
}
return ctx;
}
@@ -353,15 +526,58 @@ export class SupportChatService {
};
}
private toMessageDto(m: SupportMessage): SupportMessageDto {
/** Hydrate + map a page of messages, batching the attachment lookup. */
private async toMessageDtos(
rows: SupportMessage[],
): Promise<SupportMessageDto[]> {
if (rows.length === 0) return [];
const grouped = await this.files.findByResourceIdsGrouped(
rows.map((r) => r.id),
SUPPORT_ATTACHMENT_RESOURCE,
);
return Promise.all(
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
);
}
private async toMessageDto(
m: SupportMessage,
attachments: FileRecord[],
): Promise<SupportMessageDto> {
return {
id: m.id,
conversationId: m.conversationId,
authorUserId: m.authorUserId,
authorRole: m.authorRole,
authorName: m.authorName ?? null,
body: m.body,
body: m.body ?? "",
attachments: attachments.map((a) => this.toAttachmentDto(a)),
createdAt: new Date(m.createdAt).toISOString(),
};
}
/**
* Where the browser fetches the bytes: the API's own ownership-checked stream
* route, NOT a presigned MinIO URL.
*
* Presigned object URLs are not reachable from the browser in this deployment
* — the same reason every other file in the app streams through
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
* attachments stream through `GET /api/support/attachments/:id`, which runs the
* same-company / staff ownership check before serving a byte.
*
* A root-relative path; the web app prepends its API origin. The `<img>` sends
* the `auth-token` cookie automatically (same-site across dev ports), which is
* how the guard authenticates a request that can't carry a bearer header.
*/
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
return {
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
url: `/api/support/attachments/${f.id}`,
};
}
}

View File

@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<SupportMessage> {
super(repo);
}
/** All messages of a conversation, oldest first. */
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
return this.repository.find({
where: { conversationId },
order: { createdAt: "ASC" },
});
/**
* One page of a thread, walking backwards from newest.
*
* Returns **newest-first** and takes one row more than asked, so the caller
* can tell "there is another page" from "this page happened to be full"
* without a second COUNT. The caller trims the probe row and flips the page
* to oldest-first for rendering.
*
* Rides IDX_SUPPORT_MSG_CONV_CREATED (conversation_id, created_at); the id in
* the keyset is a tiebreak only and doesn't need its own index.
*/
async listByConversation(
conversationId: string,
limit: number,
beforeId?: string,
): Promise<SupportMessage[]> {
const qb = this.repository
.createQueryBuilder("m")
.where("m.conversation_id = :conversationId", { conversationId })
// createQueryBuilder bypasses TypeORM's soft-delete filter, unlike find().
.andWhere("m.deleted_at IS NULL");
if (beforeId) {
// Row-value comparison: strictly older than the cursor row in
// (created_at, id) order.
//
// The cursor's timestamp is read back from the row itself rather than
// passed in. `created_at` is timestamptz(6) but a JS Date only holds
// milliseconds, so a timestamp that made the round-trip through the API
// would arrive truncated — and `.254100 < .254000` is false, so every row
// sharing the cursor's millisecond but earlier within it would be skipped
// on every page, permanently. Postgres compares the stored values at full
// precision instead.
qb.andWhere(
`(m.created_at, m.id) < (
SELECT c.created_at, c.id
FROM freight.support_messages c
WHERE c.id = :cursorId
)`,
{ cursorId: beforeId },
);
}
return qb
.orderBy("m.created_at", "DESC")
.addOrderBy("m.id", "DESC")
.take(limit + 1)
.getMany();
}
/**

View File

@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -68,6 +69,9 @@ export class BookingJourneyService {
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -352,10 +356,19 @@ export class BookingJourneyService {
): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId);
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
if (!facility?.hasFacility) {
throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
);
}
// A facility only handles what its equipment can lift: containers need a
// reach stacker/gantry, bulk does not.
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
throw new BadRequestException(
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
`an intercity booking cannot be ${where} here.`,
);
}
}

View File

@@ -0,0 +1,37 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { RecordCheckpointDto } from './record-checkpoint.dto';
const validateBody = (body: Record<string, unknown>) =>
validate(plainToInstance(RecordCheckpointDto, body));
describe('RecordCheckpointDto', () => {
// The final checkpoint arrives the schedule, so a backdated one rewrites the
// journey after the fact. No UI sends occurredAt; the endpoint still accepts it.
it('rejects a backdated occurredAt', async () => {
const errors = await validateBody({
sequenceNo: 3,
occurredAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
});
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('occurredAt');
expect(errors[0].constraints).toHaveProperty('IsNotBackdated');
});
it('accepts occurredAt of now', async () => {
const errors = await validateBody({
sequenceNo: 3,
occurredAt: new Date().toISOString(),
});
expect(errors).toHaveLength(0);
});
it('accepts a body that omits occurredAt, leaving the service to stamp it', async () => {
const errors = await validateBody({ sequenceNo: 0 });
expect(errors).toHaveLength(0);
});
});

View File

@@ -10,6 +10,8 @@ import {
Min,
} from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
@IsEnum(TrainCheckpointKind)
kind?: TrainCheckpointKind;
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
/**
* A checkpoint records where the train is as staff observe it, and the final
* one arrives the schedule — so a backdated value rewrites the journey after
* the fact. Only "now" is accepted; omit the field and the service stamps it.
*/
@ApiProperty({
required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
})
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
@ApiProperty({ required: false })

View File

@@ -67,10 +67,18 @@ export class IntercityService {
ts.status AS "scheduleStatus",
oy.id AS "originYardId",
COALESCE(oy.label, oy.code) AS "origin",
oy.has_facility AS "originHasFacility",
-- Can that end actually handle THIS booking's cargo? A container
-- booking needs a facility with a stacker; bulk needs any facility.
(oy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
AS "originHasFacility",
dy.id AS "destinationYardId",
COALESCE(dy.label, dy.code) AS "destination",
dy.has_facility AS "destinationHasFacility",
(dy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
AS "destinationHasFacility",
-- Where the train actually is, so the operator knows if the cargo
-- can be worked right now.
cp.yard_id AS "trainAtYardId",
@@ -80,6 +88,10 @@ export class IntercityService {
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.yard_facilities ofac
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
LEFT JOIN freight.yard_facilities dfac
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
LEFT JOIN freight.train_schedules ts
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
LEFT JOIN LATERAL (

View File

@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
expect(result).toHaveLength(2);
});
});
describe('marshalling documents', () => {
// Staff check these against the physical consist, so every wagon on the
// train set has to appear — an empty wagon that renders no row reads as a
// wagon that is not on the train.
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
sequenceNo,
wagonNumber,
physicalWagon: { wagonNumber },
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
lengthMeters: 14,
capacityTons: 70,
allocations,
});
const loadedAllocation = {
bookingId: 'booking-1',
bookingReference: 'BK-2026-000001',
loadType: 'CONTAINER',
allocatedWeightTons: 24.5,
containerNumbers: ['CONT-001'],
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
};
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
it('lists an empty wagon on the export document and marks it EMPTY', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [
makeWagon(1, 'W-001', [loadedAllocation]),
makeWagon(2, 'W-002', []),
makeWagon(3, 'W-003', []),
],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(countRows(html)).toBe(3);
expect(html).toContain('W-002');
expect(html).toContain('W-003');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
// The wagon count must agree with the rows the reader can see.
expect(html).toContain('3 (2 empty)');
});
it('lists an empty wagon on the import document and marks it EMPTY', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '8002',
route: 'Djibouti → Indode',
origin: 'Djibouti Port',
destination: 'Indode',
totalBookings: 1,
wagons: [
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(countRows(html)).toBe(2);
expect(html).toContain('W-002');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('2 (1 empty)');
});
it('renders wagons in consist order regardless of the order the relation returns', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
});
it('omits the empty-count suffix when every wagon is loaded', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html).not.toContain('empty)');
expect(html).not.toContain('EMPTY');
});
});
});

View File

@@ -2632,6 +2632,11 @@ export class TrainSchedulingService {
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
await this.assertExportBookingsReceived([...wagonAssignedIds]);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
@@ -2686,19 +2691,24 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0,
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
// Every wagon on the train set, loaded or not, in consist order. An empty
// wagon has an empty `allocations` array — it is still part of the train
// and still belongs on the marshalling document.
wagons: [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
})),
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
};
}
@@ -2748,9 +2758,33 @@ export class TrainSchedulingService {
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
const rows = (schedule.trainSet?.wagons ?? [])
.flatMap((wagon) =>
(wagon.allocations ?? []).map((allocation) => {
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
const rows = wagons
.flatMap((wagon) => {
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
// reads as a wagon that is not there, and the count stops matching.
if (allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="6">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
@@ -2760,12 +2794,7 @@ export class TrainSchedulingService {
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
${wagonCells}
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
@@ -2773,10 +2802,11 @@ export class TrainSchedulingService {
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
</tr>`;
}),
)
});
})
.join('');
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
@@ -2804,6 +2834,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
@@ -2831,7 +2863,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
@@ -2855,7 +2887,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
</tbody>
</table>
@@ -2882,6 +2914,38 @@ export class TrainSchedulingService {
return direction === 'EXPORT';
}
/**
* Every export booking being confirmed loaded must already be received at the
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
* is the check that the cargo is physically in the yard before we call it loaded.
*/
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
if (!bookingIds.length) return;
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT b.reference
FROM freight.bookings b
WHERE b.id = ANY($1)
AND b.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id
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
)`,
[bookingIds],
);
if (rows.length) {
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
throw new BadRequestException(
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
);
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -2898,19 +2962,31 @@ export class TrainSchedulingService {
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
const allocationRows = loadList.wagons
.flatMap((wagon) =>
wagon.allocations.map(
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
),
)
);
})
.join('');
return `<!doctype html>
@@ -2942,6 +3018,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
@@ -2968,7 +3046,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
@@ -2996,7 +3074,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
</tbody>
</table>

View File

@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Export cargo is received into the warehouse to wait for its train, and only a
* paid booking may be received — otherwise storage and a GRN would start against
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
* train and its receive is the unload.
*
* The guard touches only the DataSource, so the instance is built off the
* prototype rather than stubbing all 20-odd collaborators.
*/
type Guard = (
bookingId: string | null | undefined,
direction: string | null,
) => Promise<void>;
function makeGuard(paymentStatus: string | null) {
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.dataSource = { query };
const guard = (
service as unknown as { assertExportBookingPaid: Guard }
).assertExportBookingPaid.bind(service);
return { guard, query };
}
describe('receive() — export paid gate', () => {
it('rejects an unpaid export booking', async () => {
const { guard } = makeGuard('PENDING');
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
});
it('allows a paid export booking', async () => {
const { guard } = makeGuard('PAID');
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
});
it('never blocks import, paid or not', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
it('ignores a receive with no booking attached', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,86 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
import type { UnloadBookingDto } from './dto/unload-booking.dto';
/**
* A GRN is the receipt for cargo entering the warehouse, so unloadBooking must
* issue one for every direction — import as well as export. It used to mint only
* for export, leaving import cargo received with no GRN.
*/
function makeService(opts: {
tradeDirection: string | null;
existing?: { id: string; grnNumber: string | null };
}) {
const created: Record<string, unknown>[] = [];
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
const inventoryRepository = {
findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []),
update: jest.fn((id: string, patch: Record<string, unknown>) => {
updated.push({ id, patch });
return Promise.resolve();
}),
create: jest.fn((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve({ id: 'new-inv', ...row });
}),
};
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.inventoryRepository = inventoryRepository;
service.dataSource = {
query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]),
};
// Location comes straight from the dto in these cases, so pickDefaultLocation
// is never reached; findById just echoes what was written.
service.findById = jest.fn((id: string) =>
Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }),
);
const dto: UnloadBookingDto = {
warehouseId: 'w1',
yardId: 'y1',
zoneId: 'z1',
} as UnloadBookingDto;
return { service: service as unknown as WarehouseInventoryService, dto, created, updated };
}
describe('unloadBooking — GRN issuance', () => {
it('issues an IMPORT GRN when unloading a fresh import booking', async () => {
const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' });
await service.unloadBooking('b-import', dto);
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('still issues an EXPORT GRN', async () => {
const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' });
await service.unloadBooking('b-export', dto);
expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/);
});
it('mints a GRN for an existing import row that has none', async () => {
const { service, dto, updated } = makeService({
tradeDirection: 'IMPORT',
existing: { id: 'inv-1', grnNumber: null },
});
await service.unloadBooking('b-import', dto);
expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('does not reissue when the row already has a GRN', async () => {
const { service, dto, updated } = makeService({
tradeDirection: 'IMPORT',
existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' },
});
await service.unloadBooking('b-import', dto);
expect(updated[0].patch).not.toHaveProperty('grnNumber');
});
});

View File

@@ -69,6 +69,15 @@ export class WarehouseInventoryController {
return this.inventoryService.opsStats();
}
@Get('trucks-on-site')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({
summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
})
trucksOnSite() {
return this.inventoryService.trucksOnSite();
}
@Get('zone-occupancy')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })

View File

@@ -410,6 +410,83 @@ export class WarehouseInventoryService {
* - trucksOnSite: customer trucks arrived but not departed
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
*/
/**
* Every truck currently inside the yard, across all bookings — the list behind
* the `trucksOnSite` figure on the ops dashboard, which until now could only
* be counted and never opened.
*
* Covers both haulage paths because the gate does: a customer's own truck and
* an EDR last-mile truck arrive at the same barrier and need the same paper.
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
* what is coming as well as what is here — an assigned truck only stamps
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
* it departs.
*/
async trucksOnSite(): Promise<
Array<{
source: 'CUSTOMER' | 'EDR';
assignmentId: string;
status: 'INBOUND' | 'ON_SITE';
plateNumber: string | null;
driverName: string | null;
truckType: string | null;
arrivedAt: string | null;
bookingId: string;
bookingReference: string | null;
customerName: string | null;
containers: string | null;
}>
> {
return this.dataSource.query(
`SELECT 'CUSTOMER' AS "source",
a.id AS "assignmentId",
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.arrived_at AS "arrivedAt",
b.id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
FROM freight.customer_truck_containers c
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.deleted_at IS NULL
AND a.departed_at IS NULL
UNION ALL
SELECT 'EDR' AS "source",
va.id AS "assignmentId",
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
v.vehicle_type AS "truckType",
va.arrived_at AS "arrivedAt",
b.id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
FROM freight.last_mile_vehicle_containers lvc
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
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
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE va.deleted_at IS NULL
AND va.departed_at IS NULL
-- On-site trucks first, each group oldest-arrival first; inbound trucks
-- (null arrival) sort to the end.
ORDER BY "arrivedAt" ASC NULLS LAST`,
);
}
async opsStats(): Promise<{
receivedToday: number;
receivedYesterday: number;
@@ -1067,14 +1144,15 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
// A GRN is the receipt for cargo entering the warehouse, so every booking
// gets one on unload — import as well as export. The direction only decides
// the GRN prefix, not whether one is issued.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1095,10 +1173,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber
? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1113,9 +1191,7 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1202,8 +1278,18 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
-- fallback for single-truck bookings written before that table.
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
@@ -1334,8 +1420,14 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
@@ -1794,8 +1886,18 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
-- fallback for single-truck bookings written before that table.
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
@@ -2454,6 +2556,7 @@ export class WarehouseInventoryService {
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
@@ -2978,6 +3081,31 @@ export class WarehouseInventoryService {
netTons,
],
);
// Customer self-haul: the same exit record on the customer's own truck.
// Without it a self-haul bulk booking never draws down — hauled tonnage
// summed to zero and the booking could take unlimited trucks. Matched by
// plate rather than container so bulk trucks (which carry none) count.
await manager.query(
`UPDATE freight.customer_truck_assignments a
SET departed_at = COALESCE($3::timestamptz, NOW()),
arrived_at = COALESCE(a.arrived_at, NOW()),
gross_weight_kg = $4,
tare_weight_tons = $5,
net_weight_tons = $6,
updated_at = NOW()
WHERE a.booking_id = $1
AND UPPER(a.plate_number) = UPPER($2)
AND a.departed_at IS NULL
AND a.deleted_at IS NULL`,
[
item.bookingId,
dto.truckPlateNumber.trim(),
dto.gateOutTime ?? null,
grossTons,
tareTons,
netTons,
],
);
}
await this.activityLog.record(
{
@@ -5527,6 +5655,31 @@ export class WarehouseInventoryService {
);
}
/**
* Export cargo is received into the warehouse to wait for its train, and it is
* received only once the booking is paid — receiving an unpaid export booking
* would start storage and mint a GRN against cargo the customer has not settled.
*
* Export only: import cargo arrives OFF a train and its receive is the unload,
* so gating that on payment would strand cargo already at the yard.
*/
private async assertExportBookingPaid(
bookingId: string | null | undefined,
direction: string | null,
): Promise<void> {
if (!bookingId || direction !== 'EXPORT') return;
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
`SELECT payment_status AS "paymentStatus"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
throw new BadRequestException(
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
);
}
}
private assertCapacity(
label: string,
node: LocationNode,

View File

@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
facilityType: 'DRY_PORT' as const,
facilityStatus: 'ACTIVE' as const,
locationName: 'Indode',
country: 'Djibouti',
city: 'Djibouti',
address: 'Indode, Djibouti',
latitude: 11.5447,
longitude: 43.145,
// Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
// KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
// of the line. Coordinates are left unset rather than guessed; fill them in
// when the real position is to hand.
country: 'Ethiopia',
city: 'Addis Ababa',
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
capacity: 50000,
isActive: true,
notes: 'Primary dry port for container consolidation and distribution',
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
const yardRepo = manager.getRepository(WarehouseYard);
const zoneRepo = manager.getRepository(WarehouseZone);
// Ensure facility exists
const facility = await facilityRepo.findOne({
// Ensure facility exists. An existing facility row is not proof the
// warehouses under it survived, so reuse it and carry on rather than
// returning — otherwise a facility with no warehouses stays that way.
const existing = await facilityRepo.findOne({
where: { code: INDODE_FACILITY.code },
});
if (facility) {
this.logger.log('Indode facility already exists, skipping seed');
return;
let savedFacility: Facility;
if (existing) {
savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY });
this.logger.log(`Facility ${savedFacility.code} already exists, reusing`);
} else {
savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY));
this.logger.log(`Created facility: ${savedFacility.code}`);
}
const newFacility = facilityRepo.create(INDODE_FACILITY);
const savedFacility = await facilityRepo.save(newFacility);
this.logger.log(`Created facility: ${savedFacility.code}`);
// Create warehouses for the facility
for (const warehouseData of WAREHOUSES) {
try {

View File

@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
* settled which is the intercity facility.
*/
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
const FACILITY_YARDS: Array<{
code: string;
facility: string;
hasWarehouse: boolean;
handlesContainer: boolean;
}> = [
// Containers need a reach stacker or gantry — only these three are equipped.
// Bulk needs far less, so every facility handles it.
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
];
@Injectable()
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
* yards — a missing code is logged and skipped rather than invented.
*/
async run(): Promise<void> {
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
const [yard]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
[code],
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
);
await this.dataSource.query(
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
VALUES ($1, $2, $3)
`INSERT INTO freight.yard_facilities
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
VALUES ($1, $2, $3, true, $4)
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
[yard.id, hasWarehouse, `${facility} load/unload facility`],
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
handles_container = EXCLUDED.handles_container,
handles_bulk = EXCLUDED.handles_bulk,
updated_at = NOW()`,
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
);
}
this.logger.log(

View File

@@ -46,6 +46,7 @@ import {
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
@@ -124,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import IntercityPage from "./pages/warehouses/IntercityPage";
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
@@ -464,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -696,6 +705,7 @@ const App = () => {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
@@ -958,6 +968,7 @@ const App = () => {
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -1,5 +1,13 @@
import { api } from "./http";
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
import type {
AuthTokens,
AuthUser,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
LoginResponse,
ResetTicket,
SetPasswordPayload,
} from "./types";
export const loginRequest = async (payload: {
email: string;
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/me");
return response.data;
};
// The three calls below drive the unauthenticated forgot-password flow.
// Responses under /api/auth are *flattened* by the API's response
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
export const requestPasswordResetRequest = async (
payload: ForgotPasswordRequestPayload,
) => {
await api.post("/auth/forgot-password/request", payload);
};
export const verifyPasswordResetOtpRequest = async (
payload: ForgotPasswordVerifyPayload,
) => {
const response = await api.post<ResetTicket>(
"/auth/forgot-password/verify",
payload,
);
return response.data;
};
/**
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
* Carries its own userId/verificationCode and never touches the session.
*/
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
await api.patch("/auth/set-password", payload);
};

View File

@@ -1,10 +1,7 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
@@ -14,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -96,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -112,19 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` / MutationCache handler
// shows the real cause instead of "Request failed with status code NNN".
// Applies even on suppressErrorModal paths — only the modal is opted out.
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
(error as { message?: string }).message = payload.messages.join("\n");
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -58,6 +58,29 @@ export interface LoginResponse extends Partial<AuthTokens> {
mfaRequired?: boolean;
}
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
otp: string;
}
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
export interface ResetTicket {
userId: string;
verificationCode: string;
}
export interface SetPasswordPayload {
newPassword: string;
confirmPassword: string;
userId: string;
email: string;
verificationCode: string;
}
// Additional types for Matrix form test
export interface User {
id: string;

View File

@@ -0,0 +1,145 @@
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export const OTP_LENGTH = 6;
export interface OtpChannelStepProps {
/**
* Raw contacts the code was sent to; masked before display. The API sends one
* code to every contact on the account, so both are usually set — pass only
* what the client actually knows. Omit both when the client cannot know them
* (the forgot-password flow deliberately never reveals an account's contacts)
* and a generic line is shown instead.
*/
email?: string;
phone?: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
onBack: () => void;
onResend: () => void;
/** Seconds until resend is allowed; 0 enables the button. */
resendIn: number;
sending: boolean;
verifying: boolean;
error: string | null;
title?: string;
description?: string;
submitLabel: string;
}
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service, which
* delivers a single code to the account's email AND phone; whichever message
* arrives first can be typed here.
*/
export default function OtpChannelStep({
email,
phone,
value,
onChange,
onVerify,
onBack,
onResend,
resendIn,
sending,
verifying,
error,
title,
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTargets = [
email ? maskEmail(email) : null,
phone ? maskPhone(phone) : null,
].filter(Boolean) as string[];
const busy = sending || verifying;
return (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? "Verify it's you"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
{maskedTargets.length ? (
maskedTargets.map((target, index) => (
<span key={target}>
{index > 0 ? " and " : null}
<span className="font-medium text-gray-700">{target}</span>
</span>
))
) : (
<span className="font-medium text-gray-700">
the email and phone on your account
</span>
)}
. {description ?? "Enter it to continue."}
</p>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={OTP_LENGTH}
type="number"
oneTimeCode
value={value}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={onChange}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || value.trim().length !== OTP_LENGTH}
onClick={onVerify}
>
{submitLabel}
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={busy}
onClick={onBack}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || busy}
onClick={onResend}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,41 @@
import { Check, X } from "lucide-react";
import { passwordRequirements } from "@/utils/passwordSchema";
export interface PasswordChecklistProps {
/** The current password value; the checklist hides itself when empty. */
value: string;
}
/** Live pass/fail list of the password rules, shown under a password field. */
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
if (!value) return null;
return (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(value);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More