mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/dev' into tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal 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 customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
* neither has no business in the first/last-mile queues at all.
|
||||
*/
|
||||
export const NO_MILE_SERVICE_MESSAGE =
|
||||
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';
|
||||
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal 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());
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal file
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal file
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal 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 {}
|
||||
@@ -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 customer’s 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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user