Merge remote-tracking branch 'origin/dev' into tests

This commit is contained in:
Muluhabt
2026-07-20 16:34:59 +03:00
274 changed files with 13494 additions and 3061 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

@@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type PositionTypeLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
position?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
};
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
positions?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
}[];
}[]
| null;
};
@@ -90,12 +97,110 @@ export function assertFreightPermission(
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/**
* The caller's IAM position-type keys (`iam.position_types.key`). A position
* type is the platform's notion of a role — it is what carries permissions via
* `iam.position_type_permissions` — and it is the vocabulary contract approval
* chains are configured in.
*
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
* an object on some tokens and an array on others.
*
* Note delegated positions carry no `positionType` in the token, so a delegate
* is not reachable here — they authorize through the permission arm of
* `assertCanApproveContractStep` instead.
*/
export function collectPositionTypeKeys(
user: MeLikeUser | null | undefined,
): string[] {
const employee = user?.employee;
if (!employee) return [];
const keys = new Set<string>();
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
if (pos.positionType?.key) keys.add(pos.positionType.key);
}
}
return [...keys];
}
if (employee.position?.positionType?.key) {
keys.add(employee.position.positionType.key);
}
return [...keys];
}
/**
* Legacy chain roles predate position types. Historical `approval_rules` and
* in-flight `contract_approval_steps` rows still carry them, so map each to the
* position types that stand in for it. Without this, an approver holding a
* modern position type could not action an older step.
*/
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
DIRECTOR: ['director', 'operation-director'],
CEO: ['chief', 'deputy'],
};
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
const ANY_CONTRACT_APPROVE_PERMISSION = [
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
];
/**
* May this caller action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` for chains configured by an
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
* A caller passes when any of these hold:
*
* - they are a super/organization admin (blanket bypass);
* - their position type matches the step, directly or via a legacy alias;
* - they hold the approve permission the legacy role maps to;
* - they hold any contract approve permission — this covers delegates (whose
* position type is absent from the token) and staff whose IAM position has
* no position type assigned yet.
*/
export function assertCanApproveContractStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isFreightApprovalAdmin(user)) return;
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
return;
}
throw new ForbiddenException(
`You are not the required approver (${requiredRole}) for this step.`,
);
}
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,

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,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* New built-train lifecycle status DEACTIVATED: staff park a train indefinitely
* (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like
* UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never
* overwrites it and refuses to schedule a deactivated train.
*
* Postgres cannot drop an enum value, so down() is a no-op.
*/
export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface {
name = 'AddTrainDeactivatedStatus2380000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
);
}
public async down(): Promise<void> {
// Enum values cannot be removed in Postgres; leaving the label is harmless.
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia)
* selectable in the Train Builder. The paired EXPORT number is derived
* (import 1), so only the import side is configured. Seeded with the runs
* historically hardcoded in the backoffice's trainRuns constants; admins add
* new runs from the Dropdown Settings editor.
*/
export class SeedImportTrainNumbers2390000000000 implements MigrationInterface {
name = 'SeedImportTrainNumbers2390000000000';
private readonly code = 'import_train_numbers';
private readonly options: string[] = [
'8002',
'8102',
'8202',
'8302',
'8402',
'8502',
'8602',
'8702',
'8802',
'8902',
'9002',
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta)
VALUES ($1, $2, $3, false, $4::jsonb)
RETURNING id;`,
[
this.code,
'Import train numbers',
'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import 1).',
JSON.stringify({ searchable: true, clearable: true }),
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const value = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, value, value, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard soft-delete now appends `@<epoch-ms>` to the unique code (SEBETA →
* SEBETA@1755612345678) so the name can be reused by a new yard while
* UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long
* codes plus the 14-char suffix, so widen to 40.
*/
export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface {
name = 'WidenYardCodeForSoftDeleteSuffix2390000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
);
}
public async down(): Promise<void> {
// Narrowing would fail on suffixed codes; keep 40.
}
}

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,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bookings no longer run an approval chain — accepting an intake approves the
* booking outright and generates its contract. The approval chain is now a
* contract-only concern, so `freight.approval_rules` is read by contracts alone.
*
* Also widens the role columns: chain steps now reference IAM position-type
* keys (`iam.position_types.key`), and real keys run past the old varchar(30)
* (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail
* on insert.
*/
export class DropBookingApprovalWidenRoles2410000000000
implements MigrationInterface
{
name = 'DropBookingApprovalWidenRoles2410000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.booking_approval_step;`,
);
for (const [table, column] of [
['approval_rules', 'required_role'],
['approval_rules', 'blocks_role'],
['contract_approval_steps', 'required_role'],
['contract_approval_steps', 'blocks_role'],
] as const) {
await queryRunner.query(
`ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`,
);
}
}
/**
* No-op: the booking approval chain is retired, so re-creating the table
* would leave dead schema behind. Narrowing the role columns again would
* truncate any position-type key already stored.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Audit trail for contract document edits. The document stays editable through
* the whole approval chain (each approver may edit on their turn), so the
* contract itself only ever holds the current snapshot — this table records who
* changed which article, and when.
*/
export class CreateContractDocumentRevisions2420000000000
implements MigrationInterface
{
name = 'CreateContractDocumentRevisions2420000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_document_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
actor_id uuid,
actor_role varchar(64),
step_id uuid,
summary varchar(255),
changes jsonb NOT NULL DEFAULT '[]'::jsonb
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract
ON freight.contract_document_revisions (contract_id, created_at DESC);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
);
}
}

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

@@ -157,13 +157,13 @@ export class BookingLifecycleNotifierService {
});
}
/** Clearance finalized → customer can proceed to request operation. */
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Clearance for booking ${b.reference} is complete. ` +
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY');
this.inApp(b, 'Clearance complete', msg, {
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
@@ -253,6 +253,19 @@ export class BookingLifecycleNotifierService {
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/**
* A booking was created under a contract. Contract drawdowns never pass
* through submit, so this is the only point at which staff learn the booking
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
*/
createdToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking created',
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
);
}
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(

View File

@@ -1,4 +1,3 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
@@ -9,7 +8,11 @@ export interface BookingNextStep {
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
/**
* Retained for call-site compatibility — bookings no longer run an approval
* chain, so this is always null. Approvals are a contract-only concern.
*/
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
): BookingNextStep | null {
const { status } = booking;

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
@@ -34,8 +33,6 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({

View File

@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
assertNoHardBlocks: jest.fn(),
};
const contractService = {
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
contractService as never,
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
return { service, bookingsRepository, ruleEngineService, contractService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
status: 'APPROVED',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
it('approves outright and generates the contract (no approval chain)', async () => {
const { service, contractService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
});
});

View File

@@ -7,9 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
@@ -248,16 +246,6 @@ export class BookingTransitionService {
return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(
bookingId: string,
actorId: string,
@@ -283,21 +271,33 @@ export class BookingTransitionService {
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: "PENDING_APPROVAL",
// Bookings no longer run a multi-step approval chain — accepting the intake
// approves the booking outright and generates its contract. (The approval
// chain is a contract-only concern now; see contract-transition.service.)
await this.bookingsRepository.update(bookingId, {
status: "APPROVED",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
// Generating the contract is best-effort: the acceptance is already
// committed, so a failure here must not roll it back. The booking stays
// APPROVED and staff can retry generation from the booking page.
try {
await this.contractService.generateContract(bookingId);
} catch (err) {
this.logger.warn(
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
`The booking is APPROVED — retry generation from the booking page.`,
);
}
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.accepted(fresh);
this.notifier.approved(fresh);
return fresh;
}
@@ -324,140 +324,6 @@ export class BookingTransitionService {
return fresh;
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
"Approval steps must be completed in order",
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["CONTRACT_READY"]);
@@ -1298,12 +1164,9 @@ export class BookingTransitionService {
}
let nextStep: BookingNextStep | null = null;
try {
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
nextStep = computeNextStep(booking, nextPending);
// Bookings no longer carry an approval chain, so there is never a pending
// approval step to hint at.
nextStep = computeNextStep(booking, null);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,

View File

@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
@@ -1023,47 +1021,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: "Generate contract PDF from template" })

View File

@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
@@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingDocumentReview,
BookingRateSnapshot,
BookingReviewNote,

View File

@@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
@@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
bookingId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
bookingId: string,
stepId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
});
}
/** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
const next = await this.findNextPendingApprovalStep(bookingId);
if (!next || next.requiredRole !== requiredRole) return null;
return next;
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
// ── Clearance document reviews ────────────────────────────────────────────
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
@@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
@@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join the entity by id and select just the

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

@@ -1,48 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
import { Booking } from './booking.entity';
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
@Entity({ schema: 'freight', name: 'booking_approval_step' })
@Index(['bookingId'])
@Index(['status'])
@Index(['bookingId', 'stepOrder'])
export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'approval_rule_id', type: 'uuid' })
approvalRuleId!: string;
@ManyToOne(() => ApprovalRule)
@JoinColumn({ name: 'approval_rule_id' })
approvalRule?: ApprovalRule;
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
actionedByStaffId?: string | null;
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
actionedAt?: Date | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
}

View File

@@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Train } from '../../trains/entities/train.entity';
import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
@@ -557,8 +556,6 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
approvalSteps?: BookingApprovalStep[];
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];

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

@@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
kind,
status,
onboardingCompleted,
sortBy = 'name',
sortOrder = 'ASC',
} = query;
const qb = this.repository
@@ -113,8 +115,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();

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,19 @@ export class ListCompaniesQueryDto {
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
@ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"],
default: "name",
description: "Column to order by. Defaults to name for backwards compatibility.",
})
@IsOptional()
@IsIn(["name", "createdAt", "updatedAt"])
sortBy?: "name" | "createdAt" | "updatedAt";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
}

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

@@ -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,
@@ -352,6 +354,12 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
// Tell staff the booking exists. Placed after the zero-price rollback (which
// hard-deletes the row) and before the consolidation gate, so it fires
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
@@ -481,6 +489,7 @@ export class ContractBookingService {
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
}
@@ -568,7 +577,10 @@ export class ContractBookingService {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created);
return created;
}
/**

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',
@@ -446,7 +456,7 @@ export class ContractClearanceService {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
`Cannot finalize document approval on status "${contract.status}".`,
);
}
}

View File

@@ -0,0 +1,168 @@
import type {
ContractDocumentArticle,
ContractDocumentSnapshot,
} from './entities/contract.entity';
/**
* One recorded change between two document snapshots. Granularity is per
* article: a body edit is reported as "the body changed", not as a text diff.
*/
export type ContractDocumentChange =
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
| {
kind: 'ARTICLE_RENAMED';
articleId: string;
title: string;
fromTitle: string;
}
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
| {
kind: 'ARTICLE_REORDERED';
articleId: string;
title: string;
fromOrder: number;
toOrder: number;
}
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
type SnapshotLike = Pick<
ContractDocumentSnapshot,
'documentTitle' | 'whereasClauses' | 'articles'
> | null;
/** Match on id when present, else on normalized title (editors may omit ids). */
function articleKey(article: ContractDocumentArticle): string {
return article.id || `title:${article.title.trim().toLowerCase()}`;
}
function indexArticles(
articles: ContractDocumentArticle[] | undefined,
): Map<string, ContractDocumentArticle> {
const map = new Map<string, ContractDocumentArticle>();
for (const article of articles ?? []) {
map.set(articleKey(article), article);
}
return map;
}
/**
* Compare two document snapshots and describe what changed, article by article.
* Returns an empty array when the snapshots are equivalent, so callers can skip
* recording a no-op revision.
*/
export function diffSnapshots(
before: SnapshotLike,
after: SnapshotLike,
): ContractDocumentChange[] {
const changes: ContractDocumentChange[] = [];
const beforeTitle = before?.documentTitle ?? null;
const afterTitle = after?.documentTitle ?? null;
if (beforeTitle !== afterTitle && afterTitle !== null) {
changes.push({
kind: 'DOCUMENT_TITLE_CHANGED',
title: afterTitle,
fromTitle: beforeTitle,
});
}
const beforeWhereas = before?.whereasClauses ?? [];
const afterWhereas = after?.whereasClauses ?? [];
const beforeWhereasSet = new Set(beforeWhereas);
const afterWhereasSet = new Set(afterWhereas);
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
if (whereasAdded > 0 || whereasRemoved > 0) {
changes.push({
kind: 'WHEREAS_CHANGED',
added: whereasAdded,
removed: whereasRemoved,
});
}
const beforeArticles = indexArticles(before?.articles);
const afterArticles = indexArticles(after?.articles);
for (const [key, article] of afterArticles) {
const previous = beforeArticles.get(key);
if (!previous) {
changes.push({
kind: 'ARTICLE_ADDED',
articleId: article.id,
title: article.title,
});
continue;
}
if (previous.title !== article.title) {
changes.push({
kind: 'ARTICLE_RENAMED',
articleId: article.id,
title: article.title,
fromTitle: previous.title,
});
}
if (previous.body !== article.body) {
changes.push({
kind: 'ARTICLE_BODY_CHANGED',
articleId: article.id,
title: article.title,
});
}
if (previous.order !== article.order) {
changes.push({
kind: 'ARTICLE_REORDERED',
articleId: article.id,
title: article.title,
fromOrder: previous.order,
toOrder: article.order,
});
}
}
for (const [key, article] of beforeArticles) {
if (afterArticles.has(key)) continue;
changes.push({
kind: 'ARTICLE_REMOVED',
articleId: article.id,
title: article.title,
});
}
return changes;
}
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
export function summarizeChanges(changes: ContractDocumentChange[]): string {
if (changes.length === 0) return 'No changes';
const articleVerbs: Record<string, string> = {
ARTICLE_ADDED: 'added',
ARTICLE_REMOVED: 'removed',
ARTICLE_RENAMED: 'renamed',
ARTICLE_BODY_CHANGED: 'edited',
ARTICLE_REORDERED: 'reordered',
};
const counts = new Map<string, number>();
const parts: string[] = [];
for (const change of changes) {
const verb = articleVerbs[change.kind];
if (verb) {
counts.set(verb, (counts.get(verb) ?? 0) + 1);
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
parts.push('document title changed');
} else if (change.kind === 'WHEREAS_CHANGED') {
parts.push('recitals changed');
}
}
const articleParts = [...counts.entries()].map(
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
);
return [...articleParts, ...parts].join(', ');
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
import type { ContractDocumentSnapshot } from './entities/contract.entity';
export interface RecordRevisionInput {
contractId: string;
before: ContractDocumentSnapshot | null;
after: ContractDocumentSnapshot | null;
actorId?: string | null;
actorRole?: string | null;
stepId?: string | null;
}
@Injectable()
export class ContractDocumentHistoryService {
private readonly logger = new Logger(ContractDocumentHistoryService.name);
constructor(
@InjectRepository(ContractDocumentRevision)
private readonly revisionRepo: Repository<ContractDocumentRevision>,
) {}
/**
* Append a revision describing what an edit changed. Best-effort: recording
* history must never break the edit that triggered it, so failures are logged
* and swallowed. A no-op edit records nothing.
*/
async record(input: RecordRevisionInput): Promise<void> {
try {
const changes = diffSnapshots(input.before, input.after);
if (changes.length === 0) return;
await this.revisionRepo.save(
this.revisionRepo.create({
contractId: input.contractId,
actorId: input.actorId ?? null,
actorRole: input.actorRole ?? null,
stepId: input.stepId ?? null,
summary: summarizeChanges(changes),
changes,
}),
);
} catch (err) {
this.logger.error(
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
);
}
}
/** Revision history for a contract, newest first. */
list(contractId: string): Promise<ContractDocumentRevision[]> {
return this.revisionRepo.find({
where: { contractId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -3,6 +3,7 @@ import {
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
articles: ContractDocumentArticle[];
code: string | null;
name: string | null;
/** True once the document may no longer be edited/regenerated. */
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
locked: boolean;
/** Whether the requesting user is the approver whose turn it is to edit. */
editableByMe: boolean;
/** Role holding editing rights right now, for "locked because…" messaging. */
nextApproverRole: string | null;
generatedAt: Date | null;
status: string;
}
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
*/
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
/**
* Approval chains are configured in IAM position types, so a step's role no
* longer maps onto the contract's fixed approver columns. These sets keep those
* legacy columns populated for the roles that still correspond to one — both the
* original role strings on historical rows and the position types that replaced
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
* which is the source of truth.
*/
const LEGACY_STAFF_ROLES = new Set([
'LINE_STAFF',
'employee',
'teamLeader',
'officeHead',
'recordOfficer',
]);
const LEGACY_DIRECTOR_ROLES = new Set([
'DIRECTOR',
'director',
'operation-director',
]);
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
/**
* Mask a phone for display — keep the last 4 digits, star the rest
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
@@ -73,6 +101,27 @@ function maskPhone(phone: string): string {
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
}
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
function maskEmail(email: string): string {
const [local, domain] = email.trim().split('@');
if (!domain) return email.trim();
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
/**
* Where the signing code went, for the "we sent a code to …" line in the UI.
* Both contacts are listed when both were used — a signer who only watches their
* handset otherwise has no idea the email carries the same code.
*/
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
return [
contacts.email ? maskEmail(contacts.email) : null,
contacts.phone ? maskPhone(contacts.phone) : null,
]
.filter(Boolean)
.join(' and ');
}
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -87,6 +136,7 @@ export class ContractTransitionService {
private readonly logger = new Logger(ContractTransitionService.name);
constructor(
private readonly documentHistory: ContractDocumentHistoryService,
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
@@ -109,34 +159,39 @@ export class ContractTransitionService {
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
* The contacts the signing OTP is sent to and verified against: the signer's
* own IAM account phone AND email. One code goes to both and either delivery
* verifies it, so a signer whose SMS is delayed can still complete from their
* inbox instead of abandoning a ready contract.
*
* H12(b): resolved server-side from the authenticated user id, never from the
* request body — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* request body — caller-supplied contacts would let an attacker point the code
* at their own phone or mailbox. Ownership is already gated separately by
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
* signature to the *person* signing rather than to a company landline that may
* be shared, stale, or imported from eTrade.
*/
private async resolveSignerPhone(signerUserId?: string): Promise<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. */
@@ -229,18 +284,22 @@ export class ContractTransitionService {
*/
async getContractDocumentDraft(
contractId: string,
user?: TCurrentUser | null,
): Promise<ContractDocumentDraft> {
const contract = await this.contractsService.findById(contractId);
const snapshot =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
(await this.resolveDocumentSnapshot(contract));
const editableByMe = await this.documentIsEditableBy(contract, user);
return {
documentTitle: snapshot?.documentTitle ?? null,
whereasClauses: snapshot?.whereasClauses ?? [],
articles: snapshot?.articles ?? [],
code: snapshot?.code ?? null,
name: snapshot?.name ?? null,
locked: !this.documentIsEditable(contract),
locked: !editableByMe,
editableByMe,
nextApproverRole: await this.nextApproverRole(contract),
generatedAt: contract.contractGeneratedAt ?? null,
status: contract.status,
};
@@ -255,10 +314,12 @@ export class ContractTransitionService {
async updateContractDocument(
contractId: string,
input: ContractDocumentSnapshotInput,
user?: TCurrentUser | null,
actorId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL']);
this.assertDocumentEditable(contract);
await this.assertDocumentEditable(contract, user);
const current =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
@@ -270,9 +331,25 @@ export class ContractTransitionService {
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
articles: input.articles ?? current?.articles ?? [],
};
const next = this.normalizeSnapshot(merged);
await this.contractsRepository.update(contractId, {
documentSnapshot: this.normalizeSnapshot(merged),
documentSnapshot: next,
} as never);
// Audit the edit after it lands. Recording history must never break the
// edit itself, so the history service swallows its own failures.
const step = await this.contractsRepository.findNextPendingApprovalStep(
contractId,
);
await this.documentHistory.record({
contractId,
before: current,
after: next,
actorId: actorId ?? null,
actorRole: step?.requiredRole ?? null,
stepId: step?.id ?? null,
});
return this.contractsService.findById(contractId);
}
@@ -334,23 +411,54 @@ export class ContractTransitionService {
}
/**
* The per-contract document may be edited/regenerated while the contract is at
* the accept stage (SUBMITTED) or in approval with NO approver having acted
* yet. The first approval action freezes it.
* The contract document stays editable for the whole approval chain, but only
* by the approver whose turn it is: whoever can action the next pending step.
* Approving therefore hands editing rights to the next approver in the chain.
*
* Edits never reset approvals already given — earlier approvers stay approved.
*/
private documentIsEditable(contract: Contract): boolean {
private async documentIsEditableBy(
contract: Contract,
user?: TCurrentUser | null,
): Promise<boolean> {
if (contract.status === 'SUBMITTED') return true;
if (contract.status !== 'PENDING_APPROVAL') return false;
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
const next = await this.contractsRepository.findNextPendingApprovalStep(
contract.id,
);
if (!next) return false;
if (!user) return false;
try {
assertCanApproveContractStep(user, next.requiredRole);
return true;
} catch {
return false;
}
}
private assertDocumentEditable(contract: Contract): void {
if (!this.documentIsEditable(contract)) {
throw new ConflictException(
'The contract document is locked — an approver has already acted or the ' +
'contract has advanced. It can no longer be edited or regenerated.',
);
}
/** The role that currently holds editing rights, for UI messaging. */
private async nextApproverRole(contract: Contract): Promise<string | null> {
if (contract.status !== 'PENDING_APPROVAL') return null;
const next = await this.contractsRepository.findNextPendingApprovalStep(
contract.id,
);
return next?.requiredRole ?? null;
}
private async assertDocumentEditable(
contract: Contract,
user?: TCurrentUser | null,
): Promise<void> {
if (await this.documentIsEditableBy(contract, user)) return;
const role = await this.nextApproverRole(contract);
throw new ConflictException(
role
? `The contract document can only be edited by the current approver (${role}).`
: 'The contract document is locked — the contract has advanced beyond approval.',
);
}
/**
@@ -513,25 +621,11 @@ export class ContractTransitionService {
contractId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Contract> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
// Approvers review the generated contract document, so it must exist before
// the first approval can be recorded. Staff generate it (from the frozen,
// optionally-edited snapshot) at the accept stage.
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
throw new BadRequestException(
'Generate the contract document before it can be approved.',
);
}
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -541,31 +635,34 @@ export class ContractTransitionService {
if (!next || next.id !== step.id) {
throw new BadRequestException('Approval steps must be completed in order');
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
if (step.blocksRole && step.blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
// The role is the step's own — never the caller's claim about themselves.
const requiredRole = step.requiredRole;
if (authUser) {
assertCanApproveContractStep(authUser, requiredRole);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
// Record who acted on this step, but DO NOT advance the contract status here —
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
// every step in the chain is complete; until then the contract stays in
// PENDING_APPROVAL so the next required role can act.
// approving one step must not finalize the chain while later steps are still
// pending. Status only moves to APPROVED once every step in the chain is
// complete; until then the contract stays in PENDING_APPROVAL so the next
// required approver can act.
//
// `contract_approval_steps` is the source of truth for who approved what — a
// chain is an arbitrary sequence of position types and cannot be represented
// by fixed columns. The legacy columns below are still stamped, best-effort,
// for the three roles that map onto them so older readers keep working.
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
@@ -579,14 +676,19 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
// Every step approved → CONTRACT_READY. The document was already generated
// (and reviewed) at the accept stage, so we reuse it rather than
// re-rendering. Best-effort: a hiccup must not roll back the approval.
// Final approval is what produces the contract PDF — until now there was
// only a live preview. The approval steps are already committed, so a
// render failure must not roll them back; surface it instead of swallowing
// it, since an APPROVED contract with no document needs operator action.
try {
return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
this.logger.error(
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
);
throw new ServiceUnavailableException(
'All approvals were recorded, but generating the contract PDF failed. ' +
'Retry generation from the contract page.',
);
}
}
@@ -594,24 +696,13 @@ export class ContractTransitionService {
}
/**
* Staff (re)generate the contract PDF. Two stages:
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
* snapshot so approvers review the real document. Status is UNCHANGED, and
* it is blocked once an approver has acted (the document is then locked).
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
* CONTRACT_READY.
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
* transition — the document re-renders lazily on view/download.
* Retry path for a contract that finished approval but whose PDF failed to
* render (Chromium unavailable, etc.). The normal flow generates the document
* automatically on the final approval — there is no manual generate step
* before that, only the live preview.
*/
async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status === 'PENDING_APPROVAL') {
this.assertDocumentEditable(contract);
await this.renderContractDocument(contract);
return this.contractsService.findById(contractId);
}
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
await this.renderContractDocument(contract);
await this.contractsRepository.update(contractId, {
@@ -626,11 +717,17 @@ export class ContractTransitionService {
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
* (it re-renders on view/download) but the timestamp is still stamped.
*/
private async renderContractDocument(contract: Contract): Promise<void> {
private async renderContractDocument(
contract: Contract,
options: { strict?: boolean } = {},
): Promise<void> {
const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
// Strict callers (final approval) need to know the PDF is missing — it is
// the artifact of the completed chain, not a cache that can refill later.
if (options.strict) throw err;
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
@@ -642,15 +739,14 @@ export class ContractTransitionService {
}
/**
* Every approval step landed → CONTRACT_READY. The document was already
* generated (and reviewed) at the accept stage, so reuse it; render now only
* if it was somehow never generated. Never re-renders over an existing file.
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
* This is the only point at which the document is produced: approvers review a
* live preview, and the final approval is what turns it into a PDF. Renders
* unconditionally so the file reflects every edit made during the chain.
*/
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!contract.contractGeneratedAt) {
await this.renderContractDocument(contract);
}
await this.renderContractDocument(contract, { strict: true });
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
} as never);
@@ -839,11 +935,11 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
* email — the same contacts {@link sign} verifies against. The client never
* picks them (that is the H12(b) trust property): it only asks us to send, and
* we resolve them from the authenticated user id. Returns a masked hint so the
* UI can say where the code went without exposing the full values.
*/
async sendSigningOtp(
contractId: string,
@@ -858,9 +954,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
await this.otpService.sendOtp(signerContacts);
return { sentTo: maskSignerContacts(signerContacts) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -887,17 +983,17 @@ export class ContractTransitionService {
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the SIGNER's own registered
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. Ownership is already asserted above, so this proves the specific
// person holding the account is present, not merely that someone reached a
// shared company line. Must resolve identically to sendSigningOtp, or send
// and verify would target different numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
// contacts, resolved server-side from the authenticated user id — never
// caller-supplied ones, which an attacker could point at their own phone
// or mailbox. Ownership is already asserted above, so this proves the
// specific person holding the account is present, not merely that someone
// reached a shared company line. Must resolve identically to
// sendSigningOtp, or send and verify would target different contacts.
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -31,7 +31,9 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
assertFreightPermission,
@@ -60,7 +62,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
@@ -90,6 +91,7 @@ import {
@ApiBearerAuth()
export class ContractsController {
constructor(
private readonly documentHistory: ContractDocumentHistoryService,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
private readonly transitionService: ContractTransitionService,
@@ -352,8 +354,22 @@ export class ContractsController {
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
})
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.getContractDocumentDraft(id);
getContractDocumentDraft(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Editability depends on WHO is asking — only the approver whose turn it is
// may edit — so the caller is part of the draft lookup.
return this.transitionService.getContractDocumentDraft(id, user);
}
@Get(':id/document/revisions')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({
summary: 'Audit trail of edits to this contract\'s document (newest first)',
})
getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) {
return this.documentHistory.list(id);
}
@Put(':id/document/articles')
@@ -365,8 +381,14 @@ export class ContractsController {
updateContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDocumentDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.updateContractDocument(id, dto);
return this.transitionService.updateContractDocument(
id,
dto,
user,
resolveAuthUserId(user),
);
}
@Post(':id/staff/request-changes')
@@ -396,23 +418,20 @@ export class ContractsController {
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
// Whether this caller may approve depends on the step's own required role
// (an IAM position type), so the service resolves the step and authorizes
// against it — the client never declares its own role.
return this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
}
@@ -968,13 +987,16 @@ export class ContractsController {
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
// Risk history is read by people, so resolve the name now — the id alone
// would render as a UUID in the trail.
actorLabel(user),
);
}

View File

@@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractSignature } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { ContractReviewNote } from './entities/contract-review-note.entity';
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ContractDocumentReview } from './entities/contract-document-review.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
@@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractSignature,
ContractApprovalStep,
ContractReviewNote,
ContractDocumentRevision,
ContractClearanceCycle,
ContractDocumentReview,
ClearanceMilestone,
@@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ClearanceFeeService,
ContractNotifierService,
ContractTransitionService,
ContractDocumentHistoryService,
ContractClearanceService,
ClearanceWorkflowService,
BookingClearanceService,

View File

@@ -374,23 +374,18 @@ export class ContractsService {
if (companyProfileId) {
// Business-license files are FileRecords (resource "company_profiles");
// carry the live ones by reference. Staged/pending uploads are excluded by
// code. Codes are slugged from each document name so they group under
// "Profile documents" on the contract detail page.
// code. The `business_license` prefix is preserved so the portal groups
// them under "Business license" instead of the clearance catch-all — the
// index suffix keeps multiple licences distinct.
const records = await this.filesService.findByResource(
companyProfileId,
'company_profiles',
);
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
records
.filter((r) => r.code === 'business_license')
.forEach((r, i) => {
const code = `${slug(r.name)}_${i + 1}`;
const code = `business_license_${i + 1}`;
if (existingCodes.has(code)) return;
docs.push({
code,

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

@@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity {
@Column({ name: 'step_order', type: 'smallint', default: 0 })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 40 })
@Column({ name: 'required_role', type: 'varchar', length: 64 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import type { ContractDocumentChange } from '../contract-document-diff.util';
import { Contract } from './contract.entity';
/**
* Append-only audit of contract document edits. The document stays editable
* through the whole approval chain, so this records who changed which article
* and when — the contract itself only ever holds the current snapshot.
*/
@Entity({ schema: 'freight', name: 'contract_document_revisions' })
@Index(['contractId'])
export class ContractDocumentRevision extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** The approval step's required role at the time of the edit. */
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
actorRole?: string | null;
@Column({ name: 'step_id', type: 'uuid', nullable: true })
stepId?: string | null;
@Column({ name: 'summary', type: 'varchar', length: 255, nullable: true })
summary?: string | null;
@Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` })
changes!: ContractDocumentChange[];
}

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,

View File

@@ -3,6 +3,7 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
@@ -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,
@@ -175,6 +187,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 +223,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 +235,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
) {

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

@@ -31,6 +31,15 @@ export class ApprovalRulesController {
return this.service.findChain(flag === 'true');
}
@Get('position-types')
@RuleEngineView('approval-rules')
@ApiOperation({
summary: 'IAM position types to choose from when building an approval chain',
})
listPositionTypes() {
return this.service.listPositionTypes();
}
@Post('reorder')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -1,8 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
export class CreateApprovalRuleDto {
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
@IsBoolean()
@@ -19,9 +17,12 @@ export class CreateApprovalRuleDto {
@IsUUID('4')
insertAfterId?: string;
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
@ApiProperty({
description:
'IAM position-type key required to action this step (see GET /approval-rules/position-types)',
})
@IsString()
@MaxLength(30)
@MaxLength(64)
requiredRole!: string;
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
@@ -29,9 +30,11 @@ export class CreateApprovalRuleDto {
@MaxLength(50)
actionLabel!: string;
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
@ApiPropertyOptional({
description: 'IAM position-type key explicitly blocked from actioning this step',
})
@IsOptional()
@IsString()
@MaxLength(30)
@MaxLength(64)
blocksRole?: string;
}

View File

@@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity {
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
@Column({ name: 'required_role', type: 'varchar', length: 64 })
requiredRole!: string;
@Column({ name: 'action_label', type: 'varchar', length: 50 })
actionLabel!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
blocksRole?: string | null;
}

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

@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['country'])
@Index(['isActive'])
export class Yard extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 })

View File

@@ -64,7 +64,6 @@ import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
@@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ApprovalRule,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
]),
// Team notifications for the priority-rule approval workflow.

View File

@@ -1,6 +1,5 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { Rate, RateTrigger } from './entities/rate.entity';
import {
@@ -23,15 +22,10 @@ import {
IRatesRepository,
RATES_REPOSITORY,
} from './interfaces/rates.repository.interface';
import {
IApprovalRulesRepository,
APPROVAL_RULES_REPOSITORY,
} from './interfaces/approval-rules.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
export interface BookingContainerEvalInput {
@@ -124,8 +118,6 @@ export class RuleEngineService {
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepo: IRatesRepository,
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly approvalRulesRepo: IApprovalRulesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepo: IShippingLinesRepository,
private readonly dataSource: DataSource,
@@ -390,79 +382,6 @@ export class RuleEngineService {
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
async instantiateApprovalSteps(
bookingId: string,
options: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = false;
if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
}
requiresDirectorApproval = cargoType.requiresDirectorApproval;
}
const chain = await this.approvalRulesRepo.findChainForCargo(
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
for (const rule of chain) {
const step = stepRepo.create({
bookingId,
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));
}
return steps;
}
/**
* Snapshot only the rates used in a booking's final price.
*/

View File

@@ -1,5 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
@@ -17,6 +18,7 @@ export class ApprovalRulesService {
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly repository: IApprovalRulesRepository,
private readonly displayOrder: DisplayOrderService,
private readonly dataSource: DataSource,
) {}
/** List approval rules — standard paginated envelope with server-side search. */
@@ -29,6 +31,21 @@ export class ApprovalRulesService {
return this.repository.findChainForCargo(requiresDirectorApproval);
}
/**
* IAM position types, for the approval-step role picker. A chain step names
* the position type that must approve it, so this is the vocabulary an admin
* builds chains from. Read straight from the shared `iam` schema — the same
* pattern the freight API already uses for `iam.users`.
*/
async listPositionTypes(): Promise<Array<{ label: string; value: string }>> {
const rows = await this.dataSource.query<
Array<{ key: string; label: string }>
>(`SELECT key, COALESCE(name->>'en', key) AS label
FROM iam.position_types
ORDER BY 2`);
return rows.map((row) => ({ label: row.label, value: row.key }));
}
/** Get an approval rule by ID. */
async findById(id: string): Promise<ApprovalRule> {
const entity = await this.repository.findById(id);

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

@@ -31,7 +31,7 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
const code = generateCode(dto.label);
const code = generateCode(dto.label).slice(0, 40);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
@@ -58,9 +58,19 @@ export class YardsService {
return updated;
}
/** Soft-delete a yard. */
/**
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
* same name can be created later without tripping UQ_yards_code, which spans
* soft-deleted rows too.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
const yard = await this.findById(id);
const suffix = `@${Date.now()}`;
await this.repository.update(id, {
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
});
await this.repository.softDelete(id);
}

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}`,
};
}
}

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