mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY=
|
|||||||
TELEBIRR_PUBLIC_KEY=
|
TELEBIRR_PUBLIC_KEY=
|
||||||
TELEBIRR_INSECURE_TLS=false
|
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.
|
# Portal pages the payment provider redirects the browser to after payment.
|
||||||
# Point these at the freight portal's public payment result routes.
|
# Point these at the freight portal's public payment result routes.
|
||||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
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 { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.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 { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||||
@@ -166,6 +167,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
HealthModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
@@ -275,6 +277,9 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// await this.demoUsersSeeder.run();
|
// await this.demoUsersSeeder.run();
|
||||||
// await this.freightStaffUsersSeeder.run();
|
// await this.freightStaffUsersSeeder.run();
|
||||||
// await this.pricingDataSeeder.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.indodeFacilitySeeder.run();
|
||||||
// await this.batch14TestDataSeeder.run();
|
// await this.batch14TestDataSeeder.run();
|
||||||
// await this.batch5TestDataSeeder.run();
|
// await this.batch5TestDataSeeder.run();
|
||||||
|
|||||||
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { assertExportReceivedWithGrn } from './export-received-gate';
|
||||||
|
|
||||||
|
const db = (rows: unknown[]) =>
|
||||||
|
({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource;
|
||||||
|
|
||||||
|
describe('assertExportReceivedWithGrn', () => {
|
||||||
|
it('passes when the export booking has a received row with a GRN', async () => {
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(db([{ '?column?': 1 }]), {
|
||||||
|
id: 'b-1',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an export booking with nothing received', async () => {
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never blocks import — it loads off a train, not out of the warehouse', async () => {
|
||||||
|
const source = db([]);
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// Import short-circuits before querying.
|
||||||
|
expect((source.query as jest.Mock)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block intercity cargo', async () => {
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
/** The booking fields the gate needs. */
|
||||||
|
export interface ExportLoadGateBooking {
|
||||||
|
id: string;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export cargo may not be loaded onto its train until it has physically reached
|
||||||
|
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||||
|
* the customer's own truck, and even though a wagon is already allocated. An
|
||||||
|
* allocation is a plan; the GRN is the proof the goods are actually in hand.
|
||||||
|
*
|
||||||
|
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
|
||||||
|
* loaded straight off the allocation, skipping the warehouse, so a booking could
|
||||||
|
* ride the train with nothing ever received. This closes that for export; import
|
||||||
|
* loads off a train and is unaffected.
|
||||||
|
*
|
||||||
|
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||||
|
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||||
|
* fallback older rows use.
|
||||||
|
*/
|
||||||
|
export async function assertExportReceivedWithGrn(
|
||||||
|
db: DataSource | EntityManager,
|
||||||
|
booking: ExportLoadGateBooking,
|
||||||
|
): Promise<void> {
|
||||||
|
if (booking.tradeDirection !== 'EXPORT') return;
|
||||||
|
|
||||||
|
const [row] = await db.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
WHERE inv.booking_id = $1
|
||||||
|
AND inv.deleted_at IS NULL
|
||||||
|
AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED')
|
||||||
|
AND COALESCE(
|
||||||
|
NULLIF(TRIM(inv.grn_number), ''),
|
||||||
|
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||||
|
) IS NOT NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[booking.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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",
|
env: process.env.NODE_ENV ?? "development",
|
||||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||||
apiPrefix: "api",
|
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: {
|
trainScheduling: {
|
||||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
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,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,49 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document review state, so a backoffice reviewer can request a correction
|
||||||
|
* on one specific onboarding document instead of rejecting the whole role.
|
||||||
|
*
|
||||||
|
* Until now `freight.files` carried no status at all: the `pending_add` /
|
||||||
|
* `pending_remove` badges the portal shows are derived by diffing live rows
|
||||||
|
* against an open company change request, which says nothing about whether a
|
||||||
|
* reviewer is happy with a given document. `review_status` is that missing
|
||||||
|
* verdict — NULL means never reviewed, which is the state every existing row
|
||||||
|
* correctly starts in, so no backfill is needed.
|
||||||
|
*
|
||||||
|
* The partial index serves the approval gate, which asks "does this company (or
|
||||||
|
* profile) still have any document with an open change request?" on every
|
||||||
|
* role-status write.
|
||||||
|
*/
|
||||||
|
export class AddFileReviewStatus2430000000000 implements MigrationInterface {
|
||||||
|
name = 'AddFileReviewStatus2430000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.files
|
||||||
|
ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS review_note text NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request"
|
||||||
|
ON freight.files (resource, resource_id)
|
||||||
|
WHERE review_status = 'change_requested' AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.files
|
||||||
|
DROP COLUMN IF EXISTS review_status,
|
||||||
|
DROP COLUMN IF EXISTS review_note,
|
||||||
|
DROP COLUMN IF EXISTS reviewed_by,
|
||||||
|
DROP COLUMN IF EXISTS reviewed_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
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
|
* Staff-triggered password reset. The customer receives a single-use link and
|
||||||
* own password — staff never see or handle a credential.
|
* sets their own password — staff never see or handle a credential.
|
||||||
*/
|
*/
|
||||||
@ApiTags("backoffice")
|
@ApiTags("backoffice")
|
||||||
@Controller("backoffice/customers")
|
@Controller("backoffice/customers")
|
||||||
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
|
|||||||
export class CustomerResetController {
|
export class CustomerResetController {
|
||||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
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")
|
@Post(":companyId/reset-password")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
@ApiOperation({
|
@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(
|
async resetPassword(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@Body() dto: BackofficeResetPasswordDto,
|
@Body() dto: BackofficeResetPasswordDto,
|
||||||
) {
|
) {
|
||||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
const sent = await this.customerResetService.sendResetLinkToCustomer(
|
||||||
companyId,
|
companyId,
|
||||||
dto.channel,
|
dto.channel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maskedTarget) {
|
if (!sent) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
`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 { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
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 { 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()
|
@Injectable()
|
||||||
export class CustomerResetService {
|
export class CustomerResetService {
|
||||||
@@ -14,19 +35,110 @@ export class CustomerResetService {
|
|||||||
@InjectRepository(ExternalProfile)
|
@InjectRepository(ExternalProfile)
|
||||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||||
private readonly forgotPasswordService: ForgotPasswordService,
|
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
|
* The IAM account a reset would actually reach. The backoffice shows these
|
||||||
* destination, or null when there is no eligible account for that channel.
|
* 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
|
* Unlike the public flow this reports failure honestly — the caller is an
|
||||||
* authenticated staff member, so there is nothing to enumerate.
|
* authenticated staff member, so there is nothing to enumerate.
|
||||||
*/
|
*/
|
||||||
async sendResetToCustomer(
|
async sendResetLinkToCustomer(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
channel: ResetChannel,
|
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({
|
const profile = await this.externalProfileRepository.findOne({
|
||||||
where: { companyId, isPrimaryContact: true },
|
where: { companyId, isPrimaryContact: true },
|
||||||
});
|
});
|
||||||
@@ -36,24 +148,28 @@ export class CustomerResetService {
|
|||||||
return null;
|
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(
|
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||||
profile.userId,
|
profile.userId,
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user?.id) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
return { profile, user, userId: user.id };
|
||||||
if (!target) return null;
|
}
|
||||||
|
|
||||||
this.logger.log(
|
/**
|
||||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
* 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
|
||||||
return this.forgotPasswordService.maskTarget(target);
|
* 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 { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
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 {
|
export enum ResetChannel {
|
||||||
Email = "email",
|
Email = "email",
|
||||||
Phone = "phone",
|
Phone = "phone",
|
||||||
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
identifier!: string;
|
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)
|
@IsEnum(ResetChannel)
|
||||||
channel!: ResetChannel;
|
channel?: ResetChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
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()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
otp!: string;
|
otp!: string;
|
||||||
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
|
|||||||
@IsEnum(ResetChannel)
|
@IsEnum(ResetChannel)
|
||||||
channel!: 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 {
|
import {
|
||||||
ForgotPasswordRequestDto,
|
ForgotPasswordRequestDto,
|
||||||
ForgotPasswordVerifyDto,
|
ForgotPasswordVerifyDto,
|
||||||
|
ResolveResetLinkDto,
|
||||||
} from "./dto/forgot-password.dto";
|
} 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
|
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||||
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
|
|||||||
|
|
||||||
@Post("forgot-password/request")
|
@Post("forgot-password/request")
|
||||||
@ApiOperation({
|
@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:
|
description:
|
||||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
"One code, delivered over every contact the account has; either delivery " +
|
||||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
"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 }> {
|
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
await this.forgotPasswordService.requestReset(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A delivery failure must not change the response shape either — log it
|
// A delivery failure must not change the response shape either — log it
|
||||||
// and let the caller sit on the OTP screen.
|
// and let the caller sit on the OTP screen.
|
||||||
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
|
|||||||
"alongside the same identifier and the new password.",
|
"alongside the same identifier and the new password.",
|
||||||
})
|
})
|
||||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||||
return this.forgotPasswordService.verifyAndMintTicket(
|
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
|
||||||
dto.identifier,
|
}
|
||||||
dto.channel,
|
|
||||||
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 { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||||
import { DataSource, Repository } from "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 { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
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 { 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. */
|
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
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 {
|
export interface ResetTicket {
|
||||||
userId: string;
|
userId: string;
|
||||||
verificationCode: 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()
|
@Injectable()
|
||||||
export class ForgotPasswordService {
|
export class ForgotPasswordService {
|
||||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||||
@@ -81,8 +102,12 @@ export class ForgotPasswordService {
|
|||||||
.orderBy("u.createdAt", "DESC");
|
.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) {
|
if (channel === ResetChannel.Email) {
|
||||||
return user.email ? { email: user.email } : null;
|
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
|
* Every contact the account has. The reset OTP goes to all of them and any one
|
||||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
* verifies it — a customer whose SMS never lands can finish from their inbox
|
||||||
* callers must discard it.
|
* 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`
|
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||||
* upserts. A reset request therefore overwrites any pending signup code for
|
* replaces every row the target overlaps. A reset request therefore overwrites
|
||||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
* any pending signup code for the same addresses — last code sent wins. That
|
||||||
* between any two flows sharing this table.
|
* is the pre-existing behaviour between any two flows sharing this table.
|
||||||
*/
|
*/
|
||||||
async requestReset(
|
async requestReset(user: User): Promise<OtpTarget | null> {
|
||||||
user: User,
|
const target = this.targetsFor(user);
|
||||||
channel: ResetChannel,
|
|
||||||
): Promise<OtpTarget | null> {
|
|
||||||
const target = this.targetFor(user, channel);
|
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
await this.otpService.sendOtp(target);
|
await this.otpService.sendOtp(target);
|
||||||
@@ -120,11 +165,12 @@ export class ForgotPasswordService {
|
|||||||
*/
|
*/
|
||||||
async verifyAndMintTicket(
|
async verifyAndMintTicket(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
channel: ResetChannel,
|
|
||||||
otp: string,
|
otp: string,
|
||||||
): Promise<ResetTicket> {
|
): Promise<ResetTicket> {
|
||||||
const user = await this.resolveActiveUser(identifier);
|
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) {
|
if (!user?.id || !target) {
|
||||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
// 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);
|
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 code = randomBytes(24).toString("base64url");
|
||||||
const verificationCode = await hashPassword(code);
|
const verificationCode = await hashPassword(code);
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const repo = manager.getRepository(UserVerification);
|
const repo = manager.getRepository(UserVerification);
|
||||||
@@ -147,7 +202,7 @@ export class ForgotPasswordService {
|
|||||||
userId,
|
userId,
|
||||||
otpType: EOtpType.RESET_PASSWORD,
|
otpType: EOtpType.RESET_PASSWORD,
|
||||||
verificationCode,
|
verificationCode,
|
||||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
expiresAt: new Date(Date.now() + ttlMs),
|
||||||
isUsed: false,
|
isUsed: false,
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
});
|
});
|
||||||
@@ -157,6 +212,63 @@ export class ForgotPasswordService {
|
|||||||
return { userId, verificationCode: code };
|
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`. */
|
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||||
maskTarget(target: OtpTarget): string {
|
maskTarget(target: OtpTarget): string {
|
||||||
return maskOtpTarget(target);
|
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 { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||||
|
|
||||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
@@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service';
|
|||||||
Employee,
|
Employee,
|
||||||
]),
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
// Reset links go out over email/SMS directly, not through the OTP service.
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
FreightMeController,
|
FreightMeController,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { OtpTarget } from "../otp/otp.service";
|
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` ->
|
* 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
|
* `+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 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 {
|
export function maskOtpTarget(target: OtpTarget): string {
|
||||||
if (target.email) {
|
const parts: string[] = [];
|
||||||
const [local, domain] = target.email.split("@");
|
if (target.email) parts.push(maskEmail(target.email));
|
||||||
const head = local.slice(0, 1);
|
if (target.phone) parts.push(maskPhone(target.phone));
|
||||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
return parts.join(" and ");
|
||||||
}
|
|
||||||
const phone = target.phone ?? "";
|
|
||||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,19 @@ export class BookingLifecycleNotifierService {
|
|||||||
|
|
||||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
// ── 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. */
|
/** Customer submitted a booking for review. */
|
||||||
submittedToStaff(b: Booking): void {
|
submittedToStaff(b: Booking): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
|||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
import { CustomerTruckContainer } from './entities/customer-truck-container.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 { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
@@ -67,39 +78,27 @@ export class CustomerTruckService {
|
|||||||
if (!isBulk && requested.length < 1) {
|
if (!isBulk && requested.length < 1) {
|
||||||
throw new BadRequestException('Select at least one container for this truck');
|
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) {
|
if (requested.length) {
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
// Never assign more trucks than the booking has containers.
|
|
||||||
const existingTrucks = await this.dataSource
|
const existingTrucks = await this.dataSource
|
||||||
.getRepository(CustomerTruckAssignment)
|
.getRepository(CustomerTruckAssignment)
|
||||||
.count({ where: { bookingId } });
|
.count({ where: { bookingId } });
|
||||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
|
||||||
throw new BadRequestException(
|
assertTruckLoad({
|
||||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
containers: requested,
|
||||||
);
|
bookingContainers: bookingNumbers,
|
||||||
}
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -191,28 +190,13 @@ export class CustomerTruckService {
|
|||||||
if (requested.length < 1) {
|
if (requested.length < 1) {
|
||||||
throw new BadRequestException('Select at least one container for this truck');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
if (requested.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: requested,
|
||||||
}
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||||
if (!bookingNumbers.includes(n)) {
|
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
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
|
// 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.
|
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||||
if (requested.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: requested,
|
||||||
}
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -534,18 +503,11 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||||
const hasLastMile = Boolean(booking.lastMile?.trim());
|
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||||
const usesMileService =
|
// with a customer truck and an EDR leg at once.
|
||||||
booking.tradeDirection === 'IMPORT'
|
if (usesEdrMileService(booking)) {
|
||||||
? hasLastMile
|
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||||
: 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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -614,18 +576,4 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
/** 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity {
|
|||||||
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
grossWeightKg?: number | null;
|
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 })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
|||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||||
|
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
@@ -494,6 +495,9 @@ export class CompaniesController {
|
|||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType,
|
||||||
size: f.size,
|
size: f.size,
|
||||||
uploadedAt: f.createdAt,
|
uploadedAt: f.createdAt,
|
||||||
|
reviewStatus: f.reviewStatus,
|
||||||
|
reviewNote: f.reviewNote,
|
||||||
|
reviewedAt: f.reviewedAt,
|
||||||
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||||
// it so the file previews/downloads in the client.
|
// it so the file previews/downloads in the client.
|
||||||
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||||
@@ -501,6 +505,35 @@ export class CompaniesController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("documents/:fileId/request-change")
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Ask the customer to correct one uploaded document",
|
||||||
|
description:
|
||||||
|
"Flags a single document with a reason the customer sees, notifies them, " +
|
||||||
|
"and blocks role approval until they re-upload. Narrower than rejecting " +
|
||||||
|
"the whole role.",
|
||||||
|
})
|
||||||
|
async requestDocumentChange(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@Body() dto: RequestDocumentChangeDto,
|
||||||
|
) {
|
||||||
|
const file = await this.companiesService.requestDocumentChange(
|
||||||
|
fileId,
|
||||||
|
dto.note,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
code: file.code,
|
||||||
|
reviewStatus: file.reviewStatus,
|
||||||
|
reviewNote: file.reviewNote,
|
||||||
|
reviewedAt: file.reviewedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/documents")
|
@Post(":companyId/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
)
|
)
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A company waiting on a reviewer to decide an edit it submitted after being
|
||||||
|
* approved. These rows are `status = active`, so the pending-application filter
|
||||||
|
* can never surface them — the review queue needs its own predicate.
|
||||||
|
*/
|
||||||
|
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
|
||||||
|
SELECT 1 FROM freight.company_change_request ccr
|
||||||
|
WHERE ccr.company_id = company.id
|
||||||
|
AND ccr.status = 'pending'
|
||||||
|
AND ccr.deleted_at IS NULL
|
||||||
|
)`;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Company)
|
@InjectRepository(Company)
|
||||||
repo: Repository<Company>,
|
repo: Repository<Company>,
|
||||||
@@ -67,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
kind,
|
kind,
|
||||||
status,
|
status,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
|
hasPendingChangeRequest,
|
||||||
|
sortBy = 'name',
|
||||||
|
sortOrder = 'ASC',
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
@@ -97,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasPendingChangeRequest !== undefined) {
|
||||||
|
qb.andWhere(
|
||||||
|
hasPendingChangeRequest
|
||||||
|
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
|
||||||
|
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const term = `%${search.trim()}%`;
|
const term = `%${search.trim()}%`;
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
@@ -113,8 +136,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||||
const [items, total] = await qb
|
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)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
@@ -137,6 +164,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
|
const pendingChanges = await this.repository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.where('company.deleted_at IS NULL')
|
||||||
|
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
|
||||||
|
.getCount();
|
||||||
|
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
let onboarding = 0;
|
let onboarding = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -154,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
onboarding,
|
onboarding,
|
||||||
suspended: map.get('suspended') ?? 0,
|
suspended: map.get('suspended') ?? 0,
|
||||||
blacklisted: map.get('blacklisted') ?? 0,
|
blacklisted: map.get('blacklisted') ?? 0,
|
||||||
|
pendingChanges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
@@ -98,6 +99,7 @@ export class CompaniesService {
|
|||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
private readonly companyNotifier: CompanyNotifierService,
|
private readonly companyNotifier: CompanyNotifierService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -748,7 +750,15 @@ export class CompaniesService {
|
|||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
})) ?? existing;
|
})) ?? existing;
|
||||||
|
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||||
} else {
|
} else {
|
||||||
|
// Rejecting a request leaves it Rejected rather than reopening it, so a
|
||||||
|
// customer amending after a rejection lands here with a fresh Pending row.
|
||||||
|
// That is the resubmission case the reviewer needs flagged.
|
||||||
|
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
||||||
|
const resubmitted = history.some(
|
||||||
|
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||||
|
);
|
||||||
request = await this.changeRequestRepo.create({
|
request = await this.changeRequestRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
snapshot: fields,
|
snapshot: fields,
|
||||||
@@ -756,6 +766,11 @@ export class CompaniesService {
|
|||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
});
|
});
|
||||||
|
this.companyNotifier.changeRequestSubmitted(
|
||||||
|
company,
|
||||||
|
request.id,
|
||||||
|
resubmitted,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live company is unchanged; surface the pending state for the settings page.
|
// Live company is unchanged; surface the pending state for the settings page.
|
||||||
@@ -827,6 +842,12 @@ export class CompaniesService {
|
|||||||
"companies",
|
"companies",
|
||||||
files,
|
files,
|
||||||
);
|
);
|
||||||
|
await this.resolveDocumentChangeRequests(
|
||||||
|
companyId,
|
||||||
|
"companies",
|
||||||
|
uploaded.map((f) => f.code),
|
||||||
|
uploaded.map((f) => f.id),
|
||||||
|
);
|
||||||
if (company.status === CompanyStatus.Active) {
|
if (company.status === CompanyStatus.Active) {
|
||||||
await this.stageDocumentChange(
|
await this.stageDocumentChange(
|
||||||
company.id,
|
company.id,
|
||||||
@@ -837,6 +858,95 @@ export class CompaniesService {
|
|||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the `change_requested` flag from the documents a fresh upload replaces.
|
||||||
|
*
|
||||||
|
* Uploading does not overwrite the old row — it adds a new one under the same
|
||||||
|
* `code` — so the flagged original would otherwise linger and keep the approval
|
||||||
|
* gate closed even after the customer did exactly what was asked. Only rows of
|
||||||
|
* the same code are touched, and never the newly uploaded ones.
|
||||||
|
*/
|
||||||
|
private async resolveDocumentChangeRequests(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
codes: string[],
|
||||||
|
uploadedIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (codes.length === 0) return;
|
||||||
|
const replaced = new Set(codes);
|
||||||
|
const fresh = new Set(uploadedIds);
|
||||||
|
const open = await this.filesService.findWithOpenChangeRequest(
|
||||||
|
[resourceId],
|
||||||
|
resource,
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
open
|
||||||
|
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
|
||||||
|
.map((f) => this.filesService.clearReview(f.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice: ask the customer to correct one specific document, instead of
|
||||||
|
* rejecting their whole role over it. Mirrors the contract change-request
|
||||||
|
* flow — a note the customer sees verbatim, plus a block on approval until
|
||||||
|
* they re-upload.
|
||||||
|
*/
|
||||||
|
async requestDocumentChange(
|
||||||
|
fileId: string,
|
||||||
|
note: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const file = await this.filesService.findById(fileId);
|
||||||
|
const companyId = await this.resolveDocumentCompanyId(file);
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
|
||||||
|
// Flag the document while holding a write lock on its company row. The
|
||||||
|
// approval gate takes the same lock before it reads the flags, so the two
|
||||||
|
// serialize: a change request can never land in the window between the gate
|
||||||
|
// checking "any open corrections?" and writing the profile Active.
|
||||||
|
const updated = await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.findOne(Company, {
|
||||||
|
where: { id: companyId },
|
||||||
|
lock: { mode: "pessimistic_write" },
|
||||||
|
});
|
||||||
|
return this.filesService.setReviewStatus(
|
||||||
|
file.id,
|
||||||
|
"change_requested",
|
||||||
|
note,
|
||||||
|
reviewerId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.companyNotifier.documentChangeRequested(
|
||||||
|
company,
|
||||||
|
file.name,
|
||||||
|
note,
|
||||||
|
file.id,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which company a stored document belongs to. Company documents are keyed by
|
||||||
|
* the company id directly; profile licences and POA letters hang off a company
|
||||||
|
* profile, so those resolve through it.
|
||||||
|
*/
|
||||||
|
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
|
||||||
|
if (file.resource === "companies") return file.resourceId;
|
||||||
|
if (file.resource === "company_profiles") {
|
||||||
|
const profile = await this.companyProfilesRepo.findById(file.resourceId);
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Company profile ${file.resourceId} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile.companyId;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Documents on "${file.resource}" do not support change requests`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Open or append a pending change request recording staged document uploads. */
|
/** Open or append a pending change request recording staged document uploads. */
|
||||||
private async stageDocumentChange(
|
private async stageDocumentChange(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -847,6 +957,7 @@ export class CompaniesService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const existing =
|
const existing =
|
||||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
const company = await this.companiesRepo.findById(companyId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const prev = existing.documents?.documentFileIds ?? [];
|
const prev = existing.documents?.documentFileIds ?? [];
|
||||||
await this.changeRequestRepo.update(existing.id, {
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
@@ -860,8 +971,15 @@ export class CompaniesService {
|
|||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
});
|
});
|
||||||
|
if (company) {
|
||||||
|
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.changeRequestRepo.create({
|
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
|
const resubmitted = history.some(
|
||||||
|
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||||
|
);
|
||||||
|
const created = await this.changeRequestRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
snapshot: {},
|
snapshot: {},
|
||||||
documents: { documentFileIds: fileIds },
|
documents: { documentFileIds: fileIds },
|
||||||
@@ -869,6 +987,13 @@ export class CompaniesService {
|
|||||||
submittedBy: submittedBy ?? null,
|
submittedBy: submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
});
|
});
|
||||||
|
if (company) {
|
||||||
|
this.companyNotifier.changeRequestSubmitted(
|
||||||
|
company,
|
||||||
|
created.id,
|
||||||
|
resubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,6 +1112,61 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything other than approval has no document gate and no concurrency
|
||||||
|
// hazard — apply it directly.
|
||||||
|
if (status !== ProfileStatus.Active) {
|
||||||
|
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approving over an outstanding document correction would silently accept the
|
||||||
|
// very document a reviewer just rejected, and would strand the customer's
|
||||||
|
// "please fix this" banner with nothing left to fix. The gate check and the
|
||||||
|
// status write share a write lock on the company row — `requestDocumentChange`
|
||||||
|
// takes the same lock, so a fresh correction can never land in the window
|
||||||
|
// between "any open corrections?" and the profile going Active. Suspend and
|
||||||
|
// blacklist skip all this — staff must always be able to act against a bad
|
||||||
|
// account.
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.findOne(Company, {
|
||||||
|
where: { id: existing.companyId },
|
||||||
|
lock: { mode: "pessimistic_write" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [companyDocs, profileDocs] = await Promise.all([
|
||||||
|
this.filesService.findWithOpenChangeRequest(
|
||||||
|
[existing.companyId],
|
||||||
|
"companies",
|
||||||
|
),
|
||||||
|
this.filesService.findWithOpenChangeRequest(
|
||||||
|
[existing.id],
|
||||||
|
"company_profiles",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const pending = [...companyDocs, ...profileDocs];
|
||||||
|
if (pending.length > 0) {
|
||||||
|
const names = pending.map((f) => f.name).join(", ");
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
||||||
|
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a reviewed profile status (reference minting, note handling, reviewer
|
||||||
|
* stamp) and promote the company if this is its first approved role. Split out
|
||||||
|
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
|
||||||
|
* transaction while every other status skips that overhead.
|
||||||
|
*/
|
||||||
|
private async applyProfileStatus(
|
||||||
|
existing: CompanyProfile,
|
||||||
|
status: ProfileStatus,
|
||||||
|
note?: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
// A reference number is only minted the first time a profile is approved
|
// A reference number is only minted the first time a profile is approved
|
||||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||||
const patch: Partial<CompanyProfile> = { status };
|
const patch: Partial<CompanyProfile> = { status };
|
||||||
@@ -1008,9 +1188,9 @@ export class CompaniesService {
|
|||||||
patch.reviewedAt = new Date();
|
patch.reviewedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
// Approving any profile promotes a pending company to active, so the
|
// Approving any profile promotes a pending company to active, so the
|
||||||
// customer can start working as soon as their first profile is cleared.
|
// customer can start working as soon as their first profile is cleared.
|
||||||
@@ -1057,6 +1237,13 @@ export class CompaniesService {
|
|||||||
});
|
});
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
|
||||||
|
// The role is back in the pending queue — tell the reviewers, otherwise the
|
||||||
|
// resubmission is invisible until someone happens to reopen the customer.
|
||||||
|
const company = await this.companiesRepo.findById(companyId);
|
||||||
|
if (company) {
|
||||||
|
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,6 +1699,15 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fresh licence upload answers any correction the reviewer asked for on the
|
||||||
|
// previous one, so the old row must stop blocking approval.
|
||||||
|
await this.resolveDocumentChangeRequests(
|
||||||
|
profileId,
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||||
|
uploaded.map((r) => r.id),
|
||||||
|
);
|
||||||
|
|
||||||
return this.getProfileLicenseView(profileId, company.id);
|
return this.getProfileLicenseView(profileId, company.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,6 +1789,13 @@ export class CompaniesService {
|
|||||||
await this.filesService.remove(fileId);
|
await this.filesService.remove(fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.resolveDocumentChangeRequests(
|
||||||
|
profileId,
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
|
||||||
return this.getProfileLicenseView(profileId, company.id);
|
return this.getProfileLicenseView(profileId, company.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1689,6 +1892,8 @@ export class CompaniesService {
|
|||||||
: pendingRemoveIds.has(r.id)
|
: pendingRemoveIds.has(r.id)
|
||||||
? ("pending_remove" as const)
|
? ("pending_remove" as const)
|
||||||
: ("live" as const),
|
: ("live" as const),
|
||||||
|
reviewStatus: r.reviewStatus,
|
||||||
|
reviewNote: r.reviewNote,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1855,6 +2060,13 @@ export class CompaniesService {
|
|||||||
for (const r of live) await this.filesService.remove(r.id);
|
for (const r of live) await this.filesService.remove(r.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.resolveDocumentChangeRequests(
|
||||||
|
company.id,
|
||||||
|
COMPANY_RESOURCE,
|
||||||
|
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
|
||||||
return this.getPoaDelegationView(company.id);
|
return this.getPoaDelegationView(company.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1932,6 +2144,8 @@ export class CompaniesService {
|
|||||||
: removeIds.has(r.id)
|
: removeIds.has(r.id)
|
||||||
? ("pending_remove" as const)
|
? ("pending_remove" as const)
|
||||||
: ("live" as const),
|
: ("live" as const),
|
||||||
|
reviewStatus: r.reviewStatus,
|
||||||
|
reviewNote: r.reviewNote,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,106 @@ export class CompanyNotifierService {
|
|||||||
priority: NotificationPriority.HIGH,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
||||||
|
* the customer's detail page.
|
||||||
|
*
|
||||||
|
* The recipient resolver has no role/permission targeting (see
|
||||||
|
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
||||||
|
* selector available, so marketing is reached by notifying all staff.
|
||||||
|
*/
|
||||||
|
private notifyStaff(
|
||||||
|
company: Company,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
): void {
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/dashboard/customers/${company.id}`,
|
||||||
|
data: { companyId: company.id, companyName: company.name, ...data },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer resubmitted an operational role after it was rejected for
|
||||||
|
* adjustment. Without this the role silently flips back to Pending and nobody
|
||||||
|
* is told there is anything to look at again.
|
||||||
|
*/
|
||||||
|
roleReapplied(company: Company, profileId: string, profileType: string): void {
|
||||||
|
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
|
||||||
|
this.notifyStaff(
|
||||||
|
company,
|
||||||
|
"Customer resubmitted a role for approval",
|
||||||
|
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
|
||||||
|
`It is back in the pending approval queue for review.`,
|
||||||
|
{ profileId, profileType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer submitted (or amended and resubmitted) a profile change request.
|
||||||
|
* `resubmitted` distinguishes the two so the reviewer knows this is a second
|
||||||
|
* look at something they already sent back.
|
||||||
|
*/
|
||||||
|
changeRequestSubmitted(
|
||||||
|
company: Company,
|
||||||
|
changeRequestId: string,
|
||||||
|
resubmitted: boolean,
|
||||||
|
): void {
|
||||||
|
this.logger.log(
|
||||||
|
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`,
|
||||||
|
);
|
||||||
|
this.notifyStaff(
|
||||||
|
company,
|
||||||
|
resubmitted
|
||||||
|
? "Customer resubmitted profile changes"
|
||||||
|
: "Customer submitted profile changes",
|
||||||
|
resubmitted
|
||||||
|
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
|
||||||
|
`them. They are pending your review.`
|
||||||
|
: `${company.name} has submitted profile changes that are pending review.`,
|
||||||
|
{ changeRequestId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing: a specific document needs correcting ──────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer a reviewer wants one specific document corrected. Mirrors
|
||||||
|
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
|
||||||
|
* deep-linked to the documents tab where they can re-upload.
|
||||||
|
*/
|
||||||
|
documentChangeRequested(
|
||||||
|
company: Company,
|
||||||
|
documentName: string,
|
||||||
|
note: string,
|
||||||
|
fileId: string,
|
||||||
|
): void {
|
||||||
|
const title = "Document change requested";
|
||||||
|
const body =
|
||||||
|
`A reviewer has asked you to correct "${documentName}". ` +
|
||||||
|
`Reason: ${note} ` +
|
||||||
|
`Please upload a corrected version from your settings page.`;
|
||||||
|
|
||||||
|
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
|
||||||
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: company.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: "/settings",
|
||||||
|
data: { companyId: company.id, fileId, documentName },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ export class CompanyStatsResponseDto {
|
|||||||
onboarding!: number;
|
onboarding!: number;
|
||||||
suspended!: number;
|
suspended!: number;
|
||||||
blacklisted!: number;
|
blacklisted!: number;
|
||||||
|
/**
|
||||||
|
* Approved customers with an open profile change request. Counted separately
|
||||||
|
* because they are `active` and so are invisible to the `pending` KPI, even
|
||||||
|
* though they are just as much waiting on a reviewer.
|
||||||
|
*/
|
||||||
|
pendingChanges!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
|||||||
import { CompanyType } from '../entities/company.entity';
|
import { CompanyType } from '../entities/company.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class CompanyProfileInputDto {
|
export class CompanyProfileInputDto {
|
||||||
@IsEnum(ProfileType)
|
@IsEnum(ProfileType)
|
||||||
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(10)
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@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 { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class CreateCompanyDto {
|
export class CreateCompanyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@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 {
|
export class FetchETradeDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,30 @@ export class ListCompaniesQueryDto {
|
|||||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"`true` = only companies with an open (pending) profile change request. " +
|
||||||
|
"These are already-approved customers, so they never appear under " +
|
||||||
|
"`status=pending` and would otherwise be invisible in the review queue.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
hasPendingChangeRequest?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["name", "createdAt", "updatedAt"],
|
||||||
|
default: "name",
|
||||||
|
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["name", "createdAt", "updatedAt"])
|
||||||
|
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
|
@IsIn(["ASC", "DESC"])
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class RequestDocumentChangeDto {
|
||||||
|
/** What is wrong with this document — shown verbatim to the customer. */
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
note!: string;
|
||||||
|
}
|
||||||
@@ -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 { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -34,7 +36,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -137,10 +139,14 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
renewedTo?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(ETHIOPIAN_REGIONS as unknown as string[], {
|
||||||
@MaxLength(100)
|
message: "region must be a recognised Ethiopian region",
|
||||||
region?: string;
|
})
|
||||||
|
region?: EthiopianRegion;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -37,8 +37,20 @@ export interface BusinessLicenseFile {
|
|||||||
*/
|
*/
|
||||||
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewer verdict on a document, as surfaced to clients. Distinct from
|
||||||
|
* {@link StagedFileStatus}: that describes where the file sits in the staged
|
||||||
|
* add/remove workflow, this describes whether a reviewer wants it corrected.
|
||||||
|
*/
|
||||||
|
export interface FileReviewView {
|
||||||
|
/** `change_requested` while the customer still owes a corrected upload. */
|
||||||
|
reviewStatus?: "change_requested" | "approved" | null;
|
||||||
|
/** The reviewer's reason, shown verbatim to the customer. */
|
||||||
|
reviewNote?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||||
export interface ProfileLicenseFileView {
|
export interface ProfileLicenseFileView extends FileReviewView {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -47,7 +59,7 @@ export interface ProfileLicenseFileView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||||
export interface CompanyDocumentFileView {
|
export interface CompanyDocumentFileView extends FileReviewView {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ETradeCompanyInfo,
|
ETradeCompanyInfo,
|
||||||
ETradeBusinessInfo,
|
ETradeBusinessInfo,
|
||||||
CompanyRegistrationData,
|
CompanyRegistrationData,
|
||||||
|
normalizeRegion,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -108,7 +109,11 @@ export class ETradeService {
|
|||||||
renewedFrom: businessInfo.RenewedFrom,
|
renewedFrom: businessInfo.RenewedFrom,
|
||||||
renewalDate: businessInfo.RenewalDate,
|
renewalDate: businessInfo.RenewalDate,
|
||||||
renewedTo: businessInfo.RenewedTo,
|
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 || "",
|
zone: businessInfo.AddressInfo?.Zone || "",
|
||||||
woreda: businessInfo.AddressInfo?.Woreda || "",
|
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||||
kebele: businessInfo.AddressInfo?.Kebele || "",
|
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 { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
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). */
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
riskLevel?: string | null;
|
riskLevel?: string | null;
|
||||||
riskAssignedAt?: 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). */
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
secondDuty?: ClearanceSecondDuty | null;
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
importReleaseGranted?: boolean;
|
importReleaseGranted?: boolean;
|
||||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
|||||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
? riskMilestone.triggeredAt.toISOString()
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
: null,
|
: 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,
|
secondDuty,
|
||||||
importReleaseGranted:
|
importReleaseGranted:
|
||||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
|
|||||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
|||||||
expect(saved.status).toBe('COMPLETED');
|
expect(saved.status).toBe('COMPLETED');
|
||||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
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
|
* 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
|
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
* 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(
|
async assignRisk(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
riskLevel: CustomsRiskLevel,
|
riskLevel: CustomsRiskLevel,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
|
actor?: string,
|
||||||
): Promise<ClearanceMilestone> {
|
): Promise<ClearanceMilestone> {
|
||||||
await this.assertT1Closed(bookingId);
|
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. */
|
/** 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, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
@@ -97,6 +98,7 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
private readonly clearanceFeeService: ClearanceFeeService,
|
||||||
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
@@ -353,6 +355,12 @@ export class ContractBookingService {
|
|||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||||
booking.id,
|
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 =
|
const intendedStatus =
|
||||||
generalCustoms || generalSelfClear
|
generalCustoms || generalSelfClear
|
||||||
? 'AWAITING_DOCUMENTS'
|
? 'AWAITING_DOCUMENTS'
|
||||||
@@ -482,6 +490,7 @@ export class ContractBookingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||||
return { booking: result ?? booking, warnings: [] };
|
return { booking: result ?? booking, warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +578,10 @@ export class ContractBookingService {
|
|||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
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 { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { GlOperationsService } from './gl-operations.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 { Contract } from './entities/contract.entity';
|
||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
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). */
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
riskLevel?: string | null;
|
riskLevel?: string | null;
|
||||||
riskAssignedAt?: 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). */
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
secondDuty?: ClearanceSecondDuty | null;
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
importReleaseGranted?: boolean;
|
importReleaseGranted?: boolean;
|
||||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
|||||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
? riskMilestone.triggeredAt.toISOString()
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
// Every risk decision, oldest first — see booking-clearance.service.
|
||||||
|
riskHistory:
|
||||||
|
riskMilestone?.status === 'COMPLETED'
|
||||||
|
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||||
|
: [],
|
||||||
secondDuty,
|
secondDuty,
|
||||||
importReleaseGranted:
|
importReleaseGranted:
|
||||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ function maskPhone(phone: string): string {
|
|||||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
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. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
@@ -139,34 +160,39 @@ export class ContractTransitionService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||||
* IAM account number.
|
* 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
|
* 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
|
* request body — caller-supplied contacts would let an attacker point the code
|
||||||
* at their own phone. Ownership is already gated separately by
|
* at their own phone or mailbox. Ownership is already gated separately by
|
||||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||||
* signature to the *person* signing rather than to a company landline that may
|
* signature to the *person* signing rather than to a company landline that may
|
||||||
* be shared, stale, or imported from eTrade.
|
* 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) {
|
if (!signerUserId) {
|
||||||
// Unreachable in practice (the ownership gate rejects a missing user
|
// 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');
|
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(
|
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],
|
[signerUserId],
|
||||||
);
|
);
|
||||||
const phone = rows[0]?.phone_number?.trim();
|
const phone = rows[0]?.phone_number?.trim();
|
||||||
if (!phone) {
|
const email = rows[0]?.email?.trim();
|
||||||
|
if (!phone && !email) {
|
||||||
throw new BadRequestException(
|
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. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -992,11 +1018,11 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||||
* same number {@link sign} verifies against. The client never picks the number
|
* email — the same contacts {@link sign} verifies against. The client never
|
||||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||||
* say where the code went without exposing the full number.
|
* UI can say where the code went without exposing the full values.
|
||||||
*/
|
*/
|
||||||
async sendSigningOtp(
|
async sendSigningOtp(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -1011,9 +1037,9 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||||
|
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
await this.otpService.sendOtp({ phone: signerPhone });
|
await this.otpService.sendOtp(signerContacts);
|
||||||
return { sentTo: maskPhone(signerPhone) };
|
return { sentTo: maskSignerContacts(signerContacts) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
@@ -1040,17 +1066,17 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// 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
|
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||||
// phone, resolved server-side from the authenticated user id — never a
|
// contacts, resolved server-side from the authenticated user id — never
|
||||||
// caller-supplied number, which an attacker could point at their own
|
// caller-supplied ones, which an attacker could point at their own phone
|
||||||
// phone. Ownership is already asserted above, so this proves the specific
|
// or mailbox. Ownership is already asserted above, so this proves the
|
||||||
// person holding the account is present, not merely that someone reached a
|
// specific person holding the account is present, not merely that someone
|
||||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
// reached a shared company line. Must resolve identically to
|
||||||
// and verify would target different numbers.
|
// sendSigningOtp, or send and verify would target different contacts.
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
if (!dto.otp) {
|
if (!dto.otp) {
|
||||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
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.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { actorLabel } from '../warehouses/current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
@@ -990,13 +991,16 @@ export class ContractsController {
|
|||||||
assignRisk(
|
assignRisk(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: AssignRiskDto,
|
@Body() dto: AssignRiskDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.milestoneService.assignRisk(
|
return this.milestoneService.assignRisk(
|
||||||
bookingId,
|
bookingId,
|
||||||
dto.riskLevel,
|
dto.riskLevel,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.note,
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
|||||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
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):
|
* 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`
|
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||||
* Stored on the milestone so the timeline can render the value inline.
|
* Stored on the milestone so the timeline can render the value inline.
|
||||||
*/
|
*/
|
||||||
export interface MilestoneMetadata {
|
export interface MilestoneMetadata {
|
||||||
riskLevel?: CustomsRiskLevel;
|
riskLevel?: CustomsRiskLevel;
|
||||||
|
/**
|
||||||
|
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||||
|
* equals the last entry's `level`.
|
||||||
|
*/
|
||||||
|
riskHistory?: RiskAssignmentRecord[];
|
||||||
dutyAmount?: number;
|
dutyAmount?: number;
|
||||||
dutyCurrency?: string;
|
dutyCurrency?: string;
|
||||||
declarationSerial?: string;
|
declarationSerial?: string;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewer verdict on a single stored document.
|
||||||
|
*
|
||||||
|
* `null` (the default) means "not reviewed" — the state every file starts in and
|
||||||
|
* the only state the customer is not blocked by. `change_requested` is raised by
|
||||||
|
* a backoffice reviewer against one specific document and is what the customer
|
||||||
|
* must clear by re-uploading; `approved` records an explicit sign-off.
|
||||||
|
*/
|
||||||
|
export type FileReviewStatus = "change_requested" | "approved";
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "files" })
|
@Entity({ schema: "freight", name: "files" })
|
||||||
export class FileRecord extends BaseEntity {
|
export class FileRecord extends BaseEntity {
|
||||||
@Column({ name: "resource_id", type: "uuid" })
|
@Column({ name: "resource_id", type: "uuid" })
|
||||||
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||||
mimeType!: string;
|
mimeType!: string;
|
||||||
|
|
||||||
|
/** Reviewer verdict, or `null` while the document has never been reviewed. */
|
||||||
|
@Column({
|
||||||
|
name: "review_status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
})
|
||||||
|
reviewStatus!: FileReviewStatus | null;
|
||||||
|
|
||||||
|
/** Why a change was requested — shown verbatim to the customer. */
|
||||||
|
@Column({ name: "review_note", type: "text", nullable: true })
|
||||||
|
reviewNote!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||||
|
reviewedBy!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
|
reviewedAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
|
||||||
import { FilesService } from "./files.service";
|
import { FilesService } from "./files.service";
|
||||||
@@ -23,13 +30,16 @@ export class FilesController {
|
|||||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
// 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({
|
@ApiOperation({
|
||||||
summary: "Stream a file by ID",
|
summary: "Stream a file by ID",
|
||||||
description:
|
description:
|
||||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
"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 " +
|
"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({
|
@ApiQuery({
|
||||||
name: "download",
|
name: "download",
|
||||||
@@ -41,7 +51,19 @@ export class FilesController {
|
|||||||
@Query("download") download: string | undefined,
|
@Query("download") download: string | undefined,
|
||||||
@Res() res: Response,
|
@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 forceDownload = download === "1" || download === "true";
|
||||||
const disposition = forceDownload ? "attachment" : "inline";
|
const disposition = forceDownload ? "attachment" : "inline";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
|
|
||||||
import { FileRecord } from "./entities/file.entity";
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
return this.repository.find({ where: { resourceId, resource } });
|
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(
|
findByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
@@ -33,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.repository.delete({ resourceId, resource, code });
|
await this.repository.delete({ resourceId, resource, code });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents belonging to any of the given resources that a reviewer has asked
|
||||||
|
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||||
|
* resource ids (a company plus each of its company profiles) in one query.
|
||||||
|
*/
|
||||||
|
async findWithOpenChangeRequest(
|
||||||
|
resourceIds: string[],
|
||||||
|
resource: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
if (resourceIds.length === 0) return [];
|
||||||
|
return this.repository.find({
|
||||||
|
where: {
|
||||||
|
resourceId: In(resourceIds),
|
||||||
|
resource,
|
||||||
|
reviewStatus: "change_requested",
|
||||||
|
},
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
import { MinioService } from "../minio/minio.service";
|
import { MinioService } from "../minio/minio.service";
|
||||||
import { FilesRepository } from "./files.repository";
|
import { FilesRepository } from "./files.repository";
|
||||||
import { FileRecord } from "./entities/file.entity";
|
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
|
||||||
|
|
||||||
export interface CreateFileInput {
|
export interface CreateFileInput {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -78,8 +79,19 @@ export class FilesService {
|
|||||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||||
// human-readable name is preserved separately on the record below.
|
// human-readable name is preserved separately on the record below.
|
||||||
const safeName = sanitizeObjectName(file.originalname);
|
const safeName = sanitizeObjectName(file.originalname);
|
||||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
|
// The random segment is load-bearing, not decoration. `Date.now()` alone is
|
||||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
// 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({
|
return this.filesRepository.create({
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -157,6 +169,53 @@ export class FilesService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||||
|
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||||
|
* reason can never outlive the request it explained.
|
||||||
|
*/
|
||||||
|
async setReviewStatus(
|
||||||
|
id: string,
|
||||||
|
status: FileReviewStatus,
|
||||||
|
note: string | null,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const record = await this.findById(id);
|
||||||
|
const updated = await this.filesRepository.update(record.id, {
|
||||||
|
reviewStatus: status,
|
||||||
|
reviewNote: status === "change_requested" ? (note ?? null) : null,
|
||||||
|
reviewedBy: reviewerId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException(`File ${id} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop any reviewer verdict from a document, returning it to "not reviewed".
|
||||||
|
* Called when a customer re-uploads: the new bytes have not been looked at, so
|
||||||
|
* carrying the old `change_requested` forward would keep them blocked forever.
|
||||||
|
*/
|
||||||
|
async clearReview(id: string): Promise<void> {
|
||||||
|
await this.filesRepository.update(id, {
|
||||||
|
reviewStatus: null,
|
||||||
|
reviewNote: null,
|
||||||
|
reviewedBy: null,
|
||||||
|
reviewedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Documents across these resources still awaiting a customer correction. */
|
||||||
|
findWithOpenChangeRequest(
|
||||||
|
resourceIds: string[],
|
||||||
|
resource: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return this.filesRepository.findWithOpenChangeRequest(
|
||||||
|
resourceIds,
|
||||||
|
resource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
|
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.filesRepository.softDelete(id);
|
await this.filesRepository.softDelete(id);
|
||||||
@@ -175,6 +234,27 @@ export class FilesService {
|
|||||||
return this.filesRepository.findByResource(resourceId, resource);
|
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
|
* 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;
|
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||||
@@ -190,7 +270,11 @@ export class FilesService {
|
|||||||
resource: string,
|
resource: string,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<FileRecord> {
|
): Promise<FileRecord> {
|
||||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
const record = await this.filesRepository.findByCode(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (!record)
|
if (!record)
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||||
@@ -198,7 +282,9 @@ export class FilesService {
|
|||||||
return record;
|
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 record = await this.findById(id);
|
||||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
|||||||
@@ -333,10 +333,12 @@ export class FirstMileService {
|
|||||||
firstMilePickupAddress?: string | null;
|
firstMilePickupAddress?: string | null;
|
||||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||||
}): boolean {
|
}): 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(
|
return Boolean(
|
||||||
booking.tradeDirection === 'EXPORT' &&
|
booking.tradeDirection === 'EXPORT' && booking.firstMilePickupAddress?.trim(),
|
||||||
(booking.firstMilePickupAddress?.trim() ||
|
|
||||||
booking.serviceType?.includesFirstMile),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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';
|
} from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
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 { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.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> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
@@ -352,6 +405,8 @@ export class LastMileService {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertEdrHaulsThisBooking(dto.bookingId);
|
||||||
|
|
||||||
const record = await this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
@@ -573,20 +628,6 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
/** 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 —
|
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||||
@@ -599,26 +640,9 @@ export class LastMileService {
|
|||||||
remainingTons: number;
|
remainingTons: number;
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}> {
|
}> {
|
||||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
// Counts customer trucks as well as EDR ones — a booking hauls by one path
|
||||||
await this.dataSource.query(
|
// or the other, and "until no tonnage is left" means the same either way.
|
||||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
return remainingBulkTons(this.dataSource, bookingId);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -643,11 +667,7 @@ export class LastMileService {
|
|||||||
);
|
);
|
||||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||||
if (totalTons > 0 && remainingTons <= 0) {
|
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||||
throw new BadRequestException(
|
|
||||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,33 +677,41 @@ export class LastMileService {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const vehicleId of desired) {
|
for (const vehicleId of desired) {
|
||||||
const load = loads.get(vehicleId) ?? [];
|
const load = loads.get(vehicleId) ?? [];
|
||||||
if (load.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: load,
|
||||||
}
|
bookingContainers: bookingNumbers,
|
||||||
for (const n of load) {
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
|
||||||
if (!bookingNumbers.includes(n)) {
|
assignedElsewhere: [...seen],
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
});
|
||||||
}
|
load.forEach((n) => seen.add(n));
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desired.length > bookingNumbers.length) {
|
assertTruckCountWithinContainers(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.`,
|
|
||||||
|
/**
|
||||||
|
* 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 },
|
where: { lastMileId: id },
|
||||||
relations: { containers: true },
|
relations: { containers: true },
|
||||||
});
|
});
|
||||||
|
await this.assertNoArrivedVehicleChanged(current, desiredMap);
|
||||||
|
|
||||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
// 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.
|
// 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";
|
} from "@nestjs/common";
|
||||||
import { ClientProxy } from "@nestjs/microservices";
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
import { SendEmailDto } from "./dtos/email.dto";
|
import { SendEmailDto } from "./dtos/email.dto";
|
||||||
|
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailClientService implements OnApplicationBootstrap {
|
export class EmailClientService implements OnApplicationBootstrap {
|
||||||
@@ -33,19 +34,34 @@ export class EmailClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||||
return { queued: false };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
this.emailClient.emit("send-email", {
|
const queued = await publishConfirmed(
|
||||||
to: dto.to,
|
this.emailClient,
|
||||||
subject: dto.subject,
|
"send-email",
|
||||||
text: dto.text,
|
{
|
||||||
html: dto.html,
|
to: dto.to,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
subject: dto.subject,
|
||||||
});
|
text: dto.text,
|
||||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
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(
|
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.
|
// Recipient + content are PII — debug only.
|
||||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
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";
|
} from "@nestjs/common";
|
||||||
import { ClientProxy } from "@nestjs/microservices";
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
||||||
|
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsClientService implements OnApplicationBootstrap {
|
export class SmsClientService implements OnApplicationBootstrap {
|
||||||
@@ -14,7 +15,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject("SMS_SERVICE")
|
@Inject("SMS_SERVICE")
|
||||||
private smsClient: ClientProxy,
|
private smsClient: ClientProxy,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.log("connected to SMS service");
|
this.logger.log("connected to SMS service");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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`);
|
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||||
return { queued: false };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
this.smsClient.emit("send-sms", {
|
const queued = await publishConfirmed(
|
||||||
to: dto.to,
|
this.smsClient,
|
||||||
text: dto.message,
|
"send-sms",
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
{
|
||||||
});
|
to: dto.to,
|
||||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
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(
|
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.
|
// Recipient + content are PII — debug only.
|
||||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||||
return { queued: true };
|
return { queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||||
if (!this.enabled) {
|
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 };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
const messages = (dto.messages ?? []).map((m) => ({
|
||||||
this.smsClient.emit("ozeking-bulk-sms", {
|
to: m.to,
|
||||||
messages,
|
text: m.message,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
from: m.from,
|
||||||
});
|
}));
|
||||||
|
const queued = await publishConfirmed(
|
||||||
|
this.smsClient,
|
||||||
|
"ozeking-bulk-sms",
|
||||||
|
{
|
||||||
|
messages,
|
||||||
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
this.logger.log(
|
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)}`);
|
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 { OtpService, OtpTarget } from "./otp.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
// Exactly one of phone/email must be present per request — the channel the
|
// At least one of phone/email must be present. When BOTH are given the code is
|
||||||
// code is sent through / checked against.
|
// 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 {
|
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||||
if (email) return { email };
|
const target: OtpTarget = {};
|
||||||
if (phone) return { phone };
|
if (email?.trim()) target.email = email;
|
||||||
throw new BadRequestException("phone or email is required");
|
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
|
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||||
@@ -41,7 +46,13 @@ export class OtpController {
|
|||||||
@Body("email")
|
@Body("email")
|
||||||
email?: string
|
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 { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Repository } from "typeorm";
|
import { FindOptionsWhere, Repository } from "typeorm";
|
||||||
|
|
||||||
import { OtpVerification } from "./otp.entity";
|
import { OtpVerification } from "./otp.entity";
|
||||||
|
|
||||||
|
type Target = { phone?: string; email?: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpRepository {
|
export class OtpRepository {
|
||||||
constructor(
|
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 }
|
* 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
|
||||||
return target.email
|
* one resolves the same row — that is what makes "sent to both, verify with
|
||||||
? this.findByEmail(target.email)
|
* either" work.
|
||||||
: this.findByPhone(target.phone!);
|
*/
|
||||||
}
|
private whereForTarget(
|
||||||
|
target: Target
|
||||||
|
): FindOptionsWhere<OtpVerification>[] {
|
||||||
|
const where: FindOptionsWhere<OtpVerification>[] =
|
||||||
|
[];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
if (target.email)
|
||||||
// Create OTP
|
where.push({
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async createOtp(
|
|
||||||
target: { phone?: string; email?: string },
|
|
||||||
otp: string
|
|
||||||
) {
|
|
||||||
const entity =
|
|
||||||
this.repository.create({
|
|
||||||
phone: target.phone,
|
|
||||||
email: target.email,
|
email: target.email,
|
||||||
otp,
|
|
||||||
verified: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.save(
|
if (target.phone)
|
||||||
entity
|
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
|
otp: string
|
||||||
) {
|
): Promise<{
|
||||||
otpVerification.otp = otp;
|
record: OtpVerification;
|
||||||
|
rotated: boolean;
|
||||||
|
}> {
|
||||||
|
const existing =
|
||||||
|
await this.findAllByTarget(
|
||||||
|
target
|
||||||
|
);
|
||||||
|
|
||||||
otpVerification.verified =
|
if (existing.length) {
|
||||||
false;
|
await this.repository.remove(
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.repository.save(
|
const record =
|
||||||
otpVerification
|
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)
|
// Delete OTP (single-use consume)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
|
||||||
// requested for the same number on the next action.
|
// be requested for the same target on the next action.
|
||||||
async deleteOtp(
|
async deleteOtp(
|
||||||
otpVerification: OtpVerification
|
otpVerification: OtpVerification
|
||||||
) {
|
) {
|
||||||
@@ -124,4 +184,4 @@ export class OtpRepository {
|
|||||||
otpVerification
|
otpVerification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
|
|||||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes email targets through untouched', () => {
|
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
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)', () => {
|
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||||
@@ -21,41 +28,216 @@ describe('normalizeOtpTarget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OtpService — send/verify agree across phone formats', () => {
|
interface FakeRow {
|
||||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
id: string;
|
||||||
// the test proves normalisation makes send and verify collide on one key.
|
phone?: string;
|
||||||
function makeService() {
|
email?: string;
|
||||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
otp: string;
|
||||||
const repo = {
|
updatedAt: Date;
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 () => {
|
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ phone: '+251986680099' });
|
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(
|
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 });
|
).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 { SmsClientService } from "../notifications/sms-client.service";
|
||||||
import { EmailClientService } from "../notifications/email-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 };
|
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
|
* 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,
|
* 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
|
* 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
|
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||||
* Email targets pass through untouched.
|
|
||||||
*/
|
*/
|
||||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
function normalizePhone(rawPhone: string): string {
|
||||||
if (target.email || !target.phone) return target;
|
const raw = rawPhone.trim();
|
||||||
const raw = target.phone.trim();
|
|
||||||
const digits = raw.replace(/[^\d+]/g, '');
|
const digits = raw.replace(/[^\d+]/g, '');
|
||||||
if (digits.startsWith('+')) return { phone: digits };
|
if (digits.startsWith('+')) return digits;
|
||||||
const bare = digits.replace(/^0+/, '');
|
const bare = digits.replace(/^0+/, '');
|
||||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
// it looks like a full international number, else leave as typed.
|
// 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()
|
@Injectable()
|
||||||
@@ -58,25 +104,36 @@ export class OtpService {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(rawTarget: OtpTarget) {
|
async sendOtp(rawTarget: OtpTarget) {
|
||||||
// Store under the canonical E.164 key so verify (which normalises the same
|
// Store under the canonical keys so verify (which normalises the same way)
|
||||||
// way) always finds this row regardless of how either side typed the number.
|
// always finds this row regardless of how either side typed the number.
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
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 {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// 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
|
// 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();
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing row for this channel
|
// Replaces every row this target overlaps with, so a dual-channel send
|
||||||
const existing = await this.otpRepository.findByTarget(target);
|
// 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
|
// `rotate` means a code already existed for this target and was replaced —
|
||||||
if (existing) {
|
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||||
await this.otpRepository.updateOtp(existing, otp);
|
// typing its code will fail against the row; this line is how that shows up
|
||||||
} else {
|
// in the log rather than as an unexplained "invalid OTP" report.
|
||||||
// create new otp
|
this.logger.log(
|
||||||
await this.otpRepository.createOtp(target, otp);
|
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||||
}
|
);
|
||||||
|
|
||||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
// 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
|
// 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
|
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||||
// in the codebase yet.
|
// in the codebase yet.
|
||||||
|
|
||||||
if (target.email) {
|
// Fan out to every channel the target has, independently: one transport
|
||||||
// send email (queued to RabbitMQ via the shared Email service)
|
// being down must not suppress the other, which is the whole point of
|
||||||
await this.emailClient.sendEmail({
|
// sending to both. Each helper swallows its own failure so a rejected
|
||||||
to: target.email,
|
// email publish still leaves the SMS delivered (and the code valid).
|
||||||
subject: "Your EDR Freight verification code",
|
const outcomes = (
|
||||||
text: `Your verification code is ${otp}`,
|
await Promise.all([
|
||||||
});
|
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||||
} else {
|
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
])
|
||||||
await this.smsClient.sendSms({
|
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||||
to: target.phone as string,
|
|
||||||
message: `Your verification code is ${otp}`,
|
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 {
|
return {
|
||||||
success: true,
|
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",
|
message: "OTP sent successfully",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
// 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.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||||
error instanceof Error ? error.message : String(error)
|
Date.now() - startedAt
|
||||||
}`,
|
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
throw new BadRequestException("Failed to send OTP");
|
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
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -128,23 +325,36 @@ export class OtpService {
|
|||||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||||
// when verify is called with 09… (or any equivalent form).
|
// when verify is called with 09… (or any equivalent form).
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
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 otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
// not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
// No row for this target. Most often a normalisation mismatch or a code
|
||||||
target.email ? "Email address not found" : "Phone number not found",
|
// 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
|
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||||
// code can't be verified.
|
// code can't be verified.
|
||||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"expired",
|
||||||
|
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
@@ -157,24 +367,36 @@ export class OtpService {
|
|||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"exhausted",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Too many incorrect attempts. Request a new code.",
|
"Too many incorrect attempts. Request a new code.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.actionAttempts.set(key, attempts);
|
this.actionAttempts.set(key, attempts);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"invalid",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException("Invalid OTP");
|
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);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message: target.email
|
message: "Verification successful",
|
||||||
? "Email verified successfully"
|
|
||||||
: "Phone verified successfully",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +418,6 @@ export class OtpService {
|
|||||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||||
private readonly actionAttempts = new Map<string, number>();
|
private readonly actionAttempts = new Map<string, number>();
|
||||||
|
|
||||||
private targetKey(target: OtpTarget): string {
|
|
||||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyOtpForAction(
|
async verifyOtpForAction(
|
||||||
rawTarget: OtpTarget,
|
rawTarget: OtpTarget,
|
||||||
otp: string,
|
otp: string,
|
||||||
@@ -207,22 +425,21 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
this.logVerify(target, "action", "not_found");
|
||||||
target.email
|
throw new BadRequestException(this.notFoundMessage(target, true));
|
||||||
? "No verification code was requested for this email"
|
|
||||||
: "No verification code was requested for this phone",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
|
|
||||||
if (ageMs > ttlMs) {
|
if (ageMs > ttlMs) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
|
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
@@ -235,18 +452,31 @@ export class OtpService {
|
|||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"exhausted",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Too many incorrect attempts. Request a new code.",
|
"Too many incorrect attempts. Request a new code.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.actionAttempts.set(key, attempts);
|
this.actionAttempts.set(key, attempts);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"invalid",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException("Invalid verification code");
|
throw new BadRequestException("Invalid verification code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// single-use: consume on success
|
// single-use: consume on success
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
|||||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||||
hasWarehouse!: boolean;
|
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 })
|
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||||
equipmentNotes?: string | null;
|
equipmentNotes?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
|||||||
hasFacility: boolean;
|
hasFacility: boolean;
|
||||||
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||||
hasWarehouse: boolean;
|
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
|
* 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
|
* `yard_facilities` record says what it can actually do — whether it stores cargo
|
||||||
* warehouse move cargo on and off the train and nothing more — no storage, no
|
* (storage/demurrage), and which freight types its equipment can lift. Containers
|
||||||
* demurrage. This is the single resolver the journey and handling flows use, so
|
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
|
||||||
* they can't drift on what a facility is.
|
* 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()
|
@Injectable()
|
||||||
export class YardFacilitiesService {
|
export class YardFacilitiesService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
private readonly SELECT = `
|
||||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
SELECT y.id AS "yardId",
|
||||||
const [row]: Array<{
|
y.code AS "yardCode",
|
||||||
yardId: string;
|
y.label AS "yardLabel",
|
||||||
yardCode: string | null;
|
y.has_facility AS "hasFacility",
|
||||||
yardLabel: string | null;
|
f.has_warehouse AS "hasWarehouse",
|
||||||
hasFacility: boolean;
|
f.handles_container AS "handlesContainer",
|
||||||
hasWarehouse: boolean | null;
|
f.handles_bulk AS "handlesBulk"
|
||||||
}> = await this.dataSource.query(
|
FROM freight.yards y
|
||||||
`SELECT y.id AS "yardId",
|
LEFT JOIN freight.yard_facilities f
|
||||||
y.code AS "yardCode",
|
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||||
y.label AS "yardLabel",
|
|
||||||
y.has_facility AS "hasFacility",
|
private toInfo(row: {
|
||||||
f.has_warehouse AS "hasWarehouse"
|
yardId: string;
|
||||||
FROM freight.yards y
|
yardCode: string | null;
|
||||||
LEFT JOIN freight.yard_facilities f
|
yardLabel: string | null;
|
||||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
hasFacility: boolean;
|
||||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
hasWarehouse: boolean | null;
|
||||||
[yardId],
|
handlesContainer: boolean | null;
|
||||||
);
|
handlesBulk: boolean | null;
|
||||||
if (!row) return null;
|
}): YardFacilityInfo {
|
||||||
|
// No facility record means no capability, whatever the flag says.
|
||||||
|
const hasFacility = Boolean(row.hasFacility);
|
||||||
return {
|
return {
|
||||||
yardId: row.yardId,
|
yardId: row.yardId,
|
||||||
yardCode: row.yardCode,
|
yardCode: row.yardCode,
|
||||||
yardLabel: row.yardLabel,
|
yardLabel: row.yardLabel,
|
||||||
hasFacility: Boolean(row.hasFacility),
|
hasFacility,
|
||||||
// No facility record means no warehouse, whatever the flag says.
|
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||||
hasWarehouse: Boolean(row.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. */
|
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||||
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||||
const rows: Array<{
|
const rows = await this.dataSource.query(
|
||||||
yardId: string;
|
`${this.SELECT}
|
||||||
yardCode: string | null;
|
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
|
||||||
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
|
|
||||||
ORDER BY y.display_order ASC, y.label ASC`,
|
ORDER BY y.display_order ASC, y.label ASC`,
|
||||||
);
|
);
|
||||||
return rows.map((r) => ({
|
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||||
yardId: r.yardId,
|
}
|
||||||
yardCode: r.yardCode,
|
|
||||||
yardLabel: r.yardLabel,
|
/**
|
||||||
hasFacility: true,
|
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||||
hasWarehouse: Boolean(r.hasWarehouse),
|
* 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class SendMessageDto implements ISendSupportMessageDto {
|
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()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(4000)
|
@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 })
|
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||||
authorName?: string | null;
|
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 { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SupportAuthorRole,
|
||||||
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -8,14 +11,22 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
} from "./attachment-upload.options";
|
||||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||||
import { SendMessageDto } from "./dto/send-message.dto";
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversations/:id/messages")
|
@Get("conversations/:id/messages")
|
||||||
@ApiOperation({ summary: "List messages in a thread" })
|
@ApiOperation({
|
||||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
summary: "List messages in a thread (newest page first)",
|
||||||
return this.service.getMessages(id);
|
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")
|
@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(
|
send(
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() body: SendMessageDto,
|
@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")
|
@Post("conversations/:id/read")
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
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 {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} 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 { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
@@ -29,17 +46,55 @@ export class SupportChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversation/messages")
|
@Get("conversation/messages")
|
||||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
@ApiOperation({
|
||||||
messages(@CurrentUser() user: AuthUserPayload) {
|
summary: "Messages in my company's support thread (newest page first)",
|
||||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
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")
|
@Post("conversation/messages")
|
||||||
@ApiOperation({
|
@UseInterceptors(
|
||||||
summary: "Send a message as the customer, opening the thread if needed",
|
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) {
|
@ApiOperation({
|
||||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
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")
|
@Post("conversation/read")
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { SupportAttachmentController } from "./support-attachment.controller";
|
||||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||||
import { SupportChatController } from "./support-chat.controller";
|
import { SupportChatController } from "./support-chat.controller";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// WsAuthService — reused handshake authentication for the gateway.
|
// WsAuthService — reused handshake authentication for the gateway.
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
// FilesService — chat attachments are stored as polymorphic file records.
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
SupportChatController,
|
||||||
|
SupportChatAgentController,
|
||||||
|
SupportAttachmentController,
|
||||||
],
|
],
|
||||||
controllers: [SupportChatController, SupportChatAgentController],
|
|
||||||
providers: [
|
providers: [
|
||||||
SupportConversationRepository,
|
SupportConversationRepository,
|
||||||
SupportMessageRepository,
|
SupportMessageRepository,
|
||||||
SupportChatGateway,
|
SupportChatGateway,
|
||||||
SupportChatService,
|
SupportChatService,
|
||||||
],
|
],
|
||||||
|
// FilesController's ownership check for `support_message` files defers to this
|
||||||
|
// service — see SupportAttachmentAccess.
|
||||||
|
exports: [SupportChatService],
|
||||||
})
|
})
|
||||||
export class SupportChatModule {}
|
export class SupportChatModule {}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
|
isSupportAttachmentAllowed,
|
||||||
SendSupportMessageResult,
|
SendSupportMessageResult,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SUPPORT_ATTACHMENT_RESOURCE,
|
||||||
|
SupportAttachmentDto,
|
||||||
SupportAuthorRole,
|
SupportAuthorRole,
|
||||||
SupportConversationDto,
|
SupportConversationDto,
|
||||||
SupportConversationListResult,
|
SupportConversationListResult,
|
||||||
SupportMessageDto,
|
SupportMessageDto,
|
||||||
|
SupportMessageListResult,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { QueryFailedError } from "typeorm";
|
import { QueryFailedError } from "typeorm";
|
||||||
|
|
||||||
|
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
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 { 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 { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||||
import { SupportMessageRepository } from "./support-message.repository";
|
import { SupportMessageRepository } from "./support-message.repository";
|
||||||
@@ -30,6 +45,9 @@ interface CustomerContext {
|
|||||||
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||||
const PG_UNIQUE_VIOLATION = "23505";
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
/** Stand-in preview for a message that is nothing but files. */
|
||||||
|
const ATTACHMENT_ONLY_PREVIEW = "📎";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportChatService {
|
export class SupportChatService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -38,6 +56,8 @@ export class SupportChatService {
|
|||||||
private readonly gateway: SupportChatGateway,
|
private readonly gateway: SupportChatGateway,
|
||||||
private readonly externalProfiles: ExternalProfileRepository,
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly files: FilesService,
|
||||||
|
private readonly backoffice: BackofficeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---- customer (portal) -------------------------------------------------
|
// ---- customer (portal) -------------------------------------------------
|
||||||
@@ -51,7 +71,9 @@ export class SupportChatService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SupportConversationDto | null> {
|
): Promise<SupportConversationDto | null> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
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;
|
if (!conversation) return null;
|
||||||
const unread = await this.messages.unreadCountsByConversation(
|
const unread = await this.messages.unreadCountsByConversation(
|
||||||
[conversation.id],
|
[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 ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
if (!conversation) return [];
|
ctx.companyId,
|
||||||
return this.listMessages(conversation.id);
|
);
|
||||||
|
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. */
|
/** Send as the customer, opening the thread if this is the first message. */
|
||||||
async sendAsCustomer(
|
async sendAsCustomer(
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SendSupportMessageResult> {
|
): Promise<SendSupportMessageResult> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.getOrCreate(
|
const conversation = await this.getOrCreate(
|
||||||
@@ -87,16 +115,19 @@ export class SupportChatService {
|
|||||||
SupportAuthorRole.CUSTOMER,
|
SupportAuthorRole.CUSTOMER,
|
||||||
body,
|
body,
|
||||||
ctx.authorName,
|
ctx.authorName,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
conversation: this.toConversationDto(updated, 0),
|
conversation: this.toConversationDto(updated, 0),
|
||||||
message: this.toMessageDto(message),
|
message: message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
|
ctx.companyId,
|
||||||
|
);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
customerLastReadAt: new Date(),
|
customerLastReadAt: new Date(),
|
||||||
@@ -145,7 +176,8 @@ export class SupportChatService {
|
|||||||
async sendAsAgent(
|
async sendAsAgent(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SupportMessageDto> {
|
): Promise<SupportMessageDto> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
const { message } = await this.appendMessage(
|
const { message } = await this.appendMessage(
|
||||||
@@ -153,8 +185,10 @@ export class SupportChatService {
|
|||||||
userId,
|
userId,
|
||||||
SupportAuthorRole.AGENT,
|
SupportAuthorRole.AGENT,
|
||||||
body,
|
body,
|
||||||
|
undefined,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return this.toMessageDto(message);
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAgentRead(
|
async markAgentRead(
|
||||||
@@ -171,18 +205,56 @@ export class SupportChatService {
|
|||||||
// ---- shared ------------------------------------------------------------
|
// ---- shared ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
|
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
|
||||||
* company owns it (portal route); omit for agents, who see every thread.
|
* to enforce that the caller's company owns it (portal route); omit for
|
||||||
|
* agents, who see every thread.
|
||||||
*/
|
*/
|
||||||
async getMessages(
|
async getMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
query: ListMessagesQueryDto = {},
|
||||||
asCustomerUserId?: string,
|
asCustomerUserId?: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
): Promise<SupportMessageListResult> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
if (asCustomerUserId) {
|
if (asCustomerUserId) {
|
||||||
await this.assertCustomerOwns(conversation, 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(
|
async unreadCount(
|
||||||
@@ -237,29 +309,86 @@ export class SupportChatService {
|
|||||||
|
|
||||||
private async listMessages(
|
private async listMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
query: ListMessagesQueryDto,
|
||||||
const rows = await this.messages.listByConversation(conversationId);
|
): Promise<SupportMessageListResult> {
|
||||||
return rows.map((m) => this.toMessageDto(m));
|
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(
|
private async appendMessage(
|
||||||
conversation: SupportConversation,
|
conversation: SupportConversation,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: SupportAuthorRole,
|
role: SupportAuthorRole,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
authorName?: string | null,
|
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({
|
const message = await this.messages.create({
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
authorUserId: userId,
|
authorUserId: userId,
|
||||||
authorRole: role,
|
authorRole: role,
|
||||||
authorName: authorName ?? null,
|
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.lastMessageAt = message.createdAt;
|
||||||
conversation.lastMessagePreview = body.slice(0, 280);
|
conversation.lastMessagePreview = this.buildPreview(text, stored);
|
||||||
conversation.lastMessageAuthorRole = role;
|
conversation.lastMessageAuthorRole = role;
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
lastMessageAt: conversation.lastMessageAt,
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
@@ -267,13 +396,55 @@ export class SupportChatService {
|
|||||||
lastMessageAuthorRole: role,
|
lastMessageAuthorRole: role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const messageDto = await this.toMessageDto(message, stored);
|
||||||
const dto = this.toConversationDto(conversation, 0);
|
const dto = this.toConversationDto(conversation, 0);
|
||||||
this.gateway.emitMessage(
|
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
|
||||||
conversation.companyId,
|
return { conversation, message: messageDto };
|
||||||
dto,
|
}
|
||||||
this.toMessageDto(message),
|
|
||||||
);
|
/**
|
||||||
return { conversation, message };
|
* 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(
|
private async buildListResult(
|
||||||
@@ -320,7 +491,9 @@ export class SupportChatService {
|
|||||||
): Promise<CustomerContext> {
|
): Promise<CustomerContext> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
if (conversation.companyId !== ctx.companyId) {
|
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;
|
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 {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
conversationId: m.conversationId,
|
conversationId: m.conversationId,
|
||||||
authorUserId: m.authorUserId,
|
authorUserId: m.authorUserId,
|
||||||
authorRole: m.authorRole,
|
authorRole: m.authorRole,
|
||||||
authorName: m.authorName ?? null,
|
authorName: m.authorName ?? null,
|
||||||
body: m.body,
|
body: m.body ?? "",
|
||||||
|
attachments: attachments.map((a) => this.toAttachmentDto(a)),
|
||||||
createdAt: new Date(m.createdAt).toISOString(),
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<SupportMessage> {
|
|||||||
super(repo);
|
super(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All messages of a conversation, oldest first. */
|
/**
|
||||||
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
|
* One page of a thread, walking backwards from newest.
|
||||||
return this.repository.find({
|
*
|
||||||
where: { conversationId },
|
* Returns **newest-first** and takes one row more than asked, so the caller
|
||||||
order: { createdAt: "ASC" },
|
* can tell "there is another page" from "this page happened to be full"
|
||||||
});
|
* without a second COUNT. The caller trims the probe row and flips the page
|
||||||
|
* to oldest-first for rendering.
|
||||||
|
*
|
||||||
|
* Rides IDX_SUPPORT_MSG_CONV_CREATED (conversation_id, created_at); the id in
|
||||||
|
* the keyset is a tiebreak only and doesn't need its own index.
|
||||||
|
*/
|
||||||
|
async listByConversation(
|
||||||
|
conversationId: string,
|
||||||
|
limit: number,
|
||||||
|
beforeId?: string,
|
||||||
|
): Promise<SupportMessage[]> {
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder("m")
|
||||||
|
.where("m.conversation_id = :conversationId", { conversationId })
|
||||||
|
// createQueryBuilder bypasses TypeORM's soft-delete filter, unlike find().
|
||||||
|
.andWhere("m.deleted_at IS NULL");
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
// Row-value comparison: strictly older than the cursor row in
|
||||||
|
// (created_at, id) order.
|
||||||
|
//
|
||||||
|
// The cursor's timestamp is read back from the row itself rather than
|
||||||
|
// passed in. `created_at` is timestamptz(6) but a JS Date only holds
|
||||||
|
// milliseconds, so a timestamp that made the round-trip through the API
|
||||||
|
// would arrive truncated — and `.254100 < .254000` is false, so every row
|
||||||
|
// sharing the cursor's millisecond but earlier within it would be skipped
|
||||||
|
// on every page, permanently. Postgres compares the stored values at full
|
||||||
|
// precision instead.
|
||||||
|
qb.andWhere(
|
||||||
|
`(m.created_at, m.id) < (
|
||||||
|
SELECT c.created_at, c.id
|
||||||
|
FROM freight.support_messages c
|
||||||
|
WHERE c.id = :cursorId
|
||||||
|
)`,
|
||||||
|
{ cursorId: beforeId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb
|
||||||
|
.orderBy("m.created_at", "DESC")
|
||||||
|
.addOrderBy("m.id", "DESC")
|
||||||
|
.take(limit + 1)
|
||||||
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
|||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
|
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||||
@@ -68,6 +69,9 @@ export class BookingJourneyService {
|
|||||||
}
|
}
|
||||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||||
|
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||||
|
// however it arrived and whatever it is allocated to.
|
||||||
|
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -352,10 +356,19 @@ export class BookingJourneyService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (booking.tradeDirection !== 'DOMESTIC') return;
|
if (booking.tradeDirection !== 'DOMESTIC') return;
|
||||||
const facility = await this.yardFacilities.facilityForYard(yardId);
|
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||||
|
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
||||||
|
|
||||||
if (!facility?.hasFacility) {
|
if (!facility?.hasFacility) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
|
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
||||||
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
|
);
|
||||||
|
}
|
||||||
|
// A facility only handles what its equipment can lift: containers need a
|
||||||
|
// reach stacker/gantry, bulk does not.
|
||||||
|
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
||||||
|
`an intercity booking cannot be ${where} here.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
|
import { RecordCheckpointDto } from './record-checkpoint.dto';
|
||||||
|
|
||||||
|
const validateBody = (body: Record<string, unknown>) =>
|
||||||
|
validate(plainToInstance(RecordCheckpointDto, body));
|
||||||
|
|
||||||
|
describe('RecordCheckpointDto', () => {
|
||||||
|
// The final checkpoint arrives the schedule, so a backdated one rewrites the
|
||||||
|
// journey after the fact. No UI sends occurredAt; the endpoint still accepts it.
|
||||||
|
it('rejects a backdated occurredAt', async () => {
|
||||||
|
const errors = await validateBody({
|
||||||
|
sequenceNo: 3,
|
||||||
|
occurredAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toBe('occurredAt');
|
||||||
|
expect(errors[0].constraints).toHaveProperty('IsNotBackdated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts occurredAt of now', async () => {
|
||||||
|
const errors = await validateBody({
|
||||||
|
sequenceNo: 3,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a body that omits occurredAt, leaving the service to stamp it', async () => {
|
||||||
|
const errors = await validateBody({ sequenceNo: 0 });
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||||
|
|
||||||
export class RecordCheckpointDto {
|
export class RecordCheckpointDto {
|
||||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
|
|||||||
@IsEnum(TrainCheckpointKind)
|
@IsEnum(TrainCheckpointKind)
|
||||||
kind?: TrainCheckpointKind;
|
kind?: TrainCheckpointKind;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
/**
|
||||||
|
* A checkpoint records where the train is as staff observe it, and the final
|
||||||
|
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||||
|
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@IsNotBackdated()
|
||||||
occurredAt?: string;
|
occurredAt?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
|
|||||||
@@ -67,10 +67,18 @@ export class IntercityService {
|
|||||||
ts.status AS "scheduleStatus",
|
ts.status AS "scheduleStatus",
|
||||||
oy.id AS "originYardId",
|
oy.id AS "originYardId",
|
||||||
COALESCE(oy.label, oy.code) AS "origin",
|
COALESCE(oy.label, oy.code) AS "origin",
|
||||||
oy.has_facility AS "originHasFacility",
|
-- Can that end actually handle THIS booking's cargo? A container
|
||||||
|
-- booking needs a facility with a stacker; bulk needs any facility.
|
||||||
|
(oy.has_facility AND COALESCE(
|
||||||
|
CASE WHEN b.freight_type = 'CONTAINER'
|
||||||
|
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
|
||||||
|
AS "originHasFacility",
|
||||||
dy.id AS "destinationYardId",
|
dy.id AS "destinationYardId",
|
||||||
COALESCE(dy.label, dy.code) AS "destination",
|
COALESCE(dy.label, dy.code) AS "destination",
|
||||||
dy.has_facility AS "destinationHasFacility",
|
(dy.has_facility AND COALESCE(
|
||||||
|
CASE WHEN b.freight_type = 'CONTAINER'
|
||||||
|
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
|
||||||
|
AS "destinationHasFacility",
|
||||||
-- Where the train actually is, so the operator knows if the cargo
|
-- Where the train actually is, so the operator knows if the cargo
|
||||||
-- can be worked right now.
|
-- can be worked right now.
|
||||||
cp.yard_id AS "trainAtYardId",
|
cp.yard_id AS "trainAtYardId",
|
||||||
@@ -80,6 +88,10 @@ export class IntercityService {
|
|||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
|
LEFT JOIN freight.yard_facilities ofac
|
||||||
|
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
|
||||||
|
LEFT JOIN freight.yard_facilities dfac
|
||||||
|
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
|
||||||
LEFT JOIN freight.train_schedules ts
|
LEFT JOIN freight.train_schedules ts
|
||||||
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
|
|||||||
@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('marshalling documents', () => {
|
||||||
|
// Staff check these against the physical consist, so every wagon on the
|
||||||
|
// train set has to appear — an empty wagon that renders no row reads as a
|
||||||
|
// wagon that is not on the train.
|
||||||
|
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||||
|
sequenceNo,
|
||||||
|
wagonNumber,
|
||||||
|
physicalWagon: { wagonNumber },
|
||||||
|
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||||
|
lengthMeters: 14,
|
||||||
|
capacityTons: 70,
|
||||||
|
allocations,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadedAllocation = {
|
||||||
|
bookingId: 'booking-1',
|
||||||
|
bookingReference: 'BK-2026-000001',
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
allocatedWeightTons: 24.5,
|
||||||
|
containerNumbers: ['CONT-001'],
|
||||||
|
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
|
||||||
|
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
|
||||||
|
|
||||||
|
it('lists an empty wagon on the export document and marks it EMPTY', () => {
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
trainSet: {
|
||||||
|
wagons: [
|
||||||
|
makeWagon(1, 'W-001', [loadedAllocation]),
|
||||||
|
makeWagon(2, 'W-002', []),
|
||||||
|
makeWagon(3, 'W-003', []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = (service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml(schedule);
|
||||||
|
|
||||||
|
expect(countRows(html)).toBe(3);
|
||||||
|
expect(html).toContain('W-002');
|
||||||
|
expect(html).toContain('W-003');
|
||||||
|
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
|
||||||
|
// The wagon count must agree with the rows the reader can see.
|
||||||
|
expect(html).toContain('3 (2 empty)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists an empty wagon on the import document and marks it EMPTY', () => {
|
||||||
|
const loadList = {
|
||||||
|
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||||
|
trainScheduleId: 'schedule-1',
|
||||||
|
trainNumber: '8002',
|
||||||
|
route: 'Djibouti → Indode',
|
||||||
|
origin: 'Djibouti Port',
|
||||||
|
destination: 'Indode',
|
||||||
|
totalBookings: 1,
|
||||||
|
wagons: [
|
||||||
|
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
|
||||||
|
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
|
||||||
|
],
|
||||||
|
operation: { status: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = (service as never as {
|
||||||
|
buildImportLoadListHtml: (l: unknown) => string;
|
||||||
|
}).buildImportLoadListHtml(loadList);
|
||||||
|
|
||||||
|
expect(countRows(html)).toBe(2);
|
||||||
|
expect(html).toContain('W-002');
|
||||||
|
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||||
|
expect(html).toContain('2 (1 empty)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
trainSet: {
|
||||||
|
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
|
||||||
|
},
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = (service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml(schedule);
|
||||||
|
|
||||||
|
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
|
||||||
|
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the empty-count suffix when every wagon is loaded', () => {
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = (service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml(schedule);
|
||||||
|
|
||||||
|
expect(html).not.toContain('empty)');
|
||||||
|
expect(html).not.toContain('EMPTY');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2632,6 +2632,11 @@ export class TrainSchedulingService {
|
|||||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||||
if (wagonAssignedIds.size) {
|
if (wagonAssignedIds.size) {
|
||||||
|
// Export cargo must be received at the warehouse with a GRN before it can
|
||||||
|
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||||
|
if (this.isExportSchedule(schedule)) {
|
||||||
|
await this.assertExportBookingsReceived([...wagonAssignedIds]);
|
||||||
|
}
|
||||||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
[...wagonAssignedIds],
|
[...wagonAssignedIds],
|
||||||
@@ -2686,19 +2691,24 @@ export class TrainSchedulingService {
|
|||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
// Every wagon on the train set, loaded or not, in consist order. An empty
|
||||||
sequenceNo: wagon.sequenceNo,
|
// wagon has an empty `allocations` array — it is still part of the train
|
||||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
// and still belongs on the marshalling document.
|
||||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
wagons: [...(schedule.trainSet?.wagons ?? [])]
|
||||||
bookingId: allocation.bookingId,
|
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
|
||||||
bookingReference: allocation.booking?.reference ?? null,
|
.map((wagon) => ({
|
||||||
loadType: allocation.loadType ?? null,
|
sequenceNo: wagon.sequenceNo,
|
||||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||||
containerNumbers: (allocation.containerItems ?? [])
|
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||||
.map((item) => item.containerNumber)
|
bookingId: allocation.bookingId,
|
||||||
.filter(Boolean),
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
|
loadType: allocation.loadType ?? null,
|
||||||
|
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||||
|
containerNumbers: (allocation.containerItems ?? [])
|
||||||
|
.map((item) => item.containerNumber)
|
||||||
|
.filter(Boolean),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
|
||||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2748,9 +2758,33 @@ export class TrainSchedulingService {
|
|||||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||||
const rows = (schedule.trainSet?.wagons ?? [])
|
// The document is checked against the physical train, so it has to run in
|
||||||
.flatMap((wagon) =>
|
// consist order — the relation comes back unordered.
|
||||||
(wagon.allocations ?? []).map((allocation) => {
|
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||||
|
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||||
|
);
|
||||||
|
const rows = wagons
|
||||||
|
.flatMap((wagon) => {
|
||||||
|
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||||
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
|
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||||
|
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
||||||
|
const allocations = wagon.allocations ?? [];
|
||||||
|
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||||
|
// check this document against the physical train — a wagon with no row
|
||||||
|
// reads as a wagon that is not there, and the count stops matching.
|
||||||
|
if (allocations.length === 0) {
|
||||||
|
return [
|
||||||
|
`<tr class="empty">
|
||||||
|
${wagonCells}
|
||||||
|
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||||
|
</tr>`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return allocations.map((allocation) => {
|
||||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
@@ -2760,12 +2794,7 @@ export class TrainSchedulingService {
|
|||||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${esc(wagon.sequenceNo)}</td>
|
${wagonCells}
|
||||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
|
||||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
|
||||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
|
||||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
|
||||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
|
||||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||||
<td>${esc(booking?.companyId)}</td>
|
<td>${esc(booking?.companyId)}</td>
|
||||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||||
@@ -2773,10 +2802,11 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(chassisNumbers)}</td>
|
<td>${esc(chassisNumbers)}</td>
|
||||||
<td>${esc(sealNumbers)}</td>
|
<td>${esc(sealNumbers)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}),
|
});
|
||||||
)
|
})
|
||||||
.join('');
|
.join('');
|
||||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||||
|
const totalWeight = wagons.reduce(
|
||||||
(sum, wagon) =>
|
(sum, wagon) =>
|
||||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
0,
|
0,
|
||||||
@@ -2804,6 +2834,8 @@ export class TrainSchedulingService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||||
|
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||||
@@ -2831,7 +2863,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||||
@@ -2855,7 +2887,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -2882,6 +2914,38 @@ export class TrainSchedulingService {
|
|||||||
return direction === 'EXPORT';
|
return direction === 'EXPORT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every export booking being confirmed loaded must already be received at the
|
||||||
|
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||||
|
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||||
|
*/
|
||||||
|
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||||
|
if (!bookingIds.length) return;
|
||||||
|
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT b.reference
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.id = ANY($1)
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.warehouse_inventory inv
|
||||||
|
WHERE inv.booking_id = b.id
|
||||||
|
AND inv.deleted_at IS NULL
|
||||||
|
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
|
||||||
|
AND COALESCE(
|
||||||
|
NULLIF(TRIM(inv.grn_number), ''),
|
||||||
|
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||||
|
) IS NOT NULL
|
||||||
|
)`,
|
||||||
|
[bookingIds],
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
@@ -2898,19 +2962,31 @@ export class TrainSchedulingService {
|
|||||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||||
const allocationRows = loadList.wagons
|
const allocationRows = loadList.wagons
|
||||||
.flatMap((wagon) =>
|
.flatMap((wagon) => {
|
||||||
wagon.allocations.map(
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
|
<td>${esc(wagon.wagonNumber)}</td>`;
|
||||||
|
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||||
|
// buildExportLoadListHtml.
|
||||||
|
if (wagon.allocations.length === 0) {
|
||||||
|
return [
|
||||||
|
`<tr class="empty">
|
||||||
|
${wagonCells}
|
||||||
|
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||||
|
</tr>`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return wagon.allocations.map(
|
||||||
(allocation) => `<tr>
|
(allocation) => `<tr>
|
||||||
<td>${esc(wagon.sequenceNo)}</td>
|
${wagonCells}
|
||||||
<td>${esc(wagon.wagonNumber)}</td>
|
|
||||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||||
<td>${esc(allocation.loadType)}</td>
|
<td>${esc(allocation.loadType)}</td>
|
||||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
),
|
);
|
||||||
)
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -2942,6 +3018,8 @@ export class TrainSchedulingService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||||
|
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||||
@@ -2968,7 +3046,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||||
@@ -2996,7 +3074,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export cargo is received into the warehouse to wait for its train, and only a
|
||||||
|
* paid booking may be received — otherwise storage and a GRN would start against
|
||||||
|
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
|
||||||
|
* train and its receive is the unload.
|
||||||
|
*
|
||||||
|
* The guard touches only the DataSource, so the instance is built off the
|
||||||
|
* prototype rather than stubbing all 20-odd collaborators.
|
||||||
|
*/
|
||||||
|
type Guard = (
|
||||||
|
bookingId: string | null | undefined,
|
||||||
|
direction: string | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
function makeGuard(paymentStatus: string | null) {
|
||||||
|
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.dataSource = { query };
|
||||||
|
const guard = (
|
||||||
|
service as unknown as { assertExportBookingPaid: Guard }
|
||||||
|
).assertExportBookingPaid.bind(service);
|
||||||
|
return { guard, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('receive() — export paid gate', () => {
|
||||||
|
it('rejects an unpaid export booking', async () => {
|
||||||
|
const { guard } = makeGuard('PENDING');
|
||||||
|
|
||||||
|
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a paid export booking', async () => {
|
||||||
|
const { guard } = makeGuard('PAID');
|
||||||
|
|
||||||
|
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never blocks import, paid or not', async () => {
|
||||||
|
const { guard, query } = makeGuard('PENDING');
|
||||||
|
|
||||||
|
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a receive with no booking attached', async () => {
|
||||||
|
const { guard, query } = makeGuard('PENDING');
|
||||||
|
|
||||||
|
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
import type { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GRN is the receipt for cargo entering the warehouse, so unloadBooking must
|
||||||
|
* issue one for every direction — import as well as export. It used to mint only
|
||||||
|
* for export, leaving import cargo received with no GRN.
|
||||||
|
*/
|
||||||
|
function makeService(opts: {
|
||||||
|
tradeDirection: string | null;
|
||||||
|
existing?: { id: string; grnNumber: string | null };
|
||||||
|
}) {
|
||||||
|
const created: Record<string, unknown>[] = [];
|
||||||
|
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
const inventoryRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []),
|
||||||
|
update: jest.fn((id: string, patch: Record<string, unknown>) => {
|
||||||
|
updated.push({ id, patch });
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
create: jest.fn((row: Record<string, unknown>) => {
|
||||||
|
created.push(row);
|
||||||
|
return Promise.resolve({ id: 'new-inv', ...row });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.inventoryRepository = inventoryRepository;
|
||||||
|
service.dataSource = {
|
||||||
|
query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]),
|
||||||
|
};
|
||||||
|
// Location comes straight from the dto in these cases, so pickDefaultLocation
|
||||||
|
// is never reached; findById just echoes what was written.
|
||||||
|
service.findById = jest.fn((id: string) =>
|
||||||
|
Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dto: UnloadBookingDto = {
|
||||||
|
warehouseId: 'w1',
|
||||||
|
yardId: 'y1',
|
||||||
|
zoneId: 'z1',
|
||||||
|
} as UnloadBookingDto;
|
||||||
|
|
||||||
|
return { service: service as unknown as WarehouseInventoryService, dto, created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('unloadBooking — GRN issuance', () => {
|
||||||
|
it('issues an IMPORT GRN when unloading a fresh import booking', async () => {
|
||||||
|
const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' });
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still issues an EXPORT GRN', async () => {
|
||||||
|
const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' });
|
||||||
|
|
||||||
|
await service.unloadBooking('b-export', dto);
|
||||||
|
|
||||||
|
expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a GRN for an existing import row that has none', async () => {
|
||||||
|
const { service, dto, updated } = makeService({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
existing: { id: 'inv-1', grnNumber: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reissue when the row already has a GRN', async () => {
|
||||||
|
const { service, dto, updated } = makeService({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(updated[0].patch).not.toHaveProperty('grnNumber');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,6 +69,15 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.opsStats();
|
return this.inventoryService.opsStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('trucks-on-site')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
|
||||||
|
})
|
||||||
|
trucksOnSite() {
|
||||||
|
return this.inventoryService.trucksOnSite();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('zone-occupancy')
|
@Get('zone-occupancy')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||||
|
|||||||
@@ -410,6 +410,83 @@ export class WarehouseInventoryService {
|
|||||||
* - trucksOnSite: customer trucks arrived but not departed
|
* - trucksOnSite: customer trucks arrived but not departed
|
||||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every truck currently inside the yard, across all bookings — the list behind
|
||||||
|
* the `trucksOnSite` figure on the ops dashboard, which until now could only
|
||||||
|
* be counted and never opened.
|
||||||
|
*
|
||||||
|
* Covers both haulage paths because the gate does: a customer's own truck and
|
||||||
|
* an EDR last-mile truck arrive at the same barrier and need the same paper.
|
||||||
|
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
|
||||||
|
* what is coming as well as what is here — an assigned truck only stamps
|
||||||
|
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
|
||||||
|
* it departs.
|
||||||
|
*/
|
||||||
|
async trucksOnSite(): Promise<
|
||||||
|
Array<{
|
||||||
|
source: 'CUSTOMER' | 'EDR';
|
||||||
|
assignmentId: string;
|
||||||
|
status: 'INBOUND' | 'ON_SITE';
|
||||||
|
plateNumber: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
truckType: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
containers: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT 'CUSTOMER' AS "source",
|
||||||
|
a.id AS "assignmentId",
|
||||||
|
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||||
|
a.plate_number AS "plateNumber",
|
||||||
|
a.driver_name AS "driverName",
|
||||||
|
a.truck_type AS "truckType",
|
||||||
|
a.arrived_at AS "arrivedAt",
|
||||||
|
b.id AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customerName",
|
||||||
|
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
|
||||||
|
FROM freight.customer_truck_containers c
|
||||||
|
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
WHERE a.deleted_at IS NULL
|
||||||
|
AND a.departed_at IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 'EDR' AS "source",
|
||||||
|
va.id AS "assignmentId",
|
||||||
|
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||||
|
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
|
||||||
|
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
|
||||||
|
v.vehicle_type AS "truckType",
|
||||||
|
va.arrived_at AS "arrivedAt",
|
||||||
|
b.id AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customerName",
|
||||||
|
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
|
||||||
|
FROM freight.last_mile_vehicle_containers lvc
|
||||||
|
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||||
|
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
WHERE va.deleted_at IS NULL
|
||||||
|
AND va.departed_at IS NULL
|
||||||
|
|
||||||
|
-- On-site trucks first, each group oldest-arrival first; inbound trucks
|
||||||
|
-- (null arrival) sort to the end.
|
||||||
|
ORDER BY "arrivedAt" ASC NULLS LAST`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async opsStats(): Promise<{
|
async opsStats(): Promise<{
|
||||||
receivedToday: number;
|
receivedToday: number;
|
||||||
receivedYesterday: number;
|
receivedYesterday: number;
|
||||||
@@ -1067,14 +1144,15 @@ export class WarehouseInventoryService {
|
|||||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||||
// a train without one. Import GRN handling is left untouched.
|
// gets one on unload — import as well as export. The direction only decides
|
||||||
|
// the GRN prefix, not whether one is issued.
|
||||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||||
`SELECT trade_direction AS "tradeDirection"
|
`SELECT trade_direction AS "tradeDirection"
|
||||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||||
|
|
||||||
let location: DefaultLocation | null =
|
let location: DefaultLocation | null =
|
||||||
dto.warehouseId && dto.yardId && dto.zoneId
|
dto.warehouseId && dto.yardId && dto.zoneId
|
||||||
@@ -1095,10 +1173,10 @@ export class WarehouseInventoryService {
|
|||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||||
...(isExport && !existing[0].grnNumber
|
...(existing[0].grnNumber
|
||||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
? {}
|
||||||
: {}),
|
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(existing[0].id);
|
return this.findById(existing[0].id);
|
||||||
@@ -1113,9 +1191,7 @@ export class WarehouseInventoryService {
|
|||||||
weight: 0,
|
weight: 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
...(isExport
|
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
|
||||||
: {}),
|
|
||||||
notes: dto.notes ?? 'Unloaded',
|
notes: dto.notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(saved.id);
|
return this.findById(saved.id);
|
||||||
@@ -1202,8 +1278,18 @@ export class WarehouseInventoryService {
|
|||||||
driver.phone_number AS "firstMileDriverPhone",
|
driver.phone_number AS "firstMileDriverPhone",
|
||||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||||
v.vehicle_type AS "firstMileTruckType",
|
v.vehicle_type AS "firstMileTruckType",
|
||||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
-- Multi-truck self-haul writes plates/drivers to
|
||||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
-- customer_truck_assignments and leaves the booking columns null,
|
||||||
|
-- so read the assignments first and keep the legacy column as the
|
||||||
|
-- fallback for single-truck bookings written before that table.
|
||||||
|
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||||
|
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||||
@@ -1334,8 +1420,14 @@ export class WarehouseInventoryService {
|
|||||||
driver.phone_number AS "firstMileDriverPhone",
|
driver.phone_number AS "firstMileDriverPhone",
|
||||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||||
v.vehicle_type AS "firstMileTruckType",
|
v.vehicle_type AS "firstMileTruckType",
|
||||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||||
|
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
@@ -1794,8 +1886,18 @@ export class WarehouseInventoryService {
|
|||||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
-- Multi-truck self-haul writes plates/drivers to
|
||||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
-- customer_truck_assignments and leaves the booking columns null,
|
||||||
|
-- so read the assignments first and keep the legacy column as the
|
||||||
|
-- fallback for single-truck bookings written before that table.
|
||||||
|
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||||
|
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
|
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
@@ -2454,6 +2556,7 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||||
|
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||||
|
|
||||||
const id = await this.dataSource.transaction(async (manager) => {
|
const id = await this.dataSource.transaction(async (manager) => {
|
||||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||||
@@ -2978,6 +3081,31 @@ export class WarehouseInventoryService {
|
|||||||
netTons,
|
netTons,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
// Customer self-haul: the same exit record on the customer's own truck.
|
||||||
|
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||||
|
// summed to zero and the booking could take unlimited trucks. Matched by
|
||||||
|
// plate rather than container so bulk trucks (which carry none) count.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.customer_truck_assignments a
|
||||||
|
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||||
|
arrived_at = COALESCE(a.arrived_at, NOW()),
|
||||||
|
gross_weight_kg = $4,
|
||||||
|
tare_weight_tons = $5,
|
||||||
|
net_weight_tons = $6,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE a.booking_id = $1
|
||||||
|
AND UPPER(a.plate_number) = UPPER($2)
|
||||||
|
AND a.departed_at IS NULL
|
||||||
|
AND a.deleted_at IS NULL`,
|
||||||
|
[
|
||||||
|
item.bookingId,
|
||||||
|
dto.truckPlateNumber.trim(),
|
||||||
|
dto.gateOutTime ?? null,
|
||||||
|
grossTons,
|
||||||
|
tareTons,
|
||||||
|
netTons,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
@@ -5527,6 +5655,31 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export cargo is received into the warehouse to wait for its train, and it is
|
||||||
|
* received only once the booking is paid — receiving an unpaid export booking
|
||||||
|
* would start storage and mint a GRN against cargo the customer has not settled.
|
||||||
|
*
|
||||||
|
* Export only: import cargo arrives OFF a train and its receive is the unload,
|
||||||
|
* so gating that on payment would strand cargo already at the yard.
|
||||||
|
*/
|
||||||
|
private async assertExportBookingPaid(
|
||||||
|
bookingId: string | null | undefined,
|
||||||
|
direction: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!bookingId || direction !== 'EXPORT') return;
|
||||||
|
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT payment_status AS "paymentStatus"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private assertCapacity(
|
private assertCapacity(
|
||||||
label: string,
|
label: string,
|
||||||
node: LocationNode,
|
node: LocationNode,
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
|
|||||||
facilityType: 'DRY_PORT' as const,
|
facilityType: 'DRY_PORT' as const,
|
||||||
facilityStatus: 'ACTIVE' as const,
|
facilityStatus: 'ACTIVE' as const,
|
||||||
locationName: 'Indode',
|
locationName: 'Indode',
|
||||||
country: 'Djibouti',
|
// Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
|
||||||
city: 'Djibouti',
|
// KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
|
||||||
address: 'Indode, Djibouti',
|
// of the line. Coordinates are left unset rather than guessed; fill them in
|
||||||
latitude: 11.5447,
|
// when the real position is to hand.
|
||||||
longitude: 43.145,
|
country: 'Ethiopia',
|
||||||
|
city: 'Addis Ababa',
|
||||||
|
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
|
||||||
capacity: 50000,
|
capacity: 50000,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
notes: 'Primary dry port for container consolidation and distribution',
|
notes: 'Primary dry port for container consolidation and distribution',
|
||||||
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
|
|||||||
const yardRepo = manager.getRepository(WarehouseYard);
|
const yardRepo = manager.getRepository(WarehouseYard);
|
||||||
const zoneRepo = manager.getRepository(WarehouseZone);
|
const zoneRepo = manager.getRepository(WarehouseZone);
|
||||||
|
|
||||||
// Ensure facility exists
|
// Ensure facility exists. An existing facility row is not proof the
|
||||||
const facility = await facilityRepo.findOne({
|
// warehouses under it survived, so reuse it and carry on rather than
|
||||||
|
// returning — otherwise a facility with no warehouses stays that way.
|
||||||
|
const existing = await facilityRepo.findOne({
|
||||||
where: { code: INDODE_FACILITY.code },
|
where: { code: INDODE_FACILITY.code },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (facility) {
|
let savedFacility: Facility;
|
||||||
this.logger.log('Indode facility already exists, skipping seed');
|
if (existing) {
|
||||||
return;
|
savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY });
|
||||||
|
this.logger.log(`Facility ${savedFacility.code} already exists, reusing`);
|
||||||
|
} else {
|
||||||
|
savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY));
|
||||||
|
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFacility = facilityRepo.create(INDODE_FACILITY);
|
|
||||||
const savedFacility = await facilityRepo.save(newFacility);
|
|
||||||
this.logger.log(`Created facility: ${savedFacility.code}`);
|
|
||||||
|
|
||||||
// Create warehouses for the facility
|
// Create warehouses for the facility
|
||||||
for (const warehouseData of WAREHOUSES) {
|
for (const warehouseData of WAREHOUSES) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
|
|||||||
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
|
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
|
||||||
* settled which is the intercity facility.
|
* settled which is the intercity facility.
|
||||||
*/
|
*/
|
||||||
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
|
const FACILITY_YARDS: Array<{
|
||||||
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
|
code: string;
|
||||||
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
|
facility: string;
|
||||||
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
|
hasWarehouse: boolean;
|
||||||
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
|
handlesContainer: boolean;
|
||||||
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
|
}> = [
|
||||||
|
// Containers need a reach stacker or gantry — only these three are equipped.
|
||||||
|
// Bulk needs far less, so every facility handles it.
|
||||||
|
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
|
||||||
|
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
|
||||||
|
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
|
||||||
|
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
|
||||||
|
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
|
|||||||
* yards — a missing code is logged and skipped rather than invented.
|
* yards — a missing code is logged and skipped rather than invented.
|
||||||
*/
|
*/
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
|
for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
|
||||||
const [yard]: Array<{ id: string }> = await this.dataSource.query(
|
const [yard]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
|
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
|
||||||
[code],
|
[code],
|
||||||
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
|
`INSERT INTO freight.yard_facilities
|
||||||
VALUES ($1, $2, $3)
|
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
|
||||||
|
VALUES ($1, $2, $3, true, $4)
|
||||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
|
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
||||||
[yard.id, hasWarehouse, `${facility} load/unload facility`],
|
handles_container = EXCLUDED.handles_container,
|
||||||
|
handles_bulk = EXCLUDED.handles_bulk,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
|
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
@@ -124,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
|||||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||||
import IntercityPage from "./pages/warehouses/IntercityPage";
|
import IntercityPage from "./pages/warehouses/IntercityPage";
|
||||||
|
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
|
||||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||||
@@ -464,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/warehouse-dashboard",
|
href: "/dashboard/warehouse-dashboard",
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Yard-wide, not per-direction: the gate sees import and export
|
||||||
|
// trucks at the same barrier.
|
||||||
|
label: "Trucks on Site",
|
||||||
|
href: "/dashboard/trucks-on-site",
|
||||||
|
icon: <Truck />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Warehouses",
|
label: "Warehouses",
|
||||||
href: "/dashboard/warehouses",
|
href: "/dashboard/warehouses",
|
||||||
@@ -696,6 +705,7 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
<Route path="um/set-password" element={<SetPassword />} />
|
<Route path="um/set-password" element={<SetPassword />} />
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
@@ -958,6 +968,7 @@ const App = () => {
|
|||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
<Route path="intercity" element={<IntercityPage />} />
|
<Route path="intercity" element={<IntercityPage />} />
|
||||||
|
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { api } from "./http";
|
import { api } from "./http";
|
||||||
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
|
import type {
|
||||||
|
AuthTokens,
|
||||||
|
AuthUser,
|
||||||
|
ForgotPasswordRequestPayload,
|
||||||
|
ForgotPasswordVerifyPayload,
|
||||||
|
LoginResponse,
|
||||||
|
ResetTicket,
|
||||||
|
SetPasswordPayload,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const loginRequest = async (payload: {
|
export const loginRequest = async (payload: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
|
|||||||
const response = await api.get<AuthUser>("/me");
|
const response = await api.get<AuthUser>("/me");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The three calls below drive the unauthenticated forgot-password flow.
|
||||||
|
// Responses under /api/auth are *flattened* by the API's response
|
||||||
|
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||||
|
|
||||||
|
export const requestPasswordResetRequest = async (
|
||||||
|
payload: ForgotPasswordRequestPayload,
|
||||||
|
) => {
|
||||||
|
await api.post("/auth/forgot-password/request", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyPasswordResetOtpRequest = async (
|
||||||
|
payload: ForgotPasswordVerifyPayload,
|
||||||
|
) => {
|
||||||
|
const response = await api.post<ResetTicket>(
|
||||||
|
"/auth/forgot-password/verify",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
|
||||||
|
* Carries its own userId/verificationCode and never touches the session.
|
||||||
|
*/
|
||||||
|
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
|
||||||
|
await api.patch("/auth/set-password", payload);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import {
|
|
||||||
emitApiError,
|
|
||||||
extractApiErrorPayload,
|
|
||||||
} from "@/components/errors/ApiErrorModal";
|
|
||||||
import { captureApiError } from "@/lib/posthog";
|
import { captureApiError } from "@/lib/posthog";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
@@ -14,14 +11,16 @@ import {
|
|||||||
setCookie,
|
setCookie,
|
||||||
} from "./cookies";
|
} from "./cookies";
|
||||||
import type { AuthTokens } from "./types";
|
import type { AuthTokens } from "./types";
|
||||||
|
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
|
||||||
|
|
||||||
declare module "axios" {
|
declare module "axios" {
|
||||||
export interface AxiosRequestConfig {
|
export interface AxiosRequestConfig {
|
||||||
/**
|
/**
|
||||||
* When true, the response interceptor does NOT raise the global error modal
|
* When true, the response interceptor does NOT raise the global error
|
||||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
* toast for this request's failure. For calls the caller handles itself —
|
||||||
* probe that is expected to 404 before falling back (GL clearance detail
|
* e.g. a probe that is expected to 404 before falling back (GL clearance
|
||||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
* detail tries /contracts/:id then /bookings/:id). The rejection still
|
||||||
|
* propagates.
|
||||||
*/
|
*/
|
||||||
suppressErrorModal?: boolean;
|
suppressErrorModal?: boolean;
|
||||||
}
|
}
|
||||||
@@ -96,9 +95,8 @@ api.interceptors.response.use(
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config as RetriableRequest | undefined;
|
const originalRequest = error.config as RetriableRequest | undefined;
|
||||||
|
|
||||||
// Report the failure to PostHog. Hooked here rather than inside
|
// Report the failure to PostHog, including on suppressErrorModal paths —
|
||||||
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
// those opt out of the user-facing toast, not of reporting.
|
||||||
// mile / onboarding) — those failures still need reporting.
|
|
||||||
// 401s are skipped: an expired session is refreshed below, not a defect.
|
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||||
if (!error.response || error.response.status !== 401) {
|
if (!error.response || error.response.status !== 401) {
|
||||||
captureApiError(error);
|
captureApiError(error);
|
||||||
@@ -112,19 +110,25 @@ api.interceptors.response.use(
|
|||||||
originalRequest.url?.includes("/auth/mfa-verify") ||
|
originalRequest.url?.includes("/auth/mfa-verify") ||
|
||||||
originalRequest.url?.includes("/auth/refresh-token")
|
originalRequest.url?.includes("/auth/refresh-token")
|
||||||
) {
|
) {
|
||||||
// Surface the server's actual error message in the global error modal
|
// Surface the server's actual error message in a global toast — never
|
||||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
// the error modal (401s are handled by the session-refresh flow, so skip
|
||||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
// them). A request may opt out via `suppressErrorModal` when it handles
|
||||||
|
// the failure itself.
|
||||||
if (error.response && error.response.status !== 401) {
|
if (error.response && error.response.status !== 401) {
|
||||||
const payload = extractApiErrorPayload(error);
|
const payload = extractApiErrorPayload(error);
|
||||||
// Normalize the error's own `message` to the SERVER's actual message so
|
// Normalize the error's own `message` to the SERVER's actual message so
|
||||||
// every downstream `toast.error(err.message)` / MutationCache handler
|
// every downstream `toast.error(err.message)` handler shows the real
|
||||||
// shows the real cause instead of "Request failed with status code NNN".
|
// cause instead of "Request failed with status code NNN". Applies even
|
||||||
// Applies even on suppressErrorModal paths — only the modal is opted out.
|
// on suppressErrorModal paths — only the toast is opted out.
|
||||||
if (payload?.messages.length) {
|
if (payload?.messages.length) {
|
||||||
(error as { message?: string }).message = payload.messages.join("\n");
|
const message = payload.messages.join("\n");
|
||||||
|
(error as { message?: string }).message = message;
|
||||||
|
// Keyed by message so a retried request replaces its toast instead
|
||||||
|
// of stacking duplicates.
|
||||||
|
if (!originalRequest?.suppressErrorModal) {
|
||||||
|
toast.error(message, { id: message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
|||||||
mfaRequired?: boolean;
|
mfaRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequestPayload {
|
||||||
|
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||||
|
otp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||||
|
export interface ResetTicket {
|
||||||
|
userId: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordPayload {
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Additional types for Matrix form test
|
// Additional types for Matrix form test
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
|
||||||
|
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||||
|
|
||||||
|
export const OTP_LENGTH = 6;
|
||||||
|
|
||||||
|
export interface OtpChannelStepProps {
|
||||||
|
/**
|
||||||
|
* Raw contacts the code was sent to; masked before display. The API sends one
|
||||||
|
* code to every contact on the account, so both are usually set — pass only
|
||||||
|
* what the client actually knows. Omit both when the client cannot know them
|
||||||
|
* (the forgot-password flow deliberately never reveals an account's contacts)
|
||||||
|
* and a generic line is shown instead.
|
||||||
|
*/
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (otp: string) => void;
|
||||||
|
onVerify: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onResend: () => void;
|
||||||
|
/** Seconds until resend is allowed; 0 enables the button. */
|
||||||
|
resendIn: number;
|
||||||
|
sending: boolean;
|
||||||
|
verifying: boolean;
|
||||||
|
error: string | null;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
submitLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "enter the code we sent you" stage. Shared by signup and the
|
||||||
|
* forgot-password flow — both send through the same `/api/otp/*` service, which
|
||||||
|
* delivers a single code to the account's email AND phone; whichever message
|
||||||
|
* arrives first can be typed here.
|
||||||
|
*/
|
||||||
|
export default function OtpChannelStep({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onVerify,
|
||||||
|
onBack,
|
||||||
|
onResend,
|
||||||
|
resendIn,
|
||||||
|
sending,
|
||||||
|
verifying,
|
||||||
|
error,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
submitLabel,
|
||||||
|
}: OtpChannelStepProps) {
|
||||||
|
const maskedTargets = [
|
||||||
|
email ? maskEmail(email) : null,
|
||||||
|
phone ? maskPhone(phone) : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
const busy = sending || verifying;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className="mb-1 flex justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-center">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
{title ?? "Verify it's you"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||||
|
{maskedTargets.length ? (
|
||||||
|
maskedTargets.map((target, index) => (
|
||||||
|
<span key={target}>
|
||||||
|
{index > 0 ? " and " : null}
|
||||||
|
<span className="font-medium text-gray-700">{target}</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
the email and phone on your account
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
. {description ?? "Enter it to continue."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack gap={6} align="center">
|
||||||
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
|
Verification code
|
||||||
|
</Text>
|
||||||
|
<PinInput
|
||||||
|
length={OTP_LENGTH}
|
||||||
|
type="number"
|
||||||
|
oneTimeCode
|
||||||
|
value={value}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={verifying}
|
||||||
|
styles={{ input: { textAlign: "center" } }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
loading={verifying}
|
||||||
|
disabled={verifying || value.trim().length !== OTP_LENGTH}
|
||||||
|
onClick={onVerify}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<RotateCw size={14} />}
|
||||||
|
disabled={resendIn > 0 || busy}
|
||||||
|
onClick={onResend}
|
||||||
|
>
|
||||||
|
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { passwordRequirements } from "@/utils/passwordSchema";
|
||||||
|
|
||||||
|
export interface PasswordChecklistProps {
|
||||||
|
/** The current password value; the checklist hides itself when empty. */
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live pass/fail list of the password rules, shown under a password field. */
|
||||||
|
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{passwordRequirements.map((req) => {
|
||||||
|
const met = req.test(value);
|
||||||
|
return (
|
||||||
|
<div key={req.label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
met
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{met ? (
|
||||||
|
<Check className="h-2.5 w-2.5" />
|
||||||
|
) : (
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||||
|
{req.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user