mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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_INSECURE_TLS=false
|
||||
|
||||
# Public origin of the freight customer portal. Password-reset links sent to
|
||||
# customers are built against this — it must be browser-reachable.
|
||||
FREIGHT_PORTAL_URL=http://localhost:5173
|
||||
|
||||
# Portal pages the payment provider redirects the browser to after payment.
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
|
||||
@@ -44,6 +44,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
@@ -166,6 +167,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
DropdownSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
@@ -275,6 +277,9 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// await this.demoUsersSeeder.run();
|
||||
// await this.freightStaffUsersSeeder.run();
|
||||
// await this.pricingDataSeeder.run();
|
||||
// IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so
|
||||
// it will not recognise a hand-created Indode warehouse and will seed a second
|
||||
// one alongside it. Only enable it against an Indode that has no warehouse.
|
||||
// await this.indodeFacilitySeeder.run();
|
||||
// await this.batch14TestDataSeeder.run();
|
||||
// await this.batch5TestDataSeeder.run();
|
||||
|
||||
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",
|
||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||
apiPrefix: "api",
|
||||
/**
|
||||
* Public origin of the freight customer portal. Password-reset links mailed
|
||||
* or SMS'd to customers are built against this, so it must be the address the
|
||||
* customer's browser can actually reach — not an internal service name.
|
||||
*/
|
||||
portalBaseUrl: (
|
||||
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||
).replace(/\/+$/, ""),
|
||||
trainScheduling: {
|
||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Let a support message carry files instead of text.
|
||||
*
|
||||
* No new table: chat attachments reuse the polymorphic `freight.files` record
|
||||
* with `resource = 'support_message'` and `resource_id = <message id>`, the same
|
||||
* way bookings/contracts/companies already store theirs.
|
||||
*
|
||||
* The only schema change is dropping NOT NULL from `support_messages.body`, so
|
||||
* an attachment-only message can say "there is no text" rather than smuggling
|
||||
* that fact through an empty string. DROP NOT NULL is a catalog-only change in
|
||||
* Postgres — no table rewrite, no long lock — so this is safe on a live table.
|
||||
*
|
||||
* The partial index on (resource, resource_id) is what makes hydrating a page of
|
||||
* messages one indexed lookup instead of a scan of every file row in the system.
|
||||
*/
|
||||
export class SupportChatAttachments2320000000000 implements MigrationInterface {
|
||||
name = "SupportChatAttachments2320000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.support_messages
|
||||
ALTER COLUMN body DROP NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP"
|
||||
ON freight.files (resource, resource_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP"
|
||||
`);
|
||||
|
||||
// Re-imposing NOT NULL would fail on any attachment-only message written
|
||||
// while this migration was applied. Backfill those to '' first so the
|
||||
// rollback is deterministic rather than dependent on production data.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.support_messages SET body = '' WHERE body IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.support_messages
|
||||
ALTER COLUMN body SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A facility handles what its equipment can handle. Containers need a reach
|
||||
* stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs
|
||||
* far less, so all five facilities load and unload it.
|
||||
*
|
||||
* Both default true — a facility handles everything unless someone says
|
||||
* otherwise, which keeps existing rows working and makes the seeder the place
|
||||
* where the real capability is stated.
|
||||
*/
|
||||
export class YardFacilityFreightTypes2320000000000 implements MigrationInterface {
|
||||
name = 'YardFacilityFreightTypes2320000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yard_facilities
|
||||
ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yard_facilities
|
||||
DROP COLUMN IF EXISTS handles_container,
|
||||
DROP COLUMN IF EXISTS handles_bulk
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
@@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { CustomerResetService } from "./customer-reset.service";
|
||||
import {
|
||||
CustomerResetService,
|
||||
CustomerResetTarget,
|
||||
} from "./customer-reset.service";
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. The customer receives the code and sets their
|
||||
* own password — staff never see or handle a credential.
|
||||
* Staff-triggered password reset. The customer receives a single-use link and
|
||||
* sets their own password — staff never see or handle a credential.
|
||||
*/
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice/customers")
|
||||
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
|
||||
@Get(":companyId/reset-target")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "The primary contact's IAM account a reset link would be sent to",
|
||||
})
|
||||
async resetTarget(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CustomerResetTarget> {
|
||||
const target = await this.customerResetService.getResetTarget(companyId);
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException(
|
||||
"This customer has no active primary-contact account to reset",
|
||||
);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
@Post(":companyId/reset-password")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code to a customer's primary contact",
|
||||
summary: "Send a password-reset link to a customer's primary contact",
|
||||
})
|
||||
async resetPassword(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
||||
const sent = await this.customerResetService.sendResetLinkToCustomer(
|
||||
companyId,
|
||||
dto.channel,
|
||||
);
|
||||
|
||||
if (!maskedTarget) {
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
||||
);
|
||||
}
|
||||
|
||||
return { channel: dto.channel, maskedTarget };
|
||||
return sent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService } from "./forgot-password.service";
|
||||
import {
|
||||
ForgotPasswordService,
|
||||
RESET_LINK_TTL_MS,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface SentResetLink {
|
||||
channel: ResetChannel;
|
||||
maskedTarget: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerResetService {
|
||||
@@ -14,19 +35,110 @@ export class CustomerResetService {
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||
private readonly forgotPasswordService: ForgotPasswordService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a reset code to the company's primary contact. Returns the masked
|
||||
* destination, or null when there is no eligible account for that channel.
|
||||
* The IAM account a reset would actually reach. The backoffice shows these
|
||||
* values rather than `company.email` / `company.phone`: the company row holds
|
||||
* business contact detail, while the link is delivered to the primary
|
||||
* contact's own login credentials — the two drift apart routinely, and showing
|
||||
* the wrong one has staff telling customers to check an inbox nothing was sent
|
||||
* to.
|
||||
*/
|
||||
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { profile, user, userId } = resolved;
|
||||
return {
|
||||
userId,
|
||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||
email: user.email ?? null,
|
||||
phone: user.phoneNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a password-reset link and send it to the company's primary contact.
|
||||
* Returns the masked destination, or null when there is no eligible account
|
||||
* for that channel.
|
||||
*
|
||||
* Unlike the public flow this reports failure honestly — the caller is an
|
||||
* authenticated staff member, so there is nothing to enumerate.
|
||||
*/
|
||||
async sendResetToCustomer(
|
||||
async sendResetLinkToCustomer(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<string | null> {
|
||||
): Promise<SentResetLink | null> {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
const ticket = await this.forgotPasswordService.mintResetTicket(
|
||||
userId,
|
||||
RESET_LINK_TTL_MS,
|
||||
);
|
||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Reset your EDR Freight password",
|
||||
text:
|
||||
"A password reset was started for your EDR Freight account.\n\n" +
|
||||
`Open this link to choose a new password:\n${link}\n\n` +
|
||||
"The link expires in 24 hours and can only be used once. If you did " +
|
||||
"not expect this, ignore this message — your password stays unchanged.",
|
||||
})
|
||||
: await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
// The ticket is committed and the backoffice is about to say "link sent",
|
||||
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
|
||||
// are no-ops. Without this line the only symptom is a customer who never
|
||||
// receives anything, indistinguishable from carrier loss.
|
||||
this.logger.error(
|
||||
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — transport reported no hand-off; no link will arrive`,
|
||||
);
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Same
|
||||
// deliberate tradeoff the OTP service makes — this is the only way to
|
||||
// complete a reset on an environment with no broker. Only reached when
|
||||
// delivery already failed.
|
||||
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
maskedTarget: maskOtpTarget(target),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's primary contact, gated on the same active-account rule the
|
||||
* public flow uses — so a suspended customer cannot be reactivated by a
|
||||
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
|
||||
*/
|
||||
private async resolvePrimaryContactUser(companyId: string) {
|
||||
const profile = await this.externalProfileRepository.findOne({
|
||||
where: { companyId, isPrimaryContact: true },
|
||||
});
|
||||
@@ -36,24 +148,28 @@ export class CustomerResetService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve through the same active-account gate the public flow uses, so a
|
||||
// suspended customer cannot be reactivated by a staff-triggered reset.
|
||||
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||
profile.userId,
|
||||
);
|
||||
if (!user) {
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
||||
if (!target) return null;
|
||||
return { profile, user, userId: user.id };
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
||||
);
|
||||
return this.forgotPasswordService.maskTarget(target);
|
||||
/**
|
||||
* The portal route that trades the token for a set-password form. Params are
|
||||
* URL-encoded because the token is base64url — safe as-is, but the encoding
|
||||
* keeps this correct if the token format ever changes.
|
||||
*/
|
||||
private buildResetLink(userId: string, token: string): string {
|
||||
const base = this.config.get<string>("app.portalBaseUrl");
|
||||
return `${base}/reset-password?uid=${encodeURIComponent(
|
||||
userId,
|
||||
)}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
/** The channel the reset code is delivered over. */
|
||||
/**
|
||||
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
|
||||
* it sends to every contact on the account — but the staff-triggered link flow
|
||||
* still delivers over exactly one transport.
|
||||
*/
|
||||
export enum ResetChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
|
||||
@IsNotEmpty()
|
||||
identifier!: string;
|
||||
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
/**
|
||||
* Accepted and ignored. The code now goes to the account's email AND phone,
|
||||
* so there is nothing to choose — kept optional so clients still sending it
|
||||
* (older portal/backoffice builds) are not rejected outright.
|
||||
* @deprecated
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: ResetChannel,
|
||||
deprecated: true,
|
||||
description: "Ignored — the code is sent to every contact on the account.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
channel?: ResetChannel;
|
||||
}
|
||||
|
||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two halves of a reset link's query string. Together they stand in for the
|
||||
* identifier + OTP pair of the typed flow: the token proves possession of the
|
||||
* inbox/handset the link was delivered to.
|
||||
*/
|
||||
export class ResolveResetLinkDto {
|
||||
@ApiProperty({ description: "IAM user id from the reset link's `uid` param" })
|
||||
@IsUUID()
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ description: "Opaque token from the reset link's `token` param" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token!: string;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import { Public } from "@edr/api-common";
|
||||
import {
|
||||
ForgotPasswordRequestDto,
|
||||
ForgotPasswordVerifyDto,
|
||||
ResolveResetLinkDto,
|
||||
} from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
|
||||
import {
|
||||
ForgotPasswordService,
|
||||
ResetLinkAccount,
|
||||
ResetTicket,
|
||||
} from "./forgot-password.service";
|
||||
|
||||
/**
|
||||
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
|
||||
|
||||
@Post("forgot-password/request")
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code over email or SMS",
|
||||
summary: "Send a password-reset code to the account's email AND phone",
|
||||
description:
|
||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
||||
"One code, delivered over every contact the account has; either delivery " +
|
||||
"verifies it. Always reports success — an unknown, inactive, or contactless " +
|
||||
"account is indistinguishable from a real one, so this cannot be used to " +
|
||||
"enumerate accounts.",
|
||||
})
|
||||
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
||||
await this.forgotPasswordService.requestReset(user);
|
||||
} catch (error) {
|
||||
// A delivery failure must not change the response shape either — log it
|
||||
// and let the caller sit on the OTP screen.
|
||||
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
|
||||
"alongside the same identifier and the new password.",
|
||||
})
|
||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||
return this.forgotPasswordService.verifyAndMintTicket(
|
||||
dto.identifier,
|
||||
dto.channel,
|
||||
dto.otp,
|
||||
);
|
||||
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
|
||||
}
|
||||
|
||||
@Post("forgot-password/resolve-link")
|
||||
@ApiOperation({
|
||||
summary: "Validate a staff-issued reset link and return its set-password ticket",
|
||||
description:
|
||||
"Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " +
|
||||
"is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " +
|
||||
"A bad or expired link is rejected here rather than after the password is typed.",
|
||||
})
|
||||
resolveLink(@Body() dto: ResolveResetLinkDto): Promise<ResetLinkAccount> {
|
||||
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
||||
@@ -22,11 +22,32 @@ const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
|
||||
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A staff-triggered reset link lives longer than a typed OTP: the customer may
|
||||
* only see the SMS/email hours after the call that prompted it.
|
||||
*/
|
||||
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** IAM refuses a ticket once its row hits this many failed attempts. */
|
||||
const MAX_TICKET_ATTEMPTS = 5;
|
||||
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a valid reset link resolves to. `identifier` is the value IAM's
|
||||
* `set-password` matches the user on (it accepts email / username / phone), so
|
||||
* the portal can spend the ticket without the customer typing anything.
|
||||
*/
|
||||
export interface ResetLinkAccount {
|
||||
userId: string;
|
||||
identifier: string;
|
||||
maskedIdentifier: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ForgotPasswordService {
|
||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||
@@ -81,8 +102,12 @@ export class ForgotPasswordService {
|
||||
.orderBy("u.createdAt", "DESC");
|
||||
}
|
||||
|
||||
/** The address the code goes to, taken from the account — never from input. */
|
||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||
/**
|
||||
* A single channel of the account, for flows that genuinely deliver over one
|
||||
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
|
||||
* the account — never from input.
|
||||
*/
|
||||
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||
if (channel === ResetChannel.Email) {
|
||||
return user.email ? { email: user.email } : null;
|
||||
}
|
||||
@@ -90,20 +115,40 @@ export class ForgotPasswordService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset code to the account's own email/phone. Returns the target so
|
||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
||||
* callers must discard it.
|
||||
* Every contact the account has. The reset OTP goes to all of them and any one
|
||||
* verifies it — a customer whose SMS never lands can finish from their inbox
|
||||
* without restarting the flow on a different channel. An account holding only
|
||||
* one of the two degrades to that channel; only a contactless account is null.
|
||||
*/
|
||||
targetsFor(user: User): OtpTarget | null {
|
||||
const target: OtpTarget = {};
|
||||
if (user.email) target.email = user.email;
|
||||
if (user.phoneNumber) target.phone = user.phoneNumber;
|
||||
return target.email || target.phone ? target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value IAM's `set-password` will match this account on. It looks the user
|
||||
* up by email OR username OR phoneNumber (and lowercases whatever it is
|
||||
* given), so prefer email, then phone, and fall back to username last —
|
||||
* a mixed-case username would not survive that lowercasing.
|
||||
*/
|
||||
private identifierFor(user: User): string | null {
|
||||
return user.email ?? user.phoneNumber ?? user.username ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one reset code to every contact on the account — email AND phone —
|
||||
* returning the target so authenticated (backoffice) callers can echo a masked
|
||||
* version; unauthenticated callers must discard it.
|
||||
*
|
||||
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||
* upserts. A reset request therefore overwrites any pending signup code for
|
||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
||||
* between any two flows sharing this table.
|
||||
* replaces every row the target overlaps. A reset request therefore overwrites
|
||||
* any pending signup code for the same addresses — last code sent wins. That
|
||||
* is the pre-existing behaviour between any two flows sharing this table.
|
||||
*/
|
||||
async requestReset(
|
||||
user: User,
|
||||
channel: ResetChannel,
|
||||
): Promise<OtpTarget | null> {
|
||||
const target = this.targetFor(user, channel);
|
||||
async requestReset(user: User): Promise<OtpTarget | null> {
|
||||
const target = this.targetsFor(user);
|
||||
if (!target) return null;
|
||||
|
||||
await this.otpService.sendOtp(target);
|
||||
@@ -120,11 +165,12 @@ export class ForgotPasswordService {
|
||||
*/
|
||||
async verifyAndMintTicket(
|
||||
identifier: string,
|
||||
channel: ResetChannel,
|
||||
otp: string,
|
||||
): Promise<ResetTicket> {
|
||||
const user = await this.resolveActiveUser(identifier);
|
||||
const target = user && this.targetFor(user, channel);
|
||||
// Same set of contacts `requestReset` sent to, so the code resolves whichever
|
||||
// of the two the customer actually received it on.
|
||||
const target = user && this.targetsFor(user);
|
||||
|
||||
if (!user?.id || !target) {
|
||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||
@@ -134,9 +180,18 @@ export class ForgotPasswordService {
|
||||
|
||||
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
||||
|
||||
return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
|
||||
* is the proof of possession) and the staff-triggered link flow (where the
|
||||
* ticket travels in the link and delivery to the account's own inbox/handset
|
||||
* is the proof).
|
||||
*/
|
||||
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
|
||||
const code = randomBytes(24).toString("base64url");
|
||||
const verificationCode = await hashPassword(code);
|
||||
const userId = user.id;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(UserVerification);
|
||||
@@ -147,7 +202,7 @@ export class ForgotPasswordService {
|
||||
userId,
|
||||
otpType: EOtpType.RESET_PASSWORD,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
||||
expiresAt: new Date(Date.now() + ttlMs),
|
||||
isUsed: false,
|
||||
attemptCount: 0,
|
||||
});
|
||||
@@ -157,6 +212,63 @@ export class ForgotPasswordService {
|
||||
return { userId, verificationCode: code };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a reset link and hand back everything the portal needs to spend it
|
||||
* on IAM's `PATCH /api/auth/set-password`.
|
||||
*
|
||||
* The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
|
||||
* argon match — so a link that resolves here is one IAM will honour. Doing
|
||||
* them up front is what lets the page say "this link has expired" before the
|
||||
* customer types a password rather than after.
|
||||
*
|
||||
* Every rejection is the same message: a link is a bearer credential, and the
|
||||
* holder of a bad one learns nothing about why it failed or whether the user
|
||||
* id exists.
|
||||
*/
|
||||
async resolveResetLink(
|
||||
userId: string,
|
||||
token: string,
|
||||
): Promise<ResetLinkAccount> {
|
||||
const invalid = new BadRequestException(
|
||||
"This password-reset link is invalid or has expired. Request a new one.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !identifier) throw invalid;
|
||||
|
||||
const verification = await this.dataSource
|
||||
.getRepository(UserVerification)
|
||||
.findOne({
|
||||
where: { userId, otpType: EOtpType.RESET_PASSWORD },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
|
||||
// `expiresAt` / `attemptCount` are optional on IAM's entity but always
|
||||
// written by `mintResetTicket`. A row missing either is malformed, so treat
|
||||
// it as expired rather than letting it through unchecked.
|
||||
if (
|
||||
!verification ||
|
||||
verification.isUsed ||
|
||||
!verification.expiresAt ||
|
||||
verification.expiresAt < new Date() ||
|
||||
(verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
|
||||
!(await verifyPassword(token, verification.verificationCode))
|
||||
) {
|
||||
this.logger.warn(`Reset link rejected for user ${userId}`);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
identifier,
|
||||
maskedIdentifier: maskOtpTarget(
|
||||
identifier.includes("@") ? { email: identifier } : { phone: identifier },
|
||||
),
|
||||
verificationCode: token,
|
||||
};
|
||||
}
|
||||
|
||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||
maskTarget(target: OtpTarget): string {
|
||||
return maskOtpTarget(target);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||
|
||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
@@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service';
|
||||
Employee,
|
||||
]),
|
||||
OtpModule,
|
||||
// Reset links go out over email/SMS directly, not through the OTP service.
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [
|
||||
FreightMeController,
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { OtpTarget } from "../otp/otp.service";
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||
* a caller who has not yet proven possession of the channel.
|
||||
*
|
||||
* A dual-channel target masks both and joins them, so the UI can say exactly
|
||||
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
|
||||
* one of the two otherwise assumes the other never received anything.
|
||||
*/
|
||||
export function maskOtpTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
const parts: string[] = [];
|
||||
if (target.email) parts.push(maskEmail(target.email));
|
||||
if (target.phone) parts.push(maskPhone(target.phone));
|
||||
return parts.join(" and ");
|
||||
}
|
||||
|
||||
@@ -264,6 +264,19 @@ export class BookingLifecycleNotifierService {
|
||||
|
||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A booking was created under a contract. Contract drawdowns never pass
|
||||
* through submit, so this is the only point at which staff learn the booking
|
||||
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
|
||||
*/
|
||||
createdToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'New booking created',
|
||||
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer submitted a booking for review. */
|
||||
submittedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
assertTruckLoad,
|
||||
bookingContainerSizes,
|
||||
remainingBulkTons,
|
||||
} from '../../common/truck-load.util';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
@@ -67,39 +78,27 @@ export class CustomerTruckService {
|
||||
if (!isBulk && requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
|
||||
// Bulk is capped by tonnage, not container count: trucks may be added until
|
||||
// the booking's declared weight has been hauled away. Container bookings are
|
||||
// capped below by #trucks <= #containers.
|
||||
if (isBulk) {
|
||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (alreadyAssigned.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
// Size cap: a 40ft container fills the truck.
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: bookingNumbers,
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -191,28 +190,13 @@ export class CustomerTruckService {
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (assignedElsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
@@ -340,27 +324,12 @@ export class CustomerTruckService {
|
||||
}
|
||||
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
||||
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (elsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — load only 1 container onto this truck',
|
||||
);
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -534,18 +503,11 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMile?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||
// with a customer truck and an EDR leg at once.
|
||||
if (usesEdrMileService(booking)) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
@@ -614,18 +576,4 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity {
|
||||
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
grossWeightKg?: number | null;
|
||||
|
||||
/** Empty truck weight at the gate, in tonnes. Null until the truck departs. */
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
tareWeightTons?: number | null;
|
||||
|
||||
/**
|
||||
* Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown:
|
||||
* a bulk booking is hauled until the sum of this across departed trucks
|
||||
* reaches its declared VGM. Mirrors last_mile_vehicle_assignments.
|
||||
*/
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
@@ -494,6 +495,9 @@ export class CompaniesController {
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
reviewStatus: f.reviewStatus,
|
||||
reviewNote: f.reviewNote,
|
||||
reviewedAt: f.reviewedAt,
|
||||
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||
// it so the file previews/downloads in the client.
|
||||
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||
@@ -501,6 +505,35 @@ export class CompaniesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("documents/:fileId/request-change")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Ask the customer to correct one uploaded document",
|
||||
description:
|
||||
"Flags a single document with a reason the customer sees, notifies them, " +
|
||||
"and blocks role approval until they re-upload. Narrower than rejecting " +
|
||||
"the whole role.",
|
||||
})
|
||||
async requestDocumentChange(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: RequestDocumentChangeDto,
|
||||
) {
|
||||
const file = await this.companiesService.requestDocumentChange(
|
||||
fileId,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
code: file.code,
|
||||
reviewStatus: file.reviewStatus,
|
||||
reviewNote: file.reviewNote,
|
||||
reviewedAt: file.reviewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(":companyId/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
|
||||
@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
)
|
||||
)`;
|
||||
|
||||
/**
|
||||
* A company waiting on a reviewer to decide an edit it submitted after being
|
||||
* approved. These rows are `status = active`, so the pending-application filter
|
||||
* can never surface them — the review queue needs its own predicate.
|
||||
*/
|
||||
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
|
||||
SELECT 1 FROM freight.company_change_request ccr
|
||||
WHERE ccr.company_id = company.id
|
||||
AND ccr.status = 'pending'
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -67,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
@@ -97,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPendingChangeRequest !== undefined) {
|
||||
qb.andWhere(
|
||||
hasPendingChangeRequest
|
||||
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
|
||||
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
qb.andWhere(
|
||||
@@ -113,8 +136,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
const [items, total] = await qb
|
||||
.orderBy('company.name', 'ASC')
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
@@ -137,6 +164,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||
.getRawMany();
|
||||
|
||||
const pendingChanges = await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
|
||||
.getCount();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
let onboarding = 0;
|
||||
let total = 0;
|
||||
@@ -154,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
onboarding,
|
||||
suspended: map.get('suspended') ?? 0,
|
||||
blacklisted: map.get('blacklisted') ?? 0,
|
||||
pendingChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
@@ -98,6 +99,7 @@ export class CompaniesService {
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -748,7 +750,15 @@ export class CompaniesService {
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
// Rejecting a request leaves it Rejected rather than reopening it, so a
|
||||
// customer amending after a rejection lands here with a fresh Pending row.
|
||||
// That is the resubmission case the reviewer needs flagged.
|
||||
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
||||
const resubmitted = history.some(
|
||||
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||
);
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
@@ -756,6 +766,11 @@ export class CompaniesService {
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
request.id,
|
||||
resubmitted,
|
||||
);
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
@@ -827,6 +842,12 @@ export class CompaniesService {
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
uploaded.map((f) => f.code),
|
||||
uploaded.map((f) => f.id),
|
||||
);
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentChange(
|
||||
company.id,
|
||||
@@ -837,6 +858,95 @@ export class CompaniesService {
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the `change_requested` flag from the documents a fresh upload replaces.
|
||||
*
|
||||
* Uploading does not overwrite the old row — it adds a new one under the same
|
||||
* `code` — so the flagged original would otherwise linger and keep the approval
|
||||
* gate closed even after the customer did exactly what was asked. Only rows of
|
||||
* the same code are touched, and never the newly uploaded ones.
|
||||
*/
|
||||
private async resolveDocumentChangeRequests(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
codes: string[],
|
||||
uploadedIds: string[],
|
||||
): Promise<void> {
|
||||
if (codes.length === 0) return;
|
||||
const replaced = new Set(codes);
|
||||
const fresh = new Set(uploadedIds);
|
||||
const open = await this.filesService.findWithOpenChangeRequest(
|
||||
[resourceId],
|
||||
resource,
|
||||
);
|
||||
await Promise.all(
|
||||
open
|
||||
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
|
||||
.map((f) => this.filesService.clearReview(f.id)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice: ask the customer to correct one specific document, instead of
|
||||
* rejecting their whole role over it. Mirrors the contract change-request
|
||||
* flow — a note the customer sees verbatim, plus a block on approval until
|
||||
* they re-upload.
|
||||
*/
|
||||
async requestDocumentChange(
|
||||
fileId: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<FileRecord> {
|
||||
const file = await this.filesService.findById(fileId);
|
||||
const companyId = await this.resolveDocumentCompanyId(file);
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
// Flag the document while holding a write lock on its company row. The
|
||||
// approval gate takes the same lock before it reads the flags, so the two
|
||||
// serialize: a change request can never land in the window between the gate
|
||||
// checking "any open corrections?" and writing the profile Active.
|
||||
const updated = await this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
where: { id: companyId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
return this.filesService.setReviewStatus(
|
||||
file.id,
|
||||
"change_requested",
|
||||
note,
|
||||
reviewerId,
|
||||
);
|
||||
});
|
||||
this.companyNotifier.documentChangeRequested(
|
||||
company,
|
||||
file.name,
|
||||
note,
|
||||
file.id,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which company a stored document belongs to. Company documents are keyed by
|
||||
* the company id directly; profile licences and POA letters hang off a company
|
||||
* profile, so those resolve through it.
|
||||
*/
|
||||
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
|
||||
if (file.resource === "companies") return file.resourceId;
|
||||
if (file.resource === "company_profiles") {
|
||||
const profile = await this.companyProfilesRepo.findById(file.resourceId);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`Company profile ${file.resourceId} not found`,
|
||||
);
|
||||
}
|
||||
return profile.companyId;
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Documents on "${file.resource}" do not support change requests`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording staged document uploads. */
|
||||
private async stageDocumentChange(
|
||||
companyId: string,
|
||||
@@ -847,6 +957,7 @@ export class CompaniesService {
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const company = await this.companiesRepo.findById(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
@@ -860,8 +971,15 @@ export class CompaniesService {
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
}
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||
const resubmitted = history.some(
|
||||
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||
);
|
||||
const created = await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentFileIds: fileIds },
|
||||
@@ -869,6 +987,13 @@ export class CompaniesService {
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
created.id,
|
||||
resubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,6 +1112,61 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Anything other than approval has no document gate and no concurrency
|
||||
// hazard — apply it directly.
|
||||
if (status !== ProfileStatus.Active) {
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
}
|
||||
|
||||
// Approving over an outstanding document correction would silently accept the
|
||||
// very document a reviewer just rejected, and would strand the customer's
|
||||
// "please fix this" banner with nothing left to fix. The gate check and the
|
||||
// status write share a write lock on the company row — `requestDocumentChange`
|
||||
// takes the same lock, so a fresh correction can never land in the window
|
||||
// between "any open corrections?" and the profile going Active. Suspend and
|
||||
// blacklist skip all this — staff must always be able to act against a bad
|
||||
// account.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
where: { id: existing.companyId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
|
||||
const [companyDocs, profileDocs] = await Promise.all([
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.companyId],
|
||||
"companies",
|
||||
),
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.id],
|
||||
"company_profiles",
|
||||
),
|
||||
]);
|
||||
const pending = [...companyDocs, ...profileDocs];
|
||||
if (pending.length > 0) {
|
||||
const names = pending.map((f) => f.name).join(", ");
|
||||
throw new BadRequestException(
|
||||
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
||||
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a reviewed profile status (reference minting, note handling, reviewer
|
||||
* stamp) and promote the company if this is its first approved role. Split out
|
||||
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
|
||||
* transaction while every other status skips that overhead.
|
||||
*/
|
||||
private async applyProfileStatus(
|
||||
existing: CompanyProfile,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1008,9 +1188,9 @@ export class CompaniesService {
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
@@ -1057,6 +1237,13 @@ export class CompaniesService {
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// The role is back in the pending queue — tell the reviewers, otherwise the
|
||||
// resubmission is invisible until someone happens to reopen the customer.
|
||||
const company = await this.companiesRepo.findById(companyId);
|
||||
if (company) {
|
||||
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1512,6 +1699,15 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// A fresh licence upload answers any correction the reviewer asked for on the
|
||||
// previous one, so the old row must stop blocking approval.
|
||||
await this.resolveDocumentChangeRequests(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||
uploaded.map((r) => r.id),
|
||||
);
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
@@ -1593,6 +1789,13 @@ export class CompaniesService {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
await this.resolveDocumentChangeRequests(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||
[created.id],
|
||||
);
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
@@ -1689,6 +1892,8 @@ export class CompaniesService {
|
||||
: pendingRemoveIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
reviewStatus: r.reviewStatus,
|
||||
reviewNote: r.reviewNote,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1855,6 +2060,13 @@ export class CompaniesService {
|
||||
for (const r of live) await this.filesService.remove(r.id);
|
||||
}
|
||||
|
||||
await this.resolveDocumentChangeRequests(
|
||||
company.id,
|
||||
COMPANY_RESOURCE,
|
||||
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
|
||||
[created.id],
|
||||
);
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
@@ -1932,6 +2144,8 @@ export class CompaniesService {
|
||||
: removeIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
reviewStatus: r.reviewStatus,
|
||||
reviewNote: r.reviewNote,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -88,4 +88,106 @@ export class CompanyNotifierService {
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||
|
||||
/**
|
||||
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
||||
* the customer's detail page.
|
||||
*
|
||||
* The recipient resolver has no role/permission targeting (see
|
||||
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
||||
* selector available, so marketing is reached by notifying all staff.
|
||||
*/
|
||||
private notifyStaff(
|
||||
company: Company,
|
||||
title: string,
|
||||
body: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/customers/${company.id}`,
|
||||
data: { companyId: company.id, companyName: company.name, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer resubmitted an operational role after it was rejected for
|
||||
* adjustment. Without this the role silently flips back to Pending and nobody
|
||||
* is told there is anything to look at again.
|
||||
*/
|
||||
roleReapplied(company: Company, profileId: string, profileType: string): void {
|
||||
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
|
||||
this.notifyStaff(
|
||||
company,
|
||||
"Customer resubmitted a role for approval",
|
||||
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
|
||||
`It is back in the pending approval queue for review.`,
|
||||
{ profileId, profileType },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer submitted (or amended and resubmitted) a profile change request.
|
||||
* `resubmitted` distinguishes the two so the reviewer knows this is a second
|
||||
* look at something they already sent back.
|
||||
*/
|
||||
changeRequestSubmitted(
|
||||
company: Company,
|
||||
changeRequestId: string,
|
||||
resubmitted: boolean,
|
||||
): void {
|
||||
this.logger.log(
|
||||
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`,
|
||||
);
|
||||
this.notifyStaff(
|
||||
company,
|
||||
resubmitted
|
||||
? "Customer resubmitted profile changes"
|
||||
: "Customer submitted profile changes",
|
||||
resubmitted
|
||||
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
|
||||
`them. They are pending your review.`
|
||||
: `${company.name} has submitted profile changes that are pending review.`,
|
||||
{ changeRequestId },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Customer-facing: a specific document needs correcting ──────────────────
|
||||
|
||||
/**
|
||||
* Tell the customer a reviewer wants one specific document corrected. Mirrors
|
||||
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
|
||||
* deep-linked to the documents tab where they can re-upload.
|
||||
*/
|
||||
documentChangeRequested(
|
||||
company: Company,
|
||||
documentName: string,
|
||||
note: string,
|
||||
fileId: string,
|
||||
): void {
|
||||
const title = "Document change requested";
|
||||
const body =
|
||||
`A reviewer has asked you to correct "${documentName}". ` +
|
||||
`Reason: ${note} ` +
|
||||
`Please upload a corrected version from your settings page.`;
|
||||
|
||||
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, fileId, documentName },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,10 @@ export class CompanyStatsResponseDto {
|
||||
onboarding!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
/**
|
||||
* Approved customers with an open profile change request. Counted separately
|
||||
* because they are `active` and so are invisible to the `pending` KPI, even
|
||||
* though they are just as much waiting on a reviewer.
|
||||
*/
|
||||
pendingChanges!: number;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IsString, IsNotEmpty, Length } from "class-validator";
|
||||
import { IsString, IsNotEmpty } from "class-validator";
|
||||
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||
|
||||
export class FetchETradeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin!: string;
|
||||
}
|
||||
|
||||
@@ -47,4 +47,30 @@ export class ListCompaniesQueryDto {
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"`true` = only companies with an open (pending) profile change request. " +
|
||||
"These are already-approved customers, so they never appear under " +
|
||||
"`status=pending` and would otherwise be invisible in the review queue.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
hasPendingChangeRequest?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
@@ -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 { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@@ -34,7 +36,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -137,10 +139,14 @@ export class UpdateProfileDto {
|
||||
@MaxLength(50)
|
||||
renewedTo?: string;
|
||||
|
||||
// Zone/woreda/kebele below stay free text: there is no authoritative dataset
|
||||
// of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns
|
||||
// them uncoded. Only region is a closed set today.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
region?: string;
|
||||
@IsIn(ETHIOPIAN_REGIONS as unknown as string[], {
|
||||
message: "region must be a recognised Ethiopian region",
|
||||
})
|
||||
region?: EthiopianRegion;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -37,8 +37,20 @@ export interface BusinessLicenseFile {
|
||||
*/
|
||||
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/**
|
||||
* Reviewer verdict on a document, as surfaced to clients. Distinct from
|
||||
* {@link StagedFileStatus}: that describes where the file sits in the staged
|
||||
* add/remove workflow, this describes whether a reviewer wants it corrected.
|
||||
*/
|
||||
export interface FileReviewView {
|
||||
/** `change_requested` while the customer still owes a corrected upload. */
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown verbatim to the customer. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
export interface ProfileLicenseFileView extends FileReviewView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
@@ -47,7 +59,7 @@ export interface ProfileLicenseFileView {
|
||||
}
|
||||
|
||||
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||
export interface CompanyDocumentFileView {
|
||||
export interface CompanyDocumentFileView extends FileReviewView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
|
||||
@Injectable()
|
||||
@@ -108,7 +109,11 @@ export class ETradeService {
|
||||
renewedFrom: businessInfo.RenewedFrom,
|
||||
renewalDate: businessInfo.RenewalDate,
|
||||
renewedTo: businessInfo.RenewedTo,
|
||||
region: businessInfo.AddressInfo?.Region || "",
|
||||
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
||||
// Region slot. Map it onto the canonical list; an unresolved value yields
|
||||
// "" so the form asks the user to pick rather than failing validation on
|
||||
// save with a value they never typed.
|
||||
region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "",
|
||||
zone: businessInfo.AddressInfo?.Zone || "",
|
||||
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||
kebele: businessInfo.AddressInfo?.Kebele || "",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ETHIOPIAN_REGIONS, normalizeRegion } from '@edr/types';
|
||||
|
||||
/**
|
||||
* normalizeRegion lives in @edr/types (no jest there), but it exists to keep
|
||||
* eTrade autofill from feeding UpdateProfileDto a region its @IsIn will reject.
|
||||
* That contract is an API concern, so it is guarded here.
|
||||
*/
|
||||
describe('normalizeRegion', () => {
|
||||
it('passes through every canonical region unchanged', () => {
|
||||
for (const region of ETHIOPIAN_REGIONS) {
|
||||
expect(normalizeRegion(region)).toBe(region);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ADDIS ABABA', 'Addis Ababa'],
|
||||
['Addis ababa', 'Addis Ababa'],
|
||||
[' addis ababa ', 'Addis Ababa'],
|
||||
['oromoia', 'Oromia'],
|
||||
['OROMIYA', 'Oromia'],
|
||||
['gambella', 'Gambela'],
|
||||
['TIGRAI', 'Tigray'],
|
||||
['benishangul gumuz', 'Benishangul-Gumuz'],
|
||||
])('resolves the variant %s', (input, expected) => {
|
||||
expect(normalizeRegion(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('maps a zone name in the region slot back to its parent region', () => {
|
||||
// eTrade's own placeholder data does this — "EASTERN TIGRAY" is a zone.
|
||||
expect(normalizeRegion('EASTERN TIGRAY')).toBe('Tigray');
|
||||
expect(normalizeRegion('North Wollo')).toBe('Amhara');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a city, not a region', 'Arba Minch'],
|
||||
['unknown text', 'Nowhere Land'],
|
||||
['empty', ''],
|
||||
['whitespace only', ' '],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('returns null for %s rather than guessing', (_label, input) => {
|
||||
expect(normalizeRegion(input as string | null | undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('never returns a value outside the canonical set', () => {
|
||||
const samples = ['ADDIS ABABA', 'oromoia', 'EASTERN TIGRAY', 'garbage', ''];
|
||||
for (const s of samples) {
|
||||
const out = normalizeRegion(s);
|
||||
if (out !== null) {
|
||||
expect(ETHIOPIAN_REGIONS).toContain(out);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
||||
// the current one; this is the trail behind it.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
|
||||
/**
|
||||
* The level is customer-visible and stays correctable until duty is advised,
|
||||
* so a changed level must leave a trail rather than overwrite the last one.
|
||||
*/
|
||||
describe('risk history', () => {
|
||||
it('records the first assignment with no previous level', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedByUserId: 'user-1',
|
||||
assignedBy: 'Abebe K.',
|
||||
note: 'initial rating',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
||||
});
|
||||
|
||||
it('keeps the earlier decision when the level is reassigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
||||
|
||||
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
||||
// The original RED decision survives, with who made it.
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedBy: 'Abebe K.',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
||||
level: 'GREEN',
|
||||
previousLevel: 'RED',
|
||||
assignedByUserId: 'user-2',
|
||||
assignedBy: 'Sara M.',
|
||||
note: 'downgraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
await service.assignRisk('b-1', 'YELLOW');
|
||||
const saved = await service.assignRisk('b-1', 'RED');
|
||||
|
||||
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
||||
'GREEN',
|
||||
'YELLOW',
|
||||
'RED',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record a repeat of the level already assigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('always leaves riskLevel equal to the last history entry', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED');
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
const history = saved.metadata?.riskHistory ?? [];
|
||||
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*
|
||||
* The level stays correctable until duty is advised off it, so each assignment
|
||||
* is appended to `riskHistory` instead of silently replacing the last one — a
|
||||
* customer-visible level that changes needs a trail of who changed it and when.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
riskLevel: CustomsRiskLevel,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
actor?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
|
||||
const existing = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
|
||||
});
|
||||
const previousLevel = existing?.metadata?.riskLevel;
|
||||
const history = existing?.metadata?.riskHistory ?? [];
|
||||
|
||||
// A repeat of the level already assigned is not a decision — recording it
|
||||
// would pad the trail with entries that changed nothing.
|
||||
const entries =
|
||||
previousLevel === riskLevel
|
||||
? history
|
||||
: [
|
||||
...history,
|
||||
{
|
||||
level: riskLevel,
|
||||
...(previousLevel ? { previousLevel } : {}),
|
||||
assignedAt: new Date().toISOString(),
|
||||
assignedByUserId: userId ?? null,
|
||||
assignedBy: actor ?? null,
|
||||
note: note ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
return this.completeWithMetadata(
|
||||
bookingId,
|
||||
'RISK_ASSIGNED',
|
||||
{ riskLevel, riskHistory: entries },
|
||||
userId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
@@ -97,6 +98,7 @@ export class ContractBookingService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@@ -353,6 +355,12 @@ export class ContractBookingService {
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
|
||||
// Tell staff the booking exists. Placed after the zero-price rollback (which
|
||||
// hard-deletes the row) and before the consolidation gate, so it fires
|
||||
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||
|
||||
const intendedStatus =
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
@@ -482,6 +490,7 @@ export class ContractBookingService {
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||
return { booking: result ?? booking, warnings: [] };
|
||||
}
|
||||
|
||||
@@ -569,7 +578,10 @@ export class ContractBookingService {
|
||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||
}
|
||||
|
||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
const created =
|
||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
this.bookingNotifier.createdToStaff(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first — see booking-clearance.service.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -102,6 +102,27 @@ function maskPhone(phone: string): string {
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.trim().split('@');
|
||||
if (!domain) return email.trim();
|
||||
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
||||
* Both contacts are listed when both were used — a signer who only watches their
|
||||
* handset otherwise has no idea the email carries the same code.
|
||||
*/
|
||||
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
||||
return [
|
||||
contacts.email ? maskEmail(contacts.email) : null,
|
||||
contacts.phone ? maskPhone(contacts.phone) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -139,34 +160,39 @@ export class ContractTransitionService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||
* own IAM account phone AND email. One code goes to both and either delivery
|
||||
* verifies it, so a signer whose SMS is delayed can still complete from their
|
||||
* inbox instead of abandoning a ready contract.
|
||||
*
|
||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||
* request body — a caller-supplied number would let an attacker point the code
|
||||
* at their own phone. Ownership is already gated separately by
|
||||
* request body — caller-supplied contacts would let an attacker point the code
|
||||
* at their own phone or mailbox. Ownership is already gated separately by
|
||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||
* signature to the *person* signing rather than to a company landline that may
|
||||
* be shared, stale, or imported from eTrade.
|
||||
*/
|
||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
||||
private async resolveSignerContacts(
|
||||
signerUserId?: string,
|
||||
): Promise<{ phone?: string; email?: string }> {
|
||||
if (!signerUserId) {
|
||||
// Unreachable in practice (the ownership gate rejects a missing user
|
||||
// first), but never fall back to another number if it ever changes.
|
||||
// first), but never fall back to another account if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
const email = rows[0]?.email?.trim();
|
||||
if (!phone && !email) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -992,11 +1018,11 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||
* same number {@link sign} verifies against. The client never picks the number
|
||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||
* email — the same contacts {@link sign} verifies against. The client never
|
||||
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||
* UI can say where the code went without exposing the full values.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
@@ -1011,9 +1037,9 @@ export class ContractTransitionService {
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
return { sentTo: maskSignerContacts(signerContacts) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
@@ -1040,17 +1066,17 @@ export class ContractTransitionService {
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||
// phone, resolved server-side from the authenticated user id — never a
|
||||
// caller-supplied number, which an attacker could point at their own
|
||||
// phone. Ownership is already asserted above, so this proves the specific
|
||||
// person holding the account is present, not merely that someone reached a
|
||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||
// and verify would target different numbers.
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
// contacts, resolved server-side from the authenticated user id — never
|
||||
// caller-supplied ones, which an attacker could point at their own phone
|
||||
// or mailbox. Ownership is already asserted above, so this proves the
|
||||
// specific person holding the account is present, not merely that someone
|
||||
// reached a shared company line. Must resolve identically to
|
||||
// sendSigningOtp, or send and verify would target different contacts.
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
@@ -990,13 +991,16 @@ export class ContractsController {
|
||||
assignRisk(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: AssignRiskDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.milestoneService.assignRisk(
|
||||
bookingId,
|
||||
dto.riskLevel,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
// Risk history is read by people, so resolve the name now — the id alone
|
||||
// would render as a UUID in the trail.
|
||||
actorLabel(user),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* One customs risk decision. Risk stays correctable until duty is advised off
|
||||
* it, and the level is customer-visible, so every assignment is kept rather than
|
||||
* overwritten — a disputed level needs to show what was set, by whom, and when.
|
||||
*/
|
||||
export interface RiskAssignmentRecord {
|
||||
level: CustomsRiskLevel;
|
||||
/** The level this replaced; absent on the first assignment. */
|
||||
previousLevel?: CustomsRiskLevel;
|
||||
assignedAt: string;
|
||||
assignedByUserId?: string | null;
|
||||
/** Display name resolved at assignment time, so the trail never shows a UUID. */
|
||||
assignedBy?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
||||
* - RISK_ASSIGNED → `riskLevel`
|
||||
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
|
||||
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||
* Stored on the milestone so the timeline can render the value inline.
|
||||
*/
|
||||
export interface MilestoneMetadata {
|
||||
riskLevel?: CustomsRiskLevel;
|
||||
/**
|
||||
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||
* equals the last entry's `level`.
|
||||
*/
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
dutyAmount?: number;
|
||||
dutyCurrency?: string;
|
||||
declarationSerial?: string;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reviewer verdict on a single stored document.
|
||||
*
|
||||
* `null` (the default) means "not reviewed" — the state every file starts in and
|
||||
* the only state the customer is not blocked by. `change_requested` is raised by
|
||||
* a backoffice reviewer against one specific document and is what the customer
|
||||
* must clear by re-uploading; `approved` records an explicit sign-off.
|
||||
*/
|
||||
export type FileReviewStatus = "change_requested" | "approved";
|
||||
|
||||
@Entity({ schema: "freight", name: "files" })
|
||||
export class FileRecord extends BaseEntity {
|
||||
@Column({ name: "resource_id", type: "uuid" })
|
||||
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
|
||||
|
||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||
mimeType!: string;
|
||||
|
||||
/** Reviewer verdict, or `null` while the document has never been reviewed. */
|
||||
@Column({
|
||||
name: "review_status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
reviewStatus!: FileReviewStatus | null;
|
||||
|
||||
/** Why a change was requested — shown verbatim to the customer. */
|
||||
@Column({ name: "review_note", type: "text", nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
@@ -23,13 +30,16 @@ export class FilesController {
|
||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||
// caller's booking/company before streaming).
|
||||
// caller's booking/company before streaming). Until that lands, any resource
|
||||
// whose files are cross-tenant sensitive must opt OUT of this route and expose
|
||||
// its own checked endpoint — see the support_message case below.
|
||||
@ApiOperation({
|
||||
summary: "Stream a file by ID",
|
||||
description:
|
||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||
"No resource context (e.g. booking ID) required. Serves inline by default so " +
|
||||
"the browser can preview it; pass ?download=1 to force a download.",
|
||||
"the browser can preview it; pass ?download=1 to force a download. " +
|
||||
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
@@ -41,7 +51,19 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.filesService.streamById(fileId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
// another. SupportAttachmentController scopes the caller to the owning
|
||||
// thread; refuse here rather than quietly serving the bytes.
|
||||
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
|
||||
throw new ForbiddenException(
|
||||
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
return this.repository.find({ where: { resourceId, resource } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch sibling of {@link findByResource} for hydrating a page of resources at
|
||||
* once (a thread of chat messages, say) instead of one query per row.
|
||||
*/
|
||||
async findByResourceIds(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
return this.repository.find({
|
||||
where: { resourceId: In(resourceIds), resource },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
@@ -33,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents belonging to any of the given resources that a reviewer has asked
|
||||
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||
* resource ids (a company plus each of its company profiles) in one query.
|
||||
*/
|
||||
async findWithOpenChangeRequest(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
return this.repository.find({
|
||||
where: {
|
||||
resourceId: In(resourceIds),
|
||||
resource,
|
||||
reviewStatus: "change_requested",
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
|
||||
|
||||
export interface CreateFileInput {
|
||||
resourceId: string;
|
||||
@@ -78,8 +79,19 @@ export class FilesService {
|
||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||
// human-readable name is preserved separately on the record below.
|
||||
const safeName = sanitizeObjectName(file.originalname);
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
|
||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
// The random segment is load-bearing, not decoration. `Date.now()` alone is
|
||||
// NOT unique across a batch: callers upload with Promise.all, every callback
|
||||
// runs to its first await in the same tick, so they all read the same
|
||||
// millisecond. Two files with one name in one batch — e.g. pasting two
|
||||
// screenshots, which browsers both call "image.png" — would build identical
|
||||
// keys, and the second putObject would overwrite the first while both rows
|
||||
// persisted pointing at the same object.
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
return this.filesRepository.create({
|
||||
resourceId,
|
||||
@@ -157,6 +169,53 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||
* reason can never outlive the request it explained.
|
||||
*/
|
||||
async setReviewStatus(
|
||||
id: string,
|
||||
status: FileReviewStatus,
|
||||
note: string | null,
|
||||
reviewerId?: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.findById(id);
|
||||
const updated = await this.filesRepository.update(record.id, {
|
||||
reviewStatus: status,
|
||||
reviewNote: status === "change_requested" ? (note ?? null) : null,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`File ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any reviewer verdict from a document, returning it to "not reviewed".
|
||||
* Called when a customer re-uploads: the new bytes have not been looked at, so
|
||||
* carrying the old `change_requested` forward would keep them blocked forever.
|
||||
*/
|
||||
async clearReview(id: string): Promise<void> {
|
||||
await this.filesRepository.update(id, {
|
||||
reviewStatus: null,
|
||||
reviewNote: null,
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Documents across these resources still awaiting a customer correction. */
|
||||
findWithOpenChangeRequest(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findWithOpenChangeRequest(
|
||||
resourceIds,
|
||||
resource,
|
||||
);
|
||||
}
|
||||
|
||||
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.filesRepository.softDelete(id);
|
||||
@@ -175,6 +234,27 @@ export class FilesService {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files for many resources of one kind, grouped by resource id. Resources with
|
||||
* no files are absent from the map (callers should default to `[]`).
|
||||
*/
|
||||
async findByResourceIdsGrouped(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<Map<string, FileRecord[]>> {
|
||||
const records = await this.filesRepository.findByResourceIds(
|
||||
resourceIds,
|
||||
resource,
|
||||
);
|
||||
const grouped = new Map<string, FileRecord[]>();
|
||||
for (const record of records) {
|
||||
const bucket = grouped.get(record.resourceId);
|
||||
if (bucket) bucket.push(record);
|
||||
else grouped.set(record.resourceId, [record]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||
@@ -190,7 +270,11 @@ export class FilesService {
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
||||
const record = await this.filesRepository.findByCode(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
);
|
||||
if (!record)
|
||||
throw new NotFoundException(
|
||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||
@@ -198,7 +282,9 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
async streamById(
|
||||
id: string,
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
|
||||
@@ -333,10 +333,12 @@ export class FirstMileService {
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
// The pickup address is the only record of what the contract chose.
|
||||
// `serviceType.includesFirstMile` used to satisfy this too, but every
|
||||
// service type ships with it set to true, so the OR made the address check
|
||||
// dead and admitted every paid export booking into the queue.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' &&
|
||||
(booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile),
|
||||
booking.tradeDirection === 'EXPORT' && booking.firstMilePickupAddress?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal file
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// health.controller.ts
|
||||
|
||||
import { Controller, Get, HttpStatus, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Response } from "express";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
|
||||
type CheckStatus = "ok" | "error" | "unknown";
|
||||
|
||||
/**
|
||||
* Readiness normally stays green when only the broker is down.
|
||||
*
|
||||
* A 503 pulls the pod out of the load balancer, which would take booking,
|
||||
* tracking and billing offline because SMS is unreachable — a strictly worse
|
||||
* outcome than degraded notifications. The broker check is therefore reported,
|
||||
* not enforced, and `READINESS_REQUIRES_BROKER=true` opts into hard-failing for
|
||||
* deployments where a silent OTP black hole is the greater risk.
|
||||
*/
|
||||
const READINESS_REQUIRES_BROKER =
|
||||
process.env.READINESS_REQUIRES_BROKER === "true";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Liveness probe" })
|
||||
liveness() {
|
||||
return { status: "ok", timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get("ready")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.",
|
||||
})
|
||||
async readiness(@Res() res: Response) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
let database: { status: CheckStatus; latencyMs: number; error?: string };
|
||||
try {
|
||||
await this.dataSource.query("SELECT 1");
|
||||
database = { status: "ok", latencyMs: Date.now() - startedAt };
|
||||
} catch (error) {
|
||||
database = {
|
||||
status: "error",
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
// `null` from the client means the connection manager was not reachable
|
||||
// through Nest's internals — surfaced as "unknown" so a shape change in
|
||||
// @nestjs/microservices degrades to honest ignorance, not a false "ok".
|
||||
const toStatus = (connected: boolean | null): CheckStatus =>
|
||||
connected === null ? "unknown" : connected ? "ok" : "error";
|
||||
|
||||
const broker = {
|
||||
sms: { status: toStatus(this.smsClient.brokerConnected) },
|
||||
email: { status: toStatus(this.emailClient.brokerConnected) },
|
||||
// Every OTP, and every booking/billing notification, publishes through
|
||||
// these. `error` here means codes are being generated and silently dropped.
|
||||
enabled: process.env.RABBITMQ_ENABLED !== "false",
|
||||
};
|
||||
|
||||
const brokerDown =
|
||||
broker.sms.status === "error" || broker.email.status === "error";
|
||||
const failed =
|
||||
database.status === "error" ||
|
||||
(READINESS_REQUIRES_BROKER && brokerDown);
|
||||
|
||||
const status = failed ? "error" : brokerDown ? "degraded" : "ok";
|
||||
|
||||
return res
|
||||
.status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
|
||||
.json({
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: { database, broker },
|
||||
});
|
||||
}
|
||||
|
||||
@Get("info")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "App info — version, environment, uptime" })
|
||||
info() {
|
||||
return {
|
||||
name: "edr-freight-api",
|
||||
version: process.env.npm_package_version ?? "1.0.0",
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal file
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// health.module.ts
|
||||
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { HealthController } from "./health.controller";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
@Module({
|
||||
// NotificationsModule exports the SMS/email clients; the readiness probe reads
|
||||
// their broker connection state rather than opening a second connection.
|
||||
imports: [NotificationsModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
/**
|
||||
* A booking reaches the last-mile queue only if its contract bought EDR
|
||||
* delivery, and never if the customer is hauling it themselves. Creation used
|
||||
* to check payment alone, so any paid booking could be accepted — which put a
|
||||
* self-haul booking and an EDR leg on the same shipment at once.
|
||||
*/
|
||||
type BookingRow = { tradeDirection: string; firstMile: string | null; lastMile: string | null };
|
||||
|
||||
function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) {
|
||||
const booking = opts.booking ?? {
|
||||
tradeDirection: 'IMPORT',
|
||||
firstMile: null,
|
||||
lastMile: 'Bole, Addis Ababa',
|
||||
};
|
||||
|
||||
const query = jest.fn((sql: string) => {
|
||||
if (sql.includes('customer_truck_assignments')) {
|
||||
return Promise.resolve(opts.hasCustomerTruck ? [{ '?column?': 1 }] : []);
|
||||
}
|
||||
if (sql.includes('FROM freight.bookings')) {
|
||||
return Promise.resolve([booking]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const lastMileRepository = {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
|
||||
};
|
||||
|
||||
const service = new LastMileService(
|
||||
lastMileRepository as never,
|
||||
{} as never, // bookingsRepository
|
||||
{ setAvailability: jest.fn() } as never, // vehiclesService
|
||||
{} as never, // driversService
|
||||
{} as never, // smsClient
|
||||
{ query } as unknown as DataSource,
|
||||
{ record: jest.fn() } as never, // history
|
||||
{} as never, // billing
|
||||
{} as never, // filesService
|
||||
);
|
||||
|
||||
return { service, lastMileRepository, query };
|
||||
}
|
||||
|
||||
describe('LastMileService.create — haulage guard', () => {
|
||||
it('accepts a booking whose contract chose EDR delivery', async () => {
|
||||
const { service, lastMileRepository } = makeService({});
|
||||
|
||||
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||
|
||||
expect(lastMileRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a booking that chose no road legs on its contract', async () => {
|
||||
const { service, lastMileRepository } = makeService({
|
||||
booking: { tradeDirection: 'IMPORT', firstMile: null, lastMile: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a booking already hauled by the customer’s own truck', async () => {
|
||||
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an import that only chose collection — that is the export leg', async () => {
|
||||
const { service } = makeService({
|
||||
booking: { tradeDirection: 'IMPORT', firstMile: 'Modjo', lastMile: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns the existing leg without re-checking, so the queue stays idempotent', async () => {
|
||||
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||
lastMileRepository.findAll.mockResolvedValue([{ id: 'lm-existing' }]);
|
||||
|
||||
const result = await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||
|
||||
expect(result).toEqual({ id: 'lm-existing' });
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,18 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
|
||||
import {
|
||||
NO_MILE_SERVICE_MESSAGE,
|
||||
SELF_HAUL_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
assertTruckLoad,
|
||||
bookingContainerSizes,
|
||||
remainingBulkTons,
|
||||
} from '../../common/truck-load.util';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
@@ -126,6 +138,47 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a booking that actually bought EDR delivery belongs in the last-mile
|
||||
* queue, and a booking hauled by the customer's own truck must never also get
|
||||
* an EDR leg.
|
||||
*
|
||||
* Both halves were missing: creation checked payment alone, so any paid
|
||||
* booking could be accepted into the queue — including one whose contract
|
||||
* chose no road legs at all, and one already carrying a customer truck. The
|
||||
* mirror rule existed on the truck side only
|
||||
* (CustomerTruckService.assertSelfHaulPaid), so whichever side acted second
|
||||
* silently opened a competing delivery on the same booking.
|
||||
*/
|
||||
private async assertEdrHaulsThisBooking(bookingId?: string | null): Promise<void> {
|
||||
if (!bookingId) return;
|
||||
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection",
|
||||
first_mile_pickup_address AS "firstMile",
|
||||
last_mile_delivery_address AS "lastMile"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
// The road legs are chosen on the contract and copied onto the booking, so
|
||||
// the booking's own addresses answer this without a join.
|
||||
if (booking && !usesEdrMileService(booking)) {
|
||||
throw new BadRequestException(NO_MILE_SERVICE_MESSAGE);
|
||||
}
|
||||
|
||||
const [truck] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (truck) {
|
||||
throw new BadRequestException(SELF_HAUL_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
@@ -352,6 +405,8 @@ export class LastMileService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await this.assertEdrHaulsThisBooking(dto.bookingId);
|
||||
|
||||
const record = await this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
@@ -573,20 +628,6 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||
@@ -599,26 +640,9 @@ export class LastMileService {
|
||||
remainingTons: number;
|
||||
complete: boolean;
|
||||
}> {
|
||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||
COALESCE((
|
||||
SELECT SUM(va.net_weight_tons)
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
WHERE lm.booking_id = b.id
|
||||
AND va.deleted_at IS NULL
|
||||
AND va.departed_at IS NOT NULL
|
||||
), 0) AS "hauledTons"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const totalTons = Number(row?.totalTons ?? 0);
|
||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||
// Counts customer trucks as well as EDR ones — a booking hauls by one path
|
||||
// or the other, and "until no tonnage is left" means the same either way.
|
||||
return remainingBulkTons(this.dataSource, bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,11 +667,7 @@ export class LastMileService {
|
||||
);
|
||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||
if (totalTons > 0 && remainingTons <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||
);
|
||||
}
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -657,33 +677,41 @@ export class LastMileService {
|
||||
const seen = new Set<string>();
|
||||
for (const vehicleId of desired) {
|
||||
const load = loads.get(vehicleId) ?? [];
|
||||
if (load.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
for (const n of load) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
if (seen.has(n)) {
|
||||
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||
}
|
||||
seen.add(n);
|
||||
}
|
||||
// A 40ft container fills the truck; only two 20ft share one.
|
||||
if (load.length > 1) {
|
||||
const sizes = await this.containerSizes(bookingId, load);
|
||||
if (sizes.some((s) => s.includes('40'))) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: load,
|
||||
bookingContainers: bookingNumbers,
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
|
||||
assignedElsewhere: [...seen],
|
||||
});
|
||||
load.forEach((n) => seen.add(n));
|
||||
}
|
||||
|
||||
if (desired.length > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||
assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* A truck that has already reached the customer cannot have its load rewritten
|
||||
* — the containers on it are a delivered fact, not a plan. The customer side
|
||||
* has locked this since it was built (`Cannot edit a truck that has already
|
||||
* arrived`); the EDR side let a reassignment silently rewrite history.
|
||||
*/
|
||||
private async assertNoArrivedVehicleChanged(
|
||||
current: LastMileVehicleAssignment[],
|
||||
desiredMap: Map<string, string[]>,
|
||||
): Promise<void> {
|
||||
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||
for (const assignment of current) {
|
||||
if (!assignment.arrivedAt) continue;
|
||||
const stillPresent = desiredMap.has(assignment.vehicleId);
|
||||
const load = desiredMap.get(assignment.vehicleId) ?? [];
|
||||
const currentLoad = (assignment.containers ?? []).map((c) =>
|
||||
c.containerNumber.trim().toUpperCase(),
|
||||
);
|
||||
if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
|
||||
throw new ConflictException(
|
||||
'This truck has already arrived — its load can no longer be changed or removed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +746,8 @@ export class LastMileService {
|
||||
where: { lastMileId: id },
|
||||
relations: { containers: true },
|
||||
});
|
||||
await this.assertNoArrivedVehicleChanged(current, desiredMap);
|
||||
|
||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||
// old single-vehicle path has no junction row but must still be freed.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { NEVER, Observable, throwError } from 'rxjs';
|
||||
|
||||
import { isBrokerConnected, publishConfirmed } from './broker.util';
|
||||
|
||||
/**
|
||||
* `ClientProxy.emit()` returns a cold Observable that, for RMQ, completes without
|
||||
* emitting once `dispatchEvent` settles — and rejects if the publish fails. These
|
||||
* fakes reproduce each of those three shapes.
|
||||
*/
|
||||
function clientEmitting(source: Observable<unknown>): ClientProxy {
|
||||
return { emit: jest.fn().mockReturnValue(source) } as unknown as ClientProxy;
|
||||
}
|
||||
|
||||
describe('publishConfirmed', () => {
|
||||
const logger = { error: jest.fn() } as unknown as Logger;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('is true when the publish completes (broker confirmed)', async () => {
|
||||
// Completes with no value — the success shape, and the case that throws
|
||||
// EmptyError without a defaultIfEmpty.
|
||||
const client = clientEmitting(new Observable<never>((s) => s.complete()));
|
||||
await expect(publishConfirmed(client, 'send-sms', {}, logger)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the publish never settles, rather than hanging', async () => {
|
||||
// A broker that is down: amqp-connection-manager buffers the publish and the
|
||||
// promise would never resolve. The timeout is what stops one dead broker from
|
||||
// hanging every caller of sendSms/sendEmail.
|
||||
const client = clientEmitting(NEVER);
|
||||
await expect(publishConfirmed(client, 'send-sms', {}, logger, 20)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is false when the publish errors', async () => {
|
||||
const client = clientEmitting(throwError(() => new Error('channel closed')));
|
||||
await expect(publishConfirmed(client, 'send-email', {}, logger)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBrokerConnected', () => {
|
||||
/** Stands in for `ClientProxy.unwrap()`, which returns the AmqpConnectionManager. */
|
||||
function clientUnwrapping(manager: unknown): ClientProxy {
|
||||
return { unwrap: () => manager } as unknown as ClientProxy;
|
||||
}
|
||||
|
||||
it('reports the connection manager state', () => {
|
||||
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => true }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => false }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when unwrap throws — the client never connected', () => {
|
||||
// ClientRMQ.unwrap() throws "Not initialized" while its internal client is
|
||||
// null, which is what a failed boot-time connect leaves behind. That is a
|
||||
// real down signal and must not be softened to "unknown".
|
||||
const uninitialised = {
|
||||
unwrap: () => {
|
||||
throw new Error('Not initialized. Please call the "connect" method first.');
|
||||
},
|
||||
} as unknown as ClientProxy;
|
||||
expect(isBrokerConnected(uninitialised)).toBe(false);
|
||||
});
|
||||
|
||||
it('is null — not a guess — when the manager lacks isConnected or it throws', () => {
|
||||
// Guards the health endpoint against reporting "ok" if amqp-connection-manager
|
||||
// or Nest changes shape and the accessor we rely on disappears.
|
||||
expect(isBrokerConnected(clientUnwrapping(null))).toBeNull();
|
||||
expect(isBrokerConnected(clientUnwrapping({}))).toBeNull();
|
||||
expect(
|
||||
isBrokerConnected(
|
||||
clientUnwrapping({
|
||||
isConnected: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// broker.util.ts
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { defaultIfEmpty, lastValueFrom, timeout } from "rxjs";
|
||||
|
||||
/**
|
||||
* How long to wait for a publisher confirm before giving up on a message.
|
||||
*
|
||||
* Load-bearing, not a nicety: when the broker is unreachable
|
||||
* amqp-connection-manager buffers the publish and retries it on reconnect, so the
|
||||
* underlying promise never settles. Without a bound, one dead broker turns every
|
||||
* caller of sendSms/sendEmail into a hung request.
|
||||
*/
|
||||
export const PUBLISH_CONFIRM_TIMEOUT_MS = Number(
|
||||
process.env.RABBITMQ_PUBLISH_TIMEOUT_MS ?? 5000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Publish an event and wait for RabbitMQ to confirm it.
|
||||
*
|
||||
* `ClientProxy.emit()` returns a *cold* Observable. Called without subscribing —
|
||||
* as this codebase did everywhere — nothing forces the publish to be observed, so
|
||||
* the caller reports success whether or not the broker ever accepted the message.
|
||||
* Awaiting it drives `dispatchEvent`, which resolves only once
|
||||
* amqp-connection-manager's ChannelWrapper has a publisher confirm.
|
||||
*
|
||||
* So `true` here means the broker took ownership of the message. It still says
|
||||
* nothing about the consumer, the SMS gateway, or delivery to a handset — those
|
||||
* remain outside this process's knowledge.
|
||||
*/
|
||||
export async function publishConfirmed(
|
||||
client: ClientProxy,
|
||||
pattern: string,
|
||||
payload: unknown,
|
||||
logger: Logger,
|
||||
timeoutMs: number = PUBLISH_CONFIRM_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// `emit` completes without emitting a value, so lastValueFrom needs a default
|
||||
// or it rejects with EmptyError on the success path.
|
||||
await lastValueFrom(
|
||||
client
|
||||
.emit(pattern, payload)
|
||||
.pipe(timeout(timeoutMs), defaultIfEmpty(undefined)),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`broker.publish.failed pattern='${pattern}' timeoutMs=${timeoutMs}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client's connection manager currently believes it is connected.
|
||||
*
|
||||
* Uses `ClientProxy.unwrap()` — Nest's public accessor for the underlying
|
||||
* transport client, which for `ClientRMQ` is the `AmqpConnectionManager`. Calling
|
||||
* `connect()` instead cannot answer this: it resolves against a *disconnected*
|
||||
* manager too, so it never distinguishes up from down.
|
||||
*
|
||||
* Three outcomes, deliberately distinct:
|
||||
* - `false` when the manager reports disconnected, or when `unwrap()` throws
|
||||
* because the client was never initialised (a failed boot-time connect leaves
|
||||
* it null — genuinely down, not unknown);
|
||||
* - `null` when the manager exists but has no `isConnected`, i.e. the library
|
||||
* shape changed under us — the health endpoint reports "unknown" rather than
|
||||
* quietly claiming health;
|
||||
* - `true` only on an explicit positive from the manager.
|
||||
*/
|
||||
export function isBrokerConnected(client: ClientProxy): boolean | null {
|
||||
let manager: unknown;
|
||||
try {
|
||||
manager = client.unwrap<unknown>();
|
||||
} catch {
|
||||
// "Not initialized. Please call the connect method first." — no connection
|
||||
// was ever established, which is a real down signal, not an unknown one.
|
||||
return false;
|
||||
}
|
||||
const probe = manager as { isConnected?: () => boolean } | null;
|
||||
if (!probe || typeof probe.isConnected !== "function") return null;
|
||||
try {
|
||||
return probe.isConnected();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { SendEmailDto } from "./dtos/email.dto";
|
||||
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||
|
||||
@Injectable()
|
||||
export class EmailClientService implements OnApplicationBootstrap {
|
||||
@@ -33,19 +34,34 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.emailClient.emit("send-email", {
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
const queued = await publishConfirmed(
|
||||
this.emailClient,
|
||||
"send-email",
|
||||
{
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||
// delivery — the consumer and the SMTP hop are downstream and invisible here.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state for the health endpoint. `null` means the broker client did
|
||||
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||
*/
|
||||
get brokerConnected(): boolean | null {
|
||||
if (!this.enabled) return false;
|
||||
return isBrokerConnected(this.emailClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
||||
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||
|
||||
@Injectable()
|
||||
export class SmsClientService implements OnApplicationBootstrap {
|
||||
@@ -14,7 +15,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@Inject("SMS_SERVICE")
|
||||
private smsClient: ClientProxy,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
|
||||
@@ -26,7 +27,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.log("connected to SMS service");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error happened at SMS service", err);
|
||||
this.logger.error("Error happened at SMS service", err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,34 +36,61 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.smsClient.emit("send-sms", {
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
const queued = await publishConfirmed(
|
||||
this.smsClient,
|
||||
"send-sms",
|
||||
{
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||
// delivery — the consumer, the SMS gateway and the carrier are all downstream
|
||||
// of this and invisible from here.
|
||||
this.logger.log(
|
||||
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
|
||||
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||
this.logger.warn(
|
||||
`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`,
|
||||
);
|
||||
return { queued: false };
|
||||
}
|
||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||
this.smsClient.emit("ozeking-bulk-sms", {
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
const messages = (dto.messages ?? []).map((m) => ({
|
||||
to: m.to,
|
||||
text: m.message,
|
||||
from: m.from,
|
||||
}));
|
||||
const queued = await publishConfirmed(
|
||||
this.smsClient,
|
||||
"ozeking-bulk-sms",
|
||||
{
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
this.logger.log(
|
||||
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
|
||||
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
|
||||
);
|
||||
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state for the health endpoint. `null` means the broker client did
|
||||
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||
*/
|
||||
get brokerConnected(): boolean | null {
|
||||
if (!this.enabled) return false;
|
||||
return isBrokerConnected(this.smsClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
import { OtpService, OtpTarget } from "./otp.service";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
// Exactly one of phone/email must be present per request — the channel the
|
||||
// code is sent through / checked against.
|
||||
// At least one of phone/email must be present. When BOTH are given the code is
|
||||
// sent to both and either one verifies it — the caller no longer picks a single
|
||||
// channel, it just states every address it knows for the account.
|
||||
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||
if (email) return { email };
|
||||
if (phone) return { phone };
|
||||
throw new BadRequestException("phone or email is required");
|
||||
const target: OtpTarget = {};
|
||||
if (email?.trim()) target.email = email;
|
||||
if (phone?.trim()) target.phone = phone;
|
||||
if (!target.email && !target.phone) {
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||
@@ -41,7 +46,13 @@ export class OtpController {
|
||||
@Body("email")
|
||||
email?: string
|
||||
) {
|
||||
return this.otpService.sendOtp(toTarget(phone, email));
|
||||
// `delivered` stays server-side: this route is @Public(), and whether our
|
||||
// broker accepted the publish is infrastructure state an anonymous caller has
|
||||
// no need for. It is on the `otp.dispatch` log line instead.
|
||||
const { success, message } = await this.otpService.sendOtp(
|
||||
toTarget(phone, email)
|
||||
);
|
||||
return { success, message };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,10 +4,12 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOptionsWhere, Repository } from "typeorm";
|
||||
|
||||
import { OtpVerification } from "./otp.entity";
|
||||
|
||||
type Target = { phone?: string; email?: string };
|
||||
|
||||
@Injectable()
|
||||
export class OtpRepository {
|
||||
constructor(
|
||||
@@ -46,54 +48,112 @@ export class OtpRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find By Target (either channel)
|
||||
// Find By Target (any named channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async findByTarget(
|
||||
target: { phone?: string; email?: string }
|
||||
) {
|
||||
return target.email
|
||||
? this.findByEmail(target.email)
|
||||
: this.findByPhone(target.phone!);
|
||||
}
|
||||
/**
|
||||
* OR across every channel the target names. A code sent to both phone and
|
||||
* email lives in ONE row carrying both values, so a verify that quotes either
|
||||
* one resolves the same row — that is what makes "sent to both, verify with
|
||||
* either" work.
|
||||
*/
|
||||
private whereForTarget(
|
||||
target: Target
|
||||
): FindOptionsWhere<OtpVerification>[] {
|
||||
const where: FindOptionsWhere<OtpVerification>[] =
|
||||
[];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createOtp(
|
||||
target: { phone?: string; email?: string },
|
||||
otp: string
|
||||
) {
|
||||
const entity =
|
||||
this.repository.create({
|
||||
phone: target.phone,
|
||||
if (target.email)
|
||||
where.push({
|
||||
email: target.email,
|
||||
otp,
|
||||
verified: false,
|
||||
});
|
||||
|
||||
return this.repository.save(
|
||||
entity
|
||||
if (target.phone)
|
||||
where.push({
|
||||
phone: target.phone,
|
||||
});
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
async findAllByTarget(
|
||||
target: Target
|
||||
) {
|
||||
const where =
|
||||
this.whereForTarget(target);
|
||||
|
||||
if (!where.length) return [];
|
||||
|
||||
// Newest first: a target that somehow overlaps two legacy single-channel
|
||||
// rows should resolve to the most recently issued code, not an arbitrary one.
|
||||
return this.repository.find({
|
||||
where,
|
||||
order: { updatedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findByTarget(
|
||||
target: Target
|
||||
) {
|
||||
const [
|
||||
newest,
|
||||
] = await this.findAllByTarget(
|
||||
target
|
||||
);
|
||||
|
||||
return newest ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update OTP
|
||||
// Replace OTP (upsert across every channel the target names)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async updateOtp(
|
||||
otpVerification: OtpVerification,
|
||||
/**
|
||||
* Drop every row this target overlaps and write a single fresh one holding
|
||||
* all its channels.
|
||||
*
|
||||
* `phone` and `email` are each UNIQUE, so a dual-channel send can collide with
|
||||
* up to two pre-existing single-channel rows (say an old signup code on the
|
||||
* phone and a reset code on the email). Merging into one row instead of
|
||||
* updating in place is what keeps that from raising a unique violation, and it
|
||||
* preserves the single-use guarantee: consuming the code deletes one row and
|
||||
* kills every channel it was sent to at once.
|
||||
*
|
||||
* "Last code sent wins" was already the behaviour between any two flows
|
||||
* sharing this table — this only widens it from one channel to all of them.
|
||||
*/
|
||||
async replaceOtp(
|
||||
target: Target,
|
||||
otp: string
|
||||
) {
|
||||
otpVerification.otp = otp;
|
||||
): Promise<{
|
||||
record: OtpVerification;
|
||||
rotated: boolean;
|
||||
}> {
|
||||
const existing =
|
||||
await this.findAllByTarget(
|
||||
target
|
||||
);
|
||||
|
||||
otpVerification.verified =
|
||||
false;
|
||||
if (existing.length) {
|
||||
await this.repository.remove(
|
||||
existing
|
||||
);
|
||||
}
|
||||
|
||||
return this.repository.save(
|
||||
otpVerification
|
||||
);
|
||||
const record =
|
||||
await this.repository.save(
|
||||
this.repository.create({
|
||||
phone: target.phone,
|
||||
email: target.email,
|
||||
otp,
|
||||
verified: false,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
record,
|
||||
rotated: existing.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -115,8 +175,8 @@ export class OtpRepository {
|
||||
// Delete OTP (single-use consume)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
||||
// requested for the same number on the next action.
|
||||
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
|
||||
// be requested for the same target on the next action.
|
||||
async deleteOtp(
|
||||
otpVerification: OtpVerification
|
||||
) {
|
||||
@@ -124,4 +184,4 @@ export class OtpRepository {
|
||||
otpVerification
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('passes email targets through untouched', () => {
|
||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||
});
|
||||
|
||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
@@ -21,41 +28,216 @@ describe('normalizeOtpTarget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
||||
// the test proves normalisation makes send and verify collide on one key.
|
||||
function makeService() {
|
||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
||||
const repo = {
|
||||
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
|
||||
rows.get(t.email ?? t.phone!) ?? null,
|
||||
),
|
||||
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
|
||||
existing.otp = otp;
|
||||
}),
|
||||
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
|
||||
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
|
||||
}),
|
||||
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
|
||||
rows.delete(row.phone ?? row.email!);
|
||||
}),
|
||||
};
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, rows };
|
||||
}
|
||||
interface FakeRow {
|
||||
id: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
otp: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory stand-in for OtpRepository, mirroring the two properties the service
|
||||
* depends on: rows are matched by OR across every channel named, and a send
|
||||
* replaces all overlapping rows with one row carrying every channel.
|
||||
*/
|
||||
function makeService(
|
||||
transports: {
|
||||
sms?: () => Promise<{ queued: boolean }>;
|
||||
email?: () => Promise<{ queued: boolean }>;
|
||||
} = {},
|
||||
) {
|
||||
let rows: FakeRow[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
||||
|
||||
const repo = {
|
||||
findByTarget: jest.fn(
|
||||
async (t: { phone?: string; email?: string }) =>
|
||||
rows.filter((row) => matches(row, t))[0] ?? null,
|
||||
),
|
||||
replaceOtp: jest.fn(
|
||||
async (t: { phone?: string; email?: string }, otp: string) => {
|
||||
const overlapping = rows.filter((row) => matches(row, t));
|
||||
rows = rows.filter((row) => !overlapping.includes(row));
|
||||
const record: FakeRow = {
|
||||
id: String(nextId++),
|
||||
...t,
|
||||
otp,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
rows.push(record);
|
||||
return { record, rotated: overlapping.length > 0 };
|
||||
},
|
||||
),
|
||||
deleteOtp: jest.fn(async (row: FakeRow) => {
|
||||
rows = rows.filter((r) => r !== row);
|
||||
}),
|
||||
};
|
||||
|
||||
// Both clients return `{ queued }` — the service reads it to tell a published
|
||||
// code apart from one the transport silently dropped.
|
||||
const sms = {
|
||||
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
|
||||
};
|
||||
const email = {
|
||||
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
|
||||
};
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, sms, email, rows: () => rows };
|
||||
}
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
const stored = [...rows.values()][0]!.otp;
|
||||
|
||||
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
|
||||
[...rows.values()][0]!.updatedAt = new Date();
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — dual-channel send', () => {
|
||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||
|
||||
it('sends ONE code to both transports', async () => {
|
||||
const { service, sms, email, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
const otp = rows()[0]!.otp;
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||
// Same secret on both messages — the user types whichever arrives first.
|
||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: '+251986680099',
|
||||
message: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'user@example.com',
|
||||
text: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
// One row, both channels canonicalised.
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({
|
||||
phone: '+251986680099',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['phone alone', { phone: '0986680099' }],
|
||||
['email alone', { email: 'user@example.com' }],
|
||||
['both', both],
|
||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('consuming the code via one channel kills the other', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
|
||||
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
||||
|
||||
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||
).rejects.toThrow(/No verification code was requested/);
|
||||
});
|
||||
|
||||
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
||||
const { service, rows } = makeService();
|
||||
// A pending signup code on the phone only, then a dual-channel send.
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp(both);
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
||||
});
|
||||
|
||||
it('degrades to one channel when the account has only one contact', async () => {
|
||||
const { service, sms, email } = makeService();
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still succeeds when one transport throws', async () => {
|
||||
const { service, rows } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.sendOtp(both)).resolves.toMatchObject({
|
||||
success: true,
|
||||
delivered: true,
|
||||
});
|
||||
// The code is live and verifiable on the channel that worked.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('fails the request when every transport throws', async () => {
|
||||
const { service } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
},
|
||||
email: async () => {
|
||||
throw new Error('broker down');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
||||
});
|
||||
|
||||
it('shares one brute-force budget across both channels', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
|
||||
// Alternating channels must not hand the attacker two independent budgets:
|
||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||
for (const target of [
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
]) {
|
||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||
'Invalid verification code',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||
).rejects.toThrow(/Too many incorrect attempts/);
|
||||
|
||||
// Burned: even the correct code no longer works.
|
||||
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
|
||||
/No verification code was requested/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,24 @@ import { OtpRepository } from "./otp.repository";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
|
||||
// Exactly one of phone/email is set — enforced by the controller before it
|
||||
// reaches here.
|
||||
/**
|
||||
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||
* controller and re-checked here. When BOTH are set the same code is sent to
|
||||
* both and either one can be used to verify it: a user who never receives the
|
||||
* SMS can still finish from their inbox, and vice versa. Callers that resolve
|
||||
* contacts from IAM pass whatever the account actually has, so an account with
|
||||
* only one of the two silently degrades to a single channel.
|
||||
*/
|
||||
export type OtpTarget = { phone?: string; email?: string };
|
||||
|
||||
/** Which transports a target resolves to, in a stable order for logging. */
|
||||
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||
const channels: Array<"email" | "sms"> = [];
|
||||
if (target.email) channels.push("email");
|
||||
if (target.phone) channels.push("sms");
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
||||
* up on verify collide regardless of how the number was typed. Without this,
|
||||
@@ -19,19 +33,51 @@ export type OtpTarget = { phone?: string; email?: string };
|
||||
* a code sent to one is invisible to the others — the send/verify halves must
|
||||
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||
* Email targets pass through untouched.
|
||||
*/
|
||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||
if (target.email || !target.phone) return target;
|
||||
const raw = target.phone.trim();
|
||||
function normalizePhone(rawPhone: string): string {
|
||||
const raw = rawPhone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return { phone: digits };
|
||||
if (digits.startsWith('+')) return digits;
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
// it looks like a full international number, else leave as typed.
|
||||
return { phone: digits.length >= 11 ? `+${digits}` : raw };
|
||||
return digits.length >= 11 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise every channel present on the target. Each field is normalised
|
||||
* independently — a dual-channel target must end up with both halves in their
|
||||
* canonical form, since verify may arrive naming either one.
|
||||
*/
|
||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||
const normalized: OtpTarget = {};
|
||||
|
||||
if (target.email?.trim()) {
|
||||
// Same contract as the phone branch: the string stored on send and the one
|
||||
// looked up on verify must be byte-identical, or the code is invisible to
|
||||
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
|
||||
// or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
|
||||
// trailing space are three different keys for one mailbox. Domains are
|
||||
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
|
||||
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
|
||||
// matching what users expect beats matching the letter of the spec here.
|
||||
normalized.email = target.email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (target.phone?.trim()) {
|
||||
normalized.phone = normalizePhone(target.phone);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** One transport's hand-off outcome. Never thrown — collected and reported. */
|
||||
interface DispatchOutcome {
|
||||
channel: "email" | "sms";
|
||||
queued: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -58,25 +104,36 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(rawTarget: OtpTarget) {
|
||||
// Store under the canonical E.164 key so verify (which normalises the same
|
||||
// way) always finds this row regardless of how either side typed the number.
|
||||
// Store under the canonical keys so verify (which normalises the same way)
|
||||
// always finds this row regardless of how either side typed the number.
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const channels = channelsOf(target);
|
||||
const label = this.targetLabel(target);
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (channels.length === 0) {
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
// recipient of the SMS/email.
|
||||
// recipient of the SMS/email. ONE code covers every channel: the user
|
||||
// types whichever message reaches them first.
|
||||
const otp = this.generateOtp();
|
||||
|
||||
// find existing row for this channel
|
||||
const existing = await this.otpRepository.findByTarget(target);
|
||||
// Replaces every row this target overlaps with, so a dual-channel send
|
||||
// leaves exactly one row holding both halves — verify then resolves the
|
||||
// same row whichever channel it is given.
|
||||
const { rotated } = await this.otpRepository.replaceOtp(target, otp);
|
||||
|
||||
// update existing otp
|
||||
if (existing) {
|
||||
await this.otpRepository.updateOtp(existing, otp);
|
||||
} else {
|
||||
// create new otp
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
// `rotate` means a code already existed for this target and was replaced —
|
||||
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||
// typing its code will fail against the row; this line is how that shows up
|
||||
// in the log rather than as an unexplained "invalid OTP" report.
|
||||
this.logger.log(
|
||||
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||
);
|
||||
|
||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||
// here let an attacker wipe the per-target guess budget just by calling
|
||||
@@ -86,40 +143,180 @@ export class OtpService {
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
});
|
||||
} else {
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
// Fan out to every channel the target has, independently: one transport
|
||||
// being down must not suppress the other, which is the whole point of
|
||||
// sending to both. Each helper swallows its own failure so a rejected
|
||||
// email publish still leaves the SMS delivered (and the code valid).
|
||||
const outcomes = (
|
||||
await Promise.all([
|
||||
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||
])
|
||||
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
||||
// Every channel threw. Nothing can arrive and there is no partial success
|
||||
// to preserve — fail the request the way a single-channel send always did.
|
||||
if (outcomes.every((outcome) => outcome.error)) {
|
||||
throw new Error(
|
||||
outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
// Both clients report hand-off, not delivery — capture it rather than
|
||||
// discarding it, so "delivered=false" is distinguishable from a code that
|
||||
// was published fine and lost downstream at the carrier.
|
||||
const delivered = outcomes.some((outcome) => outcome.queued);
|
||||
|
||||
if (!delivered) {
|
||||
// The row is committed and we are about to answer "OTP sent successfully",
|
||||
// but nothing left this process. Without this line the only symptom is a
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — no transport reported hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: this logs a live credential in cleartext. Anyone with read
|
||||
// access to the log stream can complete a password reset or a contract
|
||||
// signature for the address on the same line. Kept deliberately (log
|
||||
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
|
||||
// is ever revisited, gate on an env flag rather than deleting the line, so
|
||||
// dev keeps its workflow.
|
||||
this.logger.log(`OTP send for ${label}: ${otp}`);
|
||||
return {
|
||||
success: true,
|
||||
|
||||
// Distinguishes "we published it" from "the transport is a no-op". The
|
||||
// HTTP response shape is unchanged; the controller drops this field.
|
||||
delivered,
|
||||
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new BadRequestException("Failed to send OTP");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish to one transport, converting a throw into a reported outcome. A
|
||||
* broker error on one channel must not abort the other — with dual-channel
|
||||
* sends the user still has a working route to the code.
|
||||
*/
|
||||
private async dispatchEmail(
|
||||
email: string,
|
||||
otp: string,
|
||||
): Promise<DispatchOutcome> {
|
||||
try {
|
||||
const { queued } = await this.emailClient.sendEmail({
|
||||
to: email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
});
|
||||
return { channel: "email", queued };
|
||||
} catch (error) {
|
||||
return {
|
||||
channel: "email",
|
||||
queued: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
|
||||
private async dispatchSms(
|
||||
phone: string,
|
||||
otp: string,
|
||||
): Promise<DispatchOutcome> {
|
||||
try {
|
||||
const { queued } = await this.smsClient.sendSms({
|
||||
to: phone,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
return { channel: "sms", queued };
|
||||
} catch (error) {
|
||||
return {
|
||||
channel: "sms",
|
||||
queued: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation key shared by every `otp.*` line for one target, so a send and
|
||||
* its later verify can be joined with a single grep. The raw values are used
|
||||
* because the code itself is already logged in cleartext above — hashing the
|
||||
* address while printing the credential next to it would buy nothing.
|
||||
*/
|
||||
private targetLabel(target: OtpTarget): string {
|
||||
return (
|
||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per verify exit path. `result` is a closed set — ok | invalid |
|
||||
* expired | exhausted | not_found — so failures can be counted by reason
|
||||
* instead of inferred from error strings that the frontend also depends on.
|
||||
*/
|
||||
private logVerify(
|
||||
target: OtpTarget,
|
||||
mode: "simple" | "action",
|
||||
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
|
||||
detail?: string,
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* "No code for this target" phrased for whichever channels were named. A
|
||||
* dual-channel caller gets a neutral message — naming one channel would be
|
||||
* misleading when the code went to both.
|
||||
*/
|
||||
private notFoundMessage(target: OtpTarget, requested: boolean): string {
|
||||
const channels = channelsOf(target);
|
||||
if (channels.length !== 1) {
|
||||
return requested
|
||||
? "No verification code was requested for this account"
|
||||
: "No verification code found for this account";
|
||||
}
|
||||
if (target.email) {
|
||||
return requested
|
||||
? "No verification code was requested for this email"
|
||||
: "Email address not found";
|
||||
}
|
||||
return requested
|
||||
? "No verification code was requested for this phone"
|
||||
: "Phone number not found";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,23 +325,36 @@ export class OtpService {
|
||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||
// when verify is called with 09… (or any equivalent form).
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
// find the channel's row
|
||||
// Matches on ANY channel the caller named, so a code sent to both phone and
|
||||
// email verifies whichever one the user quotes back.
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
// not found
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
target.email ? "Email address not found" : "Phone number not found",
|
||||
);
|
||||
// No row for this target. Most often a normalisation mismatch or a code
|
||||
// that was already consumed/burned — not necessarily a caller who never
|
||||
// asked.
|
||||
this.logVerify(target, "simple", "not_found");
|
||||
throw new BadRequestException(this.notFoundMessage(target, false));
|
||||
}
|
||||
|
||||
// Key the attempt budget on the ROW, not on the channels the caller happened
|
||||
// to name — otherwise guessing alternately by phone and by email would hand
|
||||
// an attacker two independent budgets against the same code.
|
||||
const key = otpData.id;
|
||||
|
||||
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||
// code can't be verified.
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"expired",
|
||||
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -157,24 +367,36 @@ export class OtpService {
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// single-use: consume the code on success so it can't be replayed.
|
||||
// single-use: consume the code on success so it can't be replayed. One row
|
||||
// covers every channel it was sent to, so this kills all of them at once.
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
message: target.email
|
||||
? "Email verified successfully"
|
||||
: "Phone verified successfully",
|
||||
message: "Verification successful",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,10 +418,6 @@ export class OtpService {
|
||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||
private readonly actionAttempts = new Map<string, number>();
|
||||
|
||||
private targetKey(target: OtpTarget): string {
|
||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
rawTarget: OtpTarget,
|
||||
otp: string,
|
||||
@@ -207,22 +425,21 @@ export class OtpService {
|
||||
) {
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
: "No verification code was requested for this phone",
|
||||
);
|
||||
this.logVerify(target, "action", "not_found");
|
||||
throw new BadRequestException(this.notFoundMessage(target, true));
|
||||
}
|
||||
|
||||
// Row-keyed for the same reason as verifyOtp: one code, one budget.
|
||||
const key = otpData.id;
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
|
||||
if (ageMs > ttlMs) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -235,18 +452,31 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||
hasWarehouse!: boolean;
|
||||
|
||||
/**
|
||||
* Containers need a reach stacker or gantry, so only the equipped facilities
|
||||
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
|
||||
* everywhere.
|
||||
*/
|
||||
@Column({ name: 'handles_container', type: 'boolean', default: true })
|
||||
handlesContainer!: boolean;
|
||||
|
||||
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
||||
handlesBulk!: boolean;
|
||||
|
||||
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||
equipmentNotes?: string | null;
|
||||
|
||||
|
||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
||||
hasFacility: boolean;
|
||||
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||
hasWarehouse: boolean;
|
||||
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||
handlesContainer: boolean;
|
||||
handlesBulk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which yards can handle cargo, and how.
|
||||
* Which yards can handle cargo, and what kind.
|
||||
*
|
||||
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
||||
* `yard_facilities` record says whether it also stores cargo. Facilities without a
|
||||
* warehouse move cargo on and off the train and nothing more — no storage, no
|
||||
* demurrage. This is the single resolver the journey and handling flows use, so
|
||||
* they can't drift on what a facility is.
|
||||
* `yard_facilities` record says what it can actually do — whether it stores cargo
|
||||
* (storage/demurrage), and which freight types its equipment can lift. Containers
|
||||
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
|
||||
* bulk is handled at all five.
|
||||
*
|
||||
* This is the single resolver the journey and handling flows use, so they can't
|
||||
* drift on what a facility is or what it can lift.
|
||||
*/
|
||||
@Injectable()
|
||||
export class YardFacilitiesService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row]: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
if (!row) return null;
|
||||
private readonly SELECT = `
|
||||
SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse",
|
||||
f.handles_container AS "handlesContainer",
|
||||
f.handles_bulk AS "handlesBulk"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||
|
||||
private toInfo(row: {
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
handlesContainer: boolean | null;
|
||||
handlesBulk: boolean | null;
|
||||
}): YardFacilityInfo {
|
||||
// No facility record means no capability, whatever the flag says.
|
||||
const hasFacility = Boolean(row.hasFacility);
|
||||
return {
|
||||
yardId: row.yardId,
|
||||
yardCode: row.yardCode,
|
||||
yardLabel: row.yardLabel,
|
||||
hasFacility: Boolean(row.hasFacility),
|
||||
// No facility record means no warehouse, whatever the flag says.
|
||||
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
||||
hasFacility,
|
||||
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
||||
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
return row ? this.toInfo(row) : null;
|
||||
}
|
||||
|
||||
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||
const rows: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.deleted_at IS NULL
|
||||
AND y.is_active = true
|
||||
AND y.has_facility = true
|
||||
const rows = await this.dataSource.query(
|
||||
`${this.SELECT}
|
||||
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
|
||||
ORDER BY y.display_order ASC, y.label ASC`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
yardId: r.yardId,
|
||||
yardCode: r.yardCode,
|
||||
yardLabel: r.yardLabel,
|
||||
hasFacility: true,
|
||||
hasWarehouse: Boolean(r.hasWarehouse),
|
||||
}));
|
||||
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||
* so callers can't get it subtly wrong.
|
||||
*/
|
||||
canHandleFreight(
|
||||
facility: YardFacilityInfo | null,
|
||||
freightType: string | null | undefined,
|
||||
): boolean {
|
||||
if (!facility?.hasFacility) return false;
|
||||
return String(freightType).toUpperCase() === 'CONTAINER'
|
||||
? facility.handlesContainer
|
||||
: facility.handlesBulk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from "@edr/types";
|
||||
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
|
||||
|
||||
/** Multipart field name carrying chat files. */
|
||||
export const SUPPORT_ATTACHMENT_FIELD = "attachments";
|
||||
|
||||
/**
|
||||
* Multer-level caps for the chat send routes.
|
||||
*
|
||||
* These duplicate the checks in `SupportChatService.assertSendable` on purpose,
|
||||
* and are not a substitute for them: Multer stops reading the socket once a part
|
||||
* exceeds `fileSize`, so an oversized upload is cut off mid-stream instead of
|
||||
* being buffered into memory and rejected after the fact. The service-level
|
||||
* check is what produces the readable error message and covers callers that
|
||||
* don't come through this interceptor.
|
||||
*/
|
||||
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||
"of messages immediately OLDER than the cursor. Omit for the newest page.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class SendMessageDto implements ISendSupportMessageDto {
|
||||
@ApiProperty({ description: "Message text." })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Message text. Optional only when the request carries attachments — the " +
|
||||
"service rejects a message that is neither text nor files.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
body!: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
|
||||
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||
authorName?: string | null;
|
||||
|
||||
@Column({ name: "body", type: "text" })
|
||||
body!: string;
|
||||
/**
|
||||
* NULL for an attachment-only message. Nullable rather than "" so the absence
|
||||
* of text is representable instead of guessed at; the DTO maps NULL → "".
|
||||
*/
|
||||
@Column({ name: "body", type: "text", nullable: true })
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Keyset cursor for paging a thread backwards from newest.
|
||||
*
|
||||
* The cursor is just a message id. The sort key is the pair `(created_at, id)` —
|
||||
* two messages can share a timestamp, and a cursor on a non-unique key either
|
||||
* re-serves or skips the tied rows — but the *timestamp half is never sent over
|
||||
* the wire*, because it cannot survive the trip.
|
||||
*
|
||||
* `support_messages.created_at` is `timestamptz(6)`; a JS `Date` holds only
|
||||
* milliseconds, so the value TypeORM hands back is already truncated. Encoding
|
||||
* that into the cursor and comparing against it would silently skip every row
|
||||
* sharing the cursor's millisecond but earlier within it (`.254100` is not
|
||||
* `< .254000`) — those rows would never appear on any page. Sending the id alone
|
||||
* and letting Postgres look the real `(created_at, id)` up keeps the comparison
|
||||
* at full precision on the server, where it was never lossy.
|
||||
*
|
||||
* Opaque on purpose (base64): clients must treat it as a token, so the sort key
|
||||
* can change without a contract change.
|
||||
*
|
||||
* The passenger API's twin encodes a timestamp because its column is
|
||||
* `TIMESTAMP(3)` — millisecond, matching JS exactly — so it has no such loss.
|
||||
* The two formats are deliberately NOT interchangeable; each app reads only its
|
||||
* own cursors.
|
||||
*/
|
||||
export function encodeMessageCursor(id: string): string {
|
||||
return Buffer.from(id, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||
*/
|
||||
export function decodeMessageCursor(raw: string): string {
|
||||
let id: string;
|
||||
try {
|
||||
id = Buffer.from(raw, "base64url").toString("utf8");
|
||||
} catch {
|
||||
throw new BadRequestException("Malformed pagination cursor.");
|
||||
}
|
||||
|
||||
// The id goes into a parameterized query, but validate the shape anyway: a
|
||||
// non-uuid can only be a mangled cursor, and failing loudly here beats an
|
||||
// empty page that reads as "start of conversation".
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
) {
|
||||
throw new BadRequestException("Malformed pagination cursor.");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
/**
|
||||
* Authenticated download for chat attachments.
|
||||
*
|
||||
* This exists instead of reusing `GET /files/:fileId` because that route streams
|
||||
* any file to any authenticated caller who knows its UUID — fine-ish for a
|
||||
* booking document the caller already had a link to, not fine for chat, where
|
||||
* one customer guessing another's file id would be a cross-tenant leak. That
|
||||
* route now refuses `support_message` files outright and points here.
|
||||
*
|
||||
* Inline previews still use the short-lived signed URL on the message DTO — a
|
||||
* browser `<img>` can't send a Bearer token. This route is for explicit
|
||||
* downloads and for clients that would rather stream through the API.
|
||||
*/
|
||||
@ApiTags("support-chat")
|
||||
@ApiBearerAuth()
|
||||
@Controller("support/attachments")
|
||||
export class SupportAttachmentController {
|
||||
constructor(
|
||||
private readonly files: FilesService,
|
||||
private readonly chat: SupportChatService,
|
||||
) {}
|
||||
|
||||
@Get(":fileId")
|
||||
@ApiOperation({
|
||||
summary: "Download a support chat attachment",
|
||||
description:
|
||||
"Streams the file only if the caller is backoffice staff or belongs to the " +
|
||||
"company that owns the thread the attachment was posted in.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
required: false,
|
||||
description: "Set to 1/true to force a download instead of inline preview.",
|
||||
})
|
||||
async download(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.files.findById(fileId);
|
||||
|
||||
// Don't let this route become a second general-purpose file endpoint: it can
|
||||
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
|
||||
// no reason to confirm the id exists).
|
||||
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
|
||||
throw new NotFoundException(`File ${fileId} not found`);
|
||||
}
|
||||
|
||||
const allowed = await this.chat.canUserAccessMessage(
|
||||
record.resourceId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
"This attachment belongs to another company's conversation.",
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.files.streamById(fileId);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
|
||||
res.setHeader("Content-Type", record.mimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
|
||||
);
|
||||
// Private only — this response is scoped to one caller's authorization, so a
|
||||
// shared cache must never reuse it for the next person asking.
|
||||
res.setHeader("Cache-Control", "private, max-age=300");
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SupportAuthorRole,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -8,14 +11,22 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from "./attachment-upload.options";
|
||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
|
||||
}
|
||||
|
||||
@Get("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "List messages in a thread" })
|
||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.getMessages(id);
|
||||
@ApiOperation({
|
||||
summary: "List messages in a thread (newest page first)",
|
||||
description:
|
||||
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||
"back through history. `nextCursor: null` means the thread's start.",
|
||||
})
|
||||
messages(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getMessages(id, query);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "Reply as an agent" })
|
||||
@UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
)
|
||||
// Accepts multipart (text + files) or plain JSON (text only) — Multer passes
|
||||
// non-multipart requests straight through, so existing JSON clients are
|
||||
// unaffected.
|
||||
@ApiConsumes("multipart/form-data", "application/json")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
attachments: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: "Reply as an agent, optionally with attachments" })
|
||||
send(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
|
||||
return this.service.sendAsAgent(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
body.body,
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/read")
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SupportAuthorRole,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from "./attachment-upload.options";
|
||||
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
@@ -29,17 +46,55 @@ export class SupportChatController {
|
||||
}
|
||||
|
||||
@Get("conversation/messages")
|
||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
||||
messages(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
||||
@ApiOperation({
|
||||
summary: "Messages in my company's support thread (newest page first)",
|
||||
description:
|
||||
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||
"back through history. `nextCursor: null` means the thread's start.",
|
||||
})
|
||||
messages(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getCustomerMessages(resolveAuthUserId(user), query);
|
||||
}
|
||||
|
||||
@Post("conversation/messages")
|
||||
@ApiOperation({
|
||||
summary: "Send a message as the customer, opening the thread if needed",
|
||||
@UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data", "application/json")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
attachments: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
|
||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send a message as the customer (optionally with attachments), opening the thread if needed",
|
||||
})
|
||||
send(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendAsCustomer(
|
||||
resolveAuthUserId(user),
|
||||
body.body,
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post("conversation/read")
|
||||
|
||||
@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
import { SupportAttachmentController } from "./support-attachment.controller";
|
||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||
import { SupportChatController } from "./support-chat.controller";
|
||||
import { SupportChatGateway } from "./support-chat.gateway";
|
||||
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
|
||||
BackofficeModule,
|
||||
// WsAuthService — reused handshake authentication for the gateway.
|
||||
NotificationInboxModule,
|
||||
// FilesService — chat attachments are stored as polymorphic file records.
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [
|
||||
SupportChatController,
|
||||
SupportChatAgentController,
|
||||
SupportAttachmentController,
|
||||
],
|
||||
controllers: [SupportChatController, SupportChatAgentController],
|
||||
providers: [
|
||||
SupportConversationRepository,
|
||||
SupportMessageRepository,
|
||||
SupportChatGateway,
|
||||
SupportChatService,
|
||||
],
|
||||
// FilesController's ownership check for `support_message` files defers to this
|
||||
// service — see SupportAttachmentAccess.
|
||||
exports: [SupportChatService],
|
||||
})
|
||||
export class SupportChatModule {}
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
SendSupportMessageResult,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SUPPORT_ATTACHMENT_RESOURCE,
|
||||
SupportAttachmentDto,
|
||||
SupportAuthorRole,
|
||||
SupportConversationDto,
|
||||
SupportConversationListResult,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { QueryFailedError } from "typeorm";
|
||||
|
||||
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||
import {
|
||||
ListMessagesQueryDto,
|
||||
SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
} from "./dto/list-messages-query.dto";
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
|
||||
import { SupportChatGateway } from "./support-chat.gateway";
|
||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||
import { SupportMessageRepository } from "./support-message.repository";
|
||||
@@ -30,6 +45,9 @@ interface CustomerContext {
|
||||
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/** Stand-in preview for a message that is nothing but files. */
|
||||
const ATTACHMENT_ONLY_PREVIEW = "📎";
|
||||
|
||||
@Injectable()
|
||||
export class SupportChatService {
|
||||
constructor(
|
||||
@@ -38,6 +56,8 @@ export class SupportChatService {
|
||||
private readonly gateway: SupportChatGateway,
|
||||
private readonly externalProfiles: ExternalProfileRepository,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly files: FilesService,
|
||||
private readonly backoffice: BackofficeService,
|
||||
) {}
|
||||
|
||||
// ---- customer (portal) -------------------------------------------------
|
||||
@@ -51,7 +71,9 @@ export class SupportChatService {
|
||||
userId: string,
|
||||
): Promise<SupportConversationDto | null> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (!conversation) return null;
|
||||
const unread = await this.messages.unreadCountsByConversation(
|
||||
[conversation.id],
|
||||
@@ -63,17 +85,23 @@ export class SupportChatService {
|
||||
);
|
||||
}
|
||||
|
||||
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
|
||||
async getCustomerMessages(
|
||||
userId: string,
|
||||
query: ListMessagesQueryDto = {},
|
||||
): Promise<SupportMessageListResult> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
if (!conversation) return [];
|
||||
return this.listMessages(conversation.id);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (!conversation) return { items: [], nextCursor: null };
|
||||
return this.listMessages(conversation.id, query);
|
||||
}
|
||||
|
||||
/** Send as the customer, opening the thread if this is the first message. */
|
||||
async sendAsCustomer(
|
||||
userId: string,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<SendSupportMessageResult> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.getOrCreate(
|
||||
@@ -87,16 +115,19 @@ export class SupportChatService {
|
||||
SupportAuthorRole.CUSTOMER,
|
||||
body,
|
||||
ctx.authorName,
|
||||
attachments,
|
||||
);
|
||||
return {
|
||||
conversation: this.toConversationDto(updated, 0),
|
||||
message: this.toMessageDto(message),
|
||||
message: message,
|
||||
};
|
||||
}
|
||||
|
||||
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (conversation) {
|
||||
await this.conversations.update(conversation.id, {
|
||||
customerLastReadAt: new Date(),
|
||||
@@ -145,7 +176,8 @@ export class SupportChatService {
|
||||
async sendAsAgent(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<SupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
const { message } = await this.appendMessage(
|
||||
@@ -153,8 +185,10 @@ export class SupportChatService {
|
||||
userId,
|
||||
SupportAuthorRole.AGENT,
|
||||
body,
|
||||
undefined,
|
||||
attachments,
|
||||
);
|
||||
return this.toMessageDto(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
async markAgentRead(
|
||||
@@ -171,18 +205,56 @@ export class SupportChatService {
|
||||
// ---- shared ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
|
||||
* company owns it (portal route); omit for agents, who see every thread.
|
||||
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
|
||||
* to enforce that the caller's company owns it (portal route); omit for
|
||||
* agents, who see every thread.
|
||||
*/
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
query: ListMessagesQueryDto = {},
|
||||
asCustomerUserId?: string,
|
||||
): Promise<SupportMessageDto[]> {
|
||||
): Promise<SupportMessageListResult> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomerUserId) {
|
||||
await this.assertCustomerOwns(conversation, asCustomerUserId);
|
||||
}
|
||||
return this.listMessages(conversationId);
|
||||
return this.listMessages(conversationId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* May `userId` read the message that a chat attachment hangs off? Backstop for
|
||||
* the file-download route, which otherwise streams any file to any
|
||||
* authenticated caller who knows its UUID.
|
||||
*
|
||||
* Backoffice staff see every thread (they work a shared inbox); a portal user
|
||||
* sees only their own company's. Fails **closed** — an unresolvable message,
|
||||
* conversation, or staff list denies rather than falls through, since the
|
||||
* caller uses this to decide whether to hand over raw bytes.
|
||||
*/
|
||||
async canUserAccessMessage(
|
||||
messageId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const message = await this.messages.findById(messageId);
|
||||
if (!message) return false;
|
||||
const conversation = await this.conversations.findById(
|
||||
message.conversationId,
|
||||
);
|
||||
if (!conversation) return false;
|
||||
|
||||
try {
|
||||
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
|
||||
if (staffIds.includes(userId)) return true;
|
||||
} catch {
|
||||
// Staff lookup is best-effort for room-joining in the gateway, but here it
|
||||
// gates bytes: on failure fall through to the (stricter) company check
|
||||
// rather than assuming staff.
|
||||
}
|
||||
|
||||
const profile = await this.externalProfiles.findByUserId(userId);
|
||||
return Boolean(
|
||||
profile?.companyId && profile.companyId === conversation.companyId,
|
||||
);
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
@@ -237,29 +309,86 @@ export class SupportChatService {
|
||||
|
||||
private async listMessages(
|
||||
conversationId: string,
|
||||
): Promise<SupportMessageDto[]> {
|
||||
const rows = await this.messages.listByConversation(conversationId);
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
query: ListMessagesQueryDto,
|
||||
): Promise<SupportMessageListResult> {
|
||||
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||
|
||||
// The repo returns newest-first and over-fetches by one to probe for a
|
||||
// further page.
|
||||
const rows = await this.messages.listByConversation(
|
||||
conversationId,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
const oldest = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
|
||||
|
||||
// Flip to oldest-first so the client can prepend a page as one block.
|
||||
const items = await this.toMessageDtos([...page].reverse());
|
||||
return { items, nextCursor };
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
/**
|
||||
* Persist a message (plus any attachments), bump the conversation's
|
||||
* denormalized fields, emit live.
|
||||
*
|
||||
* Files are validated *before* the row is written: a rejected upload should
|
||||
* leave no message behind, and a half-uploaded batch is worse than none.
|
||||
*/
|
||||
private async appendMessage(
|
||||
conversation: SupportConversation,
|
||||
userId: string,
|
||||
role: SupportAuthorRole,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
authorName?: string | null,
|
||||
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<{
|
||||
conversation: SupportConversation;
|
||||
message: SupportMessageDto;
|
||||
}> {
|
||||
const text = (body ?? "").trim();
|
||||
this.assertSendable(text, attachments);
|
||||
|
||||
const message = await this.messages.create({
|
||||
conversationId: conversation.id,
|
||||
authorUserId: userId,
|
||||
authorRole: role,
|
||||
authorName: authorName ?? null,
|
||||
body,
|
||||
// NULL, not "", so "this message has no text" is representable rather than
|
||||
// inferred. The DTO flattens it back to "" for rendering.
|
||||
body: text || null,
|
||||
});
|
||||
|
||||
// The row has to exist before the files, since each one is stored against
|
||||
// `resourceId = message.id`. That leaves a window: if a upload fails here,
|
||||
// the message is already committed. Undo it rather than leave the thread
|
||||
// with a permanently blank bubble — there is no delete flow, so an orphan
|
||||
// would be unremovable, and an attachment-only message that lost its files
|
||||
// has no content at all.
|
||||
let stored: FileRecord[];
|
||||
try {
|
||||
stored = await Promise.all(
|
||||
attachments.map((file) =>
|
||||
this.files.upload({
|
||||
resourceId: message.id,
|
||||
resource: SUPPORT_ATTACHMENT_RESOURCE,
|
||||
code: "attachment",
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
await this.messages.softDelete(message.id);
|
||||
throw error;
|
||||
}
|
||||
|
||||
conversation.lastMessageAt = message.createdAt;
|
||||
conversation.lastMessagePreview = body.slice(0, 280);
|
||||
conversation.lastMessagePreview = this.buildPreview(text, stored);
|
||||
conversation.lastMessageAuthorRole = role;
|
||||
await this.conversations.update(conversation.id, {
|
||||
lastMessageAt: conversation.lastMessageAt,
|
||||
@@ -267,13 +396,55 @@ export class SupportChatService {
|
||||
lastMessageAuthorRole: role,
|
||||
});
|
||||
|
||||
const messageDto = await this.toMessageDto(message, stored);
|
||||
const dto = this.toConversationDto(conversation, 0);
|
||||
this.gateway.emitMessage(
|
||||
conversation.companyId,
|
||||
dto,
|
||||
this.toMessageDto(message),
|
||||
);
|
||||
return { conversation, message };
|
||||
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
|
||||
return { conversation, message: messageDto };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard the chat-specific upload rules. These are tighter than
|
||||
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
|
||||
* which exist for scanned business documents — chat files are pushed at
|
||||
* another human, so the allowlist is narrower and SVG is excluded outright.
|
||||
*/
|
||||
private assertSendable(
|
||||
text: string,
|
||||
attachments: Express.Multer.File[],
|
||||
): void {
|
||||
if (!text && attachments.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"A message needs text or at least one attachment.",
|
||||
);
|
||||
}
|
||||
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
throw new BadRequestException(
|
||||
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
}
|
||||
for (const file of attachments) {
|
||||
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported attachment type: ${file.mimetype}`,
|
||||
);
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${file.originalname}" exceeds the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB attachment limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inbox preview line — falls back to the filenames when there's no text. */
|
||||
private buildPreview(text: string, attachments: FileRecord[]): string {
|
||||
if (text) return text.slice(0, 280);
|
||||
if (attachments.length === 1) {
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||
}
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
@@ -320,7 +491,9 @@ export class SupportChatService {
|
||||
): Promise<CustomerContext> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
if (conversation.companyId !== ctx.companyId) {
|
||||
throw new ForbiddenException("This conversation belongs to another company.");
|
||||
throw new ForbiddenException(
|
||||
"This conversation belongs to another company.",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -353,15 +526,58 @@ export class SupportChatService {
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: SupportMessage): SupportMessageDto {
|
||||
/** Hydrate + map a page of messages, batching the attachment lookup. */
|
||||
private async toMessageDtos(
|
||||
rows: SupportMessage[],
|
||||
): Promise<SupportMessageDto[]> {
|
||||
if (rows.length === 0) return [];
|
||||
const grouped = await this.files.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
SUPPORT_ATTACHMENT_RESOURCE,
|
||||
);
|
||||
return Promise.all(
|
||||
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
|
||||
);
|
||||
}
|
||||
|
||||
private async toMessageDto(
|
||||
m: SupportMessage,
|
||||
attachments: FileRecord[],
|
||||
): Promise<SupportMessageDto> {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
authorUserId: m.authorUserId,
|
||||
authorRole: m.authorRole,
|
||||
authorName: m.authorName ?? null,
|
||||
body: m.body,
|
||||
body: m.body ?? "",
|
||||
attachments: attachments.map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: new Date(m.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the browser fetches the bytes: the API's own ownership-checked stream
|
||||
* route, NOT a presigned MinIO URL.
|
||||
*
|
||||
* Presigned object URLs are not reachable from the browser in this deployment
|
||||
* — the same reason every other file in the app streams through
|
||||
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
|
||||
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
|
||||
* attachments stream through `GET /api/support/attachments/:id`, which runs the
|
||||
* same-company / staff ownership check before serving a byte.
|
||||
*
|
||||
* A root-relative path; the web app prepends its API origin. The `<img>` sends
|
||||
* the `auth-token` cookie automatically (same-site across dev ports), which is
|
||||
* how the guard authenticates a request that can't carry a bearer header.
|
||||
*/
|
||||
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
url: `/api/support/attachments/${f.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<SupportMessage> {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** All messages of a conversation, oldest first. */
|
||||
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
|
||||
return this.repository.find({
|
||||
where: { conversationId },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
/**
|
||||
* One page of a thread, walking backwards from newest.
|
||||
*
|
||||
* Returns **newest-first** and takes one row more than asked, so the caller
|
||||
* can tell "there is another page" from "this page happened to be full"
|
||||
* without a second COUNT. The caller trims the probe row and flips the page
|
||||
* to oldest-first for rendering.
|
||||
*
|
||||
* Rides IDX_SUPPORT_MSG_CONV_CREATED (conversation_id, created_at); the id in
|
||||
* the keyset is a tiebreak only and doesn't need its own index.
|
||||
*/
|
||||
async listByConversation(
|
||||
conversationId: string,
|
||||
limit: number,
|
||||
beforeId?: string,
|
||||
): Promise<SupportMessage[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("m")
|
||||
.where("m.conversation_id = :conversationId", { conversationId })
|
||||
// createQueryBuilder bypasses TypeORM's soft-delete filter, unlike find().
|
||||
.andWhere("m.deleted_at IS NULL");
|
||||
|
||||
if (beforeId) {
|
||||
// Row-value comparison: strictly older than the cursor row in
|
||||
// (created_at, id) order.
|
||||
//
|
||||
// The cursor's timestamp is read back from the row itself rather than
|
||||
// passed in. `created_at` is timestamptz(6) but a JS Date only holds
|
||||
// milliseconds, so a timestamp that made the round-trip through the API
|
||||
// would arrive truncated — and `.254100 < .254000` is false, so every row
|
||||
// sharing the cursor's millisecond but earlier within it would be skipped
|
||||
// on every page, permanently. Postgres compares the stored values at full
|
||||
// precision instead.
|
||||
qb.andWhere(
|
||||
`(m.created_at, m.id) < (
|
||||
SELECT c.created_at, c.id
|
||||
FROM freight.support_messages c
|
||||
WHERE c.id = :cursorId
|
||||
)`,
|
||||
{ cursorId: beforeId },
|
||||
);
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy("m.created_at", "DESC")
|
||||
.addOrderBy("m.id", "DESC")
|
||||
.take(limit + 1)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
@@ -68,6 +69,9 @@ export class BookingJourneyService {
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -352,10 +356,19 @@ export class BookingJourneyService {
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'DOMESTIC') return;
|
||||
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
||||
|
||||
if (!facility?.hasFacility) {
|
||||
throw new BadRequestException(
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
|
||||
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
// A facility only handles what its equipment can lift: containers need a
|
||||
// reach stacker/gantry, bulk does not.
|
||||
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
||||
throw new BadRequestException(
|
||||
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
||||
`an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
/**
|
||||
* A checkpoint records where the train is as staff observe it, and the final
|
||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
|
||||
@@ -67,10 +67,18 @@ export class IntercityService {
|
||||
ts.status AS "scheduleStatus",
|
||||
oy.id AS "originYardId",
|
||||
COALESCE(oy.label, oy.code) AS "origin",
|
||||
oy.has_facility AS "originHasFacility",
|
||||
-- Can that end actually handle THIS booking's cargo? A container
|
||||
-- booking needs a facility with a stacker; bulk needs any facility.
|
||||
(oy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
|
||||
AS "originHasFacility",
|
||||
dy.id AS "destinationYardId",
|
||||
COALESCE(dy.label, dy.code) AS "destination",
|
||||
dy.has_facility AS "destinationHasFacility",
|
||||
(dy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
|
||||
AS "destinationHasFacility",
|
||||
-- Where the train actually is, so the operator knows if the cargo
|
||||
-- can be worked right now.
|
||||
cp.yard_id AS "trainAtYardId",
|
||||
@@ -80,6 +88,10 @@ export class IntercityService {
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.yard_facilities ofac
|
||||
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
|
||||
LEFT JOIN freight.yard_facilities dfac
|
||||
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
|
||||
LEFT JOIN freight.train_schedules ts
|
||||
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marshalling documents', () => {
|
||||
// Staff check these against the physical consist, so every wagon on the
|
||||
// train set has to appear — an empty wagon that renders no row reads as a
|
||||
// wagon that is not on the train.
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
capacityTons: 70,
|
||||
allocations,
|
||||
});
|
||||
|
||||
const loadedAllocation = {
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BK-2026-000001',
|
||||
loadType: 'CONTAINER',
|
||||
allocatedWeightTons: 24.5,
|
||||
containerNumbers: ['CONT-001'],
|
||||
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
|
||||
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
|
||||
};
|
||||
|
||||
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
|
||||
|
||||
it('lists an empty wagon on the export document and marks it EMPTY', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [
|
||||
makeWagon(1, 'W-001', [loadedAllocation]),
|
||||
makeWagon(2, 'W-002', []),
|
||||
makeWagon(3, 'W-003', []),
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(countRows(html)).toBe(3);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html).toContain('W-003');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
|
||||
// The wagon count must agree with the rows the reader can see.
|
||||
expect(html).toContain('3 (2 empty)');
|
||||
});
|
||||
|
||||
it('lists an empty wagon on the import document and marks it EMPTY', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '8002',
|
||||
route: 'Djibouti → Indode',
|
||||
origin: 'Djibouti Port',
|
||||
destination: 'Indode',
|
||||
totalBookings: 1,
|
||||
wagons: [
|
||||
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
|
||||
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(countRows(html)).toBe(2);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
|
||||
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
|
||||
});
|
||||
|
||||
it('omits the empty-count suffix when every wagon is loaded', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html).not.toContain('empty)');
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2632,6 +2632,11 @@ export class TrainSchedulingService {
|
||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.size) {
|
||||
// Export cargo must be received at the warehouse with a GRN before it can
|
||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||
if (this.isExportSchedule(schedule)) {
|
||||
await this.assertExportBookingsReceived([...wagonAssignedIds]);
|
||||
}
|
||||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||||
scheduleId,
|
||||
[...wagonAssignedIds],
|
||||
@@ -2686,19 +2691,24 @@ export class TrainSchedulingService {
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
// Every wagon on the train set, loaded or not, in consist order. An empty
|
||||
// wagon has an empty `allocations` array — it is still part of the train
|
||||
// and still belongs on the marshalling document.
|
||||
wagons: [...(schedule.trainSet?.wagons ?? [])]
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
|
||||
.map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
@@ -2748,9 +2758,33 @@ export class TrainSchedulingService {
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
const rows = wagons
|
||||
.flatMap((wagon) => {
|
||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
||||
const allocations = wagon.allocations ?? [];
|
||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||
// check this document against the physical train — a wagon with no row
|
||||
// reads as a wagon that is not there, and the count stops matching.
|
||||
if (allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
@@ -2760,12 +2794,7 @@ export class TrainSchedulingService {
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
@@ -2773,10 +2802,11 @@ export class TrainSchedulingService {
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
@@ -2804,6 +2834,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
@@ -2831,7 +2863,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
@@ -2855,7 +2887,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -2882,6 +2914,38 @@ export class TrainSchedulingService {
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every export booking being confirmed loaded must already be received at the
|
||||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||
*/
|
||||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||
if (!bookingIds.length) return;
|
||||
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||
`SELECT b.reference
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = ANY($1)
|
||||
AND b.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = b.id
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
)`,
|
||||
[bookingIds],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
|
||||
throw new BadRequestException(
|
||||
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -2898,19 +2962,31 @@ export class TrainSchedulingService {
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>`;
|
||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||
// buildExportLoadListHtml.
|
||||
if (wagon.allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -2942,6 +3018,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
@@ -2968,7 +3046,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
@@ -2996,7 +3074,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@Get('trucks-on-site')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({
|
||||
summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
|
||||
})
|
||||
trucksOnSite() {
|
||||
return this.inventoryService.trucksOnSite();
|
||||
}
|
||||
|
||||
@Get('zone-occupancy')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||
|
||||
@@ -410,6 +410,83 @@ export class WarehouseInventoryService {
|
||||
* - trucksOnSite: customer trucks arrived but not departed
|
||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||
*/
|
||||
/**
|
||||
* Every truck currently inside the yard, across all bookings — the list behind
|
||||
* the `trucksOnSite` figure on the ops dashboard, which until now could only
|
||||
* be counted and never opened.
|
||||
*
|
||||
* Covers both haulage paths because the gate does: a customer's own truck and
|
||||
* an EDR last-mile truck arrive at the same barrier and need the same paper.
|
||||
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
|
||||
* what is coming as well as what is here — an assigned truck only stamps
|
||||
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
|
||||
* it departs.
|
||||
*/
|
||||
async trucksOnSite(): Promise<
|
||||
Array<{
|
||||
source: 'CUSTOMER' | 'EDR';
|
||||
assignmentId: string;
|
||||
status: 'INBOUND' | 'ON_SITE';
|
||||
plateNumber: string | null;
|
||||
driverName: string | null;
|
||||
truckType: string | null;
|
||||
arrivedAt: string | null;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
containers: string | null;
|
||||
}>
|
||||
> {
|
||||
return this.dataSource.query(
|
||||
`SELECT 'CUSTOMER' AS "source",
|
||||
a.id AS "assignmentId",
|
||||
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||
a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.arrived_at AS "arrivedAt",
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
|
||||
FROM freight.customer_truck_containers c
|
||||
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
|
||||
FROM freight.customer_truck_assignments a
|
||||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.departed_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'EDR' AS "source",
|
||||
va.id AS "assignmentId",
|
||||
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
|
||||
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
|
||||
v.vehicle_type AS "truckType",
|
||||
va.arrived_at AS "arrivedAt",
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
|
||||
FROM freight.last_mile_vehicle_containers lvc
|
||||
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE va.deleted_at IS NULL
|
||||
AND va.departed_at IS NULL
|
||||
|
||||
-- On-site trucks first, each group oldest-arrival first; inbound trucks
|
||||
-- (null arrival) sort to the end.
|
||||
ORDER BY "arrivedAt" ASC NULLS LAST`,
|
||||
);
|
||||
}
|
||||
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
receivedYesterday: number;
|
||||
@@ -1067,14 +1144,15 @@ export class WarehouseInventoryService {
|
||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||||
// a train without one. Import GRN handling is left untouched.
|
||||
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||
// gets one on unload — import as well as export. The direction only decides
|
||||
// the GRN prefix, not whether one is issued.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||||
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1095,10 +1173,10 @@ export class WarehouseInventoryService {
|
||||
zoneId: location.zoneId,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
||||
...(isExport && !existing[0].grnNumber
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||
...(existing[0].grnNumber
|
||||
? {}
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1113,9 +1191,7 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
...(isExport
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1202,8 +1278,18 @@ export class WarehouseInventoryService {
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
-- fallback for single-truck bookings written before that table.
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
@@ -1334,8 +1420,14 @@ export class WarehouseInventoryService {
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
@@ -1794,8 +1886,18 @@ export class WarehouseInventoryService {
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
-- fallback for single-truck bookings written before that table.
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
@@ -2454,6 +2556,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
@@ -2978,6 +3081,31 @@ export class WarehouseInventoryService {
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
// Customer self-haul: the same exit record on the customer's own truck.
|
||||
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||
// summed to zero and the booking could take unlimited trucks. Matched by
|
||||
// plate rather than container so bulk trucks (which carry none) count.
|
||||
await manager.query(
|
||||
`UPDATE freight.customer_truck_assignments a
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(a.arrived_at, NOW()),
|
||||
gross_weight_kg = $4,
|
||||
tare_weight_tons = $5,
|
||||
net_weight_tons = $6,
|
||||
updated_at = NOW()
|
||||
WHERE a.booking_id = $1
|
||||
AND UPPER(a.plate_number) = UPPER($2)
|
||||
AND a.departed_at IS NULL
|
||||
AND a.deleted_at IS NULL`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
dto.gateOutTime ?? null,
|
||||
grossTons,
|
||||
tareTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
@@ -5527,6 +5655,31 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cargo is received into the warehouse to wait for its train, and it is
|
||||
* received only once the booking is paid — receiving an unpaid export booking
|
||||
* would start storage and mint a GRN against cargo the customer has not settled.
|
||||
*
|
||||
* Export only: import cargo arrives OFF a train and its receive is the unload,
|
||||
* so gating that on payment would strand cargo already at the yard.
|
||||
*/
|
||||
private async assertExportBookingPaid(
|
||||
bookingId: string | null | undefined,
|
||||
direction: string | null,
|
||||
): Promise<void> {
|
||||
if (!bookingId || direction !== 'EXPORT') return;
|
||||
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
|
||||
`SELECT payment_status AS "paymentStatus"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: LocationNode,
|
||||
|
||||
@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
|
||||
facilityType: 'DRY_PORT' as const,
|
||||
facilityStatus: 'ACTIVE' as const,
|
||||
locationName: 'Indode',
|
||||
country: 'Djibouti',
|
||||
city: 'Djibouti',
|
||||
address: 'Indode, Djibouti',
|
||||
latitude: 11.5447,
|
||||
longitude: 43.145,
|
||||
// Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
|
||||
// KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
|
||||
// of the line. Coordinates are left unset rather than guessed; fill them in
|
||||
// when the real position is to hand.
|
||||
country: 'Ethiopia',
|
||||
city: 'Addis Ababa',
|
||||
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
|
||||
capacity: 50000,
|
||||
isActive: true,
|
||||
notes: 'Primary dry port for container consolidation and distribution',
|
||||
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
|
||||
const yardRepo = manager.getRepository(WarehouseYard);
|
||||
const zoneRepo = manager.getRepository(WarehouseZone);
|
||||
|
||||
// Ensure facility exists
|
||||
const facility = await facilityRepo.findOne({
|
||||
// Ensure facility exists. An existing facility row is not proof the
|
||||
// warehouses under it survived, so reuse it and carry on rather than
|
||||
// returning — otherwise a facility with no warehouses stays that way.
|
||||
const existing = await facilityRepo.findOne({
|
||||
where: { code: INDODE_FACILITY.code },
|
||||
});
|
||||
|
||||
if (facility) {
|
||||
this.logger.log('Indode facility already exists, skipping seed');
|
||||
return;
|
||||
let savedFacility: Facility;
|
||||
if (existing) {
|
||||
savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY });
|
||||
this.logger.log(`Facility ${savedFacility.code} already exists, reusing`);
|
||||
} else {
|
||||
savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY));
|
||||
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||
}
|
||||
|
||||
const newFacility = facilityRepo.create(INDODE_FACILITY);
|
||||
const savedFacility = await facilityRepo.save(newFacility);
|
||||
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||
|
||||
// Create warehouses for the facility
|
||||
for (const warehouseData of WAREHOUSES) {
|
||||
try {
|
||||
|
||||
@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
|
||||
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
|
||||
* settled which is the intercity facility.
|
||||
*/
|
||||
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
|
||||
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
|
||||
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
|
||||
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
|
||||
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
|
||||
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
|
||||
const FACILITY_YARDS: Array<{
|
||||
code: string;
|
||||
facility: string;
|
||||
hasWarehouse: boolean;
|
||||
handlesContainer: boolean;
|
||||
}> = [
|
||||
// Containers need a reach stacker or gantry — only these three are equipped.
|
||||
// Bulk needs far less, so every facility handles it.
|
||||
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
|
||||
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
|
||||
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
|
||||
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
|
||||
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
|
||||
* yards — a missing code is logged and skipped rather than invented.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
|
||||
for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
|
||||
const [yard]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
|
||||
[code],
|
||||
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
|
||||
);
|
||||
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO freight.yard_facilities
|
||||
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
|
||||
VALUES ($1, $2, $3, true, $4)
|
||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
|
||||
[yard.id, hasWarehouse, `${facility} load/unload facility`],
|
||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
||||
handles_container = EXCLUDED.handles_container,
|
||||
handles_bulk = EXCLUDED.handles_bulk,
|
||||
updated_at = NOW()`,
|
||||
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
@@ -124,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import IntercityPage from "./pages/warehouses/IntercityPage";
|
||||
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
@@ -464,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
// Yard-wide, not per-direction: the gate sees import and export
|
||||
// trucks at the same barrier.
|
||||
label: "Trucks on Site",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
@@ -696,6 +705,7 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route path="um/set-password" element={<SetPassword />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
@@ -958,6 +968,7 @@ const App = () => {
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { api } from "./http";
|
||||
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
|
||||
import type {
|
||||
AuthTokens,
|
||||
AuthUser,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
LoginResponse,
|
||||
ResetTicket,
|
||||
SetPasswordPayload,
|
||||
} from "./types";
|
||||
|
||||
export const loginRequest = async (payload: {
|
||||
email: string;
|
||||
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
|
||||
const response = await api.get<AuthUser>("/me");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// The three calls below drive the unauthenticated forgot-password flow.
|
||||
// Responses under /api/auth are *flattened* by the API's response
|
||||
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||
|
||||
export const requestPasswordResetRequest = async (
|
||||
payload: ForgotPasswordRequestPayload,
|
||||
) => {
|
||||
await api.post("/auth/forgot-password/request", payload);
|
||||
};
|
||||
|
||||
export const verifyPasswordResetOtpRequest = async (
|
||||
payload: ForgotPasswordVerifyPayload,
|
||||
) => {
|
||||
const response = await api.post<ResetTicket>(
|
||||
"/auth/forgot-password/verify",
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
|
||||
* Carries its own userId/verificationCode and never touches the session.
|
||||
*/
|
||||
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
|
||||
await api.patch("/auth/set-password", payload);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import axios from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
emitApiError,
|
||||
extractApiErrorPayload,
|
||||
} from "@/components/errors/ApiErrorModal";
|
||||
import { captureApiError } from "@/lib/posthog";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
@@ -14,14 +11,16 @@ import {
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
/**
|
||||
* When true, the response interceptor does NOT raise the global error modal
|
||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
||||
* probe that is expected to 404 before falling back (GL clearance detail
|
||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
||||
* When true, the response interceptor does NOT raise the global error
|
||||
* toast for this request's failure. For calls the caller handles itself —
|
||||
* e.g. a probe that is expected to 404 before falling back (GL clearance
|
||||
* detail tries /contracts/:id then /bookings/:id). The rejection still
|
||||
* propagates.
|
||||
*/
|
||||
suppressErrorModal?: boolean;
|
||||
}
|
||||
@@ -96,9 +95,8 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config as RetriableRequest | undefined;
|
||||
|
||||
// Report the failure to PostHog. Hooked here rather than inside
|
||||
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
||||
// mile / onboarding) — those failures still need reporting.
|
||||
// Report the failure to PostHog, including on suppressErrorModal paths —
|
||||
// those opt out of the user-facing toast, not of reporting.
|
||||
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
captureApiError(error);
|
||||
@@ -112,19 +110,25 @@ api.interceptors.response.use(
|
||||
originalRequest.url?.includes("/auth/mfa-verify") ||
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
// Surface the server's actual error message in the global error modal
|
||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
||||
// Surface the server's actual error message in a global toast — never
|
||||
// the error modal (401s are handled by the session-refresh flow, so skip
|
||||
// them). A request may opt out via `suppressErrorModal` when it handles
|
||||
// the failure itself.
|
||||
if (error.response && error.response.status !== 401) {
|
||||
const payload = extractApiErrorPayload(error);
|
||||
// Normalize the error's own `message` to the SERVER's actual message so
|
||||
// every downstream `toast.error(err.message)` / MutationCache handler
|
||||
// shows the real cause instead of "Request failed with status code NNN".
|
||||
// Applies even on suppressErrorModal paths — only the modal is opted out.
|
||||
// every downstream `toast.error(err.message)` handler shows the real
|
||||
// cause instead of "Request failed with status code NNN". Applies even
|
||||
// on suppressErrorModal paths — only the toast is opted out.
|
||||
if (payload?.messages.length) {
|
||||
(error as { message?: string }).message = payload.messages.join("\n");
|
||||
const message = payload.messages.join("\n");
|
||||
(error as { message?: string }).message = message;
|
||||
// Keyed by message so a retried request replaces its toast instead
|
||||
// of stacking duplicates.
|
||||
if (!originalRequest?.suppressErrorModal) {
|
||||
toast.error(message, { id: message });
|
||||
}
|
||||
}
|
||||
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,29 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordRequestPayload {
|
||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
// Additional types for Matrix form test
|
||||
export interface User {
|
||||
id: string;
|
||||
|
||||
@@ -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