mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Merge remote-tracking branch 'origin/dev' into tests
This commit is contained in:
@@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY=
|
|||||||
TELEBIRR_PUBLIC_KEY=
|
TELEBIRR_PUBLIC_KEY=
|
||||||
TELEBIRR_INSECURE_TLS=false
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
|
||||||
|
# Public origin of the freight customer portal. Password-reset links sent to
|
||||||
|
# customers are built against this — it must be browser-reachable.
|
||||||
|
FREIGHT_PORTAL_URL=http://localhost:5173
|
||||||
|
|
||||||
# Portal pages the payment provider redirects the browser to after payment.
|
# Portal pages the payment provider redirects the browser to after payment.
|
||||||
# Point these at the freight portal's public payment result routes.
|
# Point these at the freight portal's public payment result routes.
|
||||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
|||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||||
@@ -166,6 +167,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
HealthModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
@@ -275,6 +277,9 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// await this.demoUsersSeeder.run();
|
// await this.demoUsersSeeder.run();
|
||||||
// await this.freightStaffUsersSeeder.run();
|
// await this.freightStaffUsersSeeder.run();
|
||||||
// await this.pricingDataSeeder.run();
|
// await this.pricingDataSeeder.run();
|
||||||
|
// IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so
|
||||||
|
// it will not recognise a hand-created Indode warehouse and will seed a second
|
||||||
|
// one alongside it. Only enable it against an Indode that has no warehouse.
|
||||||
// await this.indodeFacilitySeeder.run();
|
// await this.indodeFacilitySeeder.run();
|
||||||
// await this.batch14TestDataSeeder.run();
|
// await this.batch14TestDataSeeder.run();
|
||||||
// await this.batch5TestDataSeeder.run();
|
// await this.batch5TestDataSeeder.run();
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin';
|
|||||||
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||||
|
|
||||||
type PermissionLike = { key?: string };
|
type PermissionLike = { key?: string };
|
||||||
|
type PositionTypeLike = { key?: string };
|
||||||
type MeLikeUser = {
|
type MeLikeUser = {
|
||||||
roles?: { key?: string }[];
|
roles?: { key?: string }[];
|
||||||
permissions?: PermissionLike[];
|
permissions?: PermissionLike[];
|
||||||
employee?:
|
employee?:
|
||||||
| {
|
| {
|
||||||
position?: { permissions?: PermissionLike[] };
|
position?: {
|
||||||
|
permissions?: PermissionLike[];
|
||||||
|
positionType?: PositionTypeLike | null;
|
||||||
|
};
|
||||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
positions?: { permissions?: PermissionLike[] }[];
|
positions?: {
|
||||||
|
permissions?: PermissionLike[];
|
||||||
|
positionType?: PositionTypeLike | null;
|
||||||
|
}[];
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
};
|
};
|
||||||
@@ -90,12 +97,110 @@ export function assertFreightPermission(
|
|||||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's IAM position-type keys (`iam.position_types.key`). A position
|
||||||
|
* type is the platform's notion of a role — it is what carries permissions via
|
||||||
|
* `iam.position_type_permissions` — and it is the vocabulary contract approval
|
||||||
|
* chains are configured in.
|
||||||
|
*
|
||||||
|
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
|
||||||
|
* an object on some tokens and an array on others.
|
||||||
|
*
|
||||||
|
* Note delegated positions carry no `positionType` in the token, so a delegate
|
||||||
|
* is not reachable here — they authorize through the permission arm of
|
||||||
|
* `assertCanApproveContractStep` instead.
|
||||||
|
*/
|
||||||
|
export function collectPositionTypeKeys(
|
||||||
|
user: MeLikeUser | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
const employee = user?.employee;
|
||||||
|
if (!employee) return [];
|
||||||
|
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
if (Array.isArray(employee)) {
|
||||||
|
for (const emp of employee) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
if (pos.positionType?.key) keys.add(pos.positionType.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.position?.positionType?.key) {
|
||||||
|
keys.add(employee.position.positionType.key);
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy chain roles predate position types. Historical `approval_rules` and
|
||||||
|
* in-flight `contract_approval_steps` rows still carry them, so map each to the
|
||||||
|
* position types that stand in for it. Without this, an approver holding a
|
||||||
|
* modern position type could not action an older step.
|
||||||
|
*/
|
||||||
|
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||||
|
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
|
||||||
|
DIRECTOR: ['director', 'operation-director'],
|
||||||
|
CEO: ['chief', 'deputy'],
|
||||||
|
};
|
||||||
|
|
||||||
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||||
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
||||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||||
|
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANY_CONTRACT_APPROVE_PERMISSION = [
|
||||||
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
|
FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May this caller action a contract approval step requiring `requiredRole`?
|
||||||
|
*
|
||||||
|
* `requiredRole` is an `iam.position_types.key` for chains configured by an
|
||||||
|
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
|
||||||
|
* A caller passes when any of these hold:
|
||||||
|
*
|
||||||
|
* - they are a super/organization admin (blanket bypass);
|
||||||
|
* - their position type matches the step, directly or via a legacy alias;
|
||||||
|
* - they hold the approve permission the legacy role maps to;
|
||||||
|
* - they hold any contract approve permission — this covers delegates (whose
|
||||||
|
* position type is absent from the token) and staff whose IAM position has
|
||||||
|
* no position type assigned yet.
|
||||||
|
*/
|
||||||
|
export function assertCanApproveContractStep(
|
||||||
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
|
requiredRole: string,
|
||||||
|
): void {
|
||||||
|
if (isFreightApprovalAdmin(user)) return;
|
||||||
|
|
||||||
|
const positionTypes = collectPositionTypeKeys(user);
|
||||||
|
if (positionTypes.includes(requiredRole)) return;
|
||||||
|
|
||||||
|
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||||
|
if (aliases.some((alias) => positionTypes.includes(alias))) return;
|
||||||
|
|
||||||
|
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||||
|
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
|
||||||
|
|
||||||
|
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`You are not the required approver (${requiredRole}) for this step.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function assertCanApproveBookingStep(
|
export function assertCanApproveBookingStep(
|
||||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
|
|||||||
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { usesEdrMileService } from './mile-haulage.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road legs are chosen on the contract and copied onto the booking. EDR
|
||||||
|
* haulage and a customer's own truck are alternatives, so this one answer gates
|
||||||
|
* both sides — the customer-truck guard and the mile-queue guard.
|
||||||
|
*/
|
||||||
|
describe('usesEdrMileService', () => {
|
||||||
|
const booking = (over: Partial<Parameters<typeof usesEdrMileService>[0]> = {}) => ({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
firstMile: null,
|
||||||
|
lastMile: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an import that chose delivery uses EDR haulage', () => {
|
||||||
|
expect(usesEdrMileService(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an import that chose nothing does not', () => {
|
||||||
|
expect(usesEdrMileService(booking())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the pickup address on an import — collection is the export leg', () => {
|
||||||
|
expect(usesEdrMileService(booking({ firstMile: 'Modjo' }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an export that chose collection uses EDR haulage', () => {
|
||||||
|
expect(
|
||||||
|
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the delivery address on an export — delivery is the import leg', () => {
|
||||||
|
expect(
|
||||||
|
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a domestic booking counts either leg', () => {
|
||||||
|
expect(
|
||||||
|
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a whitespace-only address as no choice', () => {
|
||||||
|
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/** The booking fields that decide who hauls the road legs. */
|
||||||
|
export interface MileHaulageRow {
|
||||||
|
tradeDirection: string | null;
|
||||||
|
/** `first_mile_pickup_address` — set when the customer asked EDR to collect. */
|
||||||
|
firstMile: string | null;
|
||||||
|
/** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */
|
||||||
|
lastMile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the customer bought the EDR road leg that matters for their direction:
|
||||||
|
* delivery at the end of an import, collection at the start of an export. A
|
||||||
|
* DOMESTIC booking can use either, so either one counts.
|
||||||
|
*
|
||||||
|
* The address is the signal because it is the only per-booking record of the
|
||||||
|
* choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be
|
||||||
|
* used — every service type ships with both set to true, so reading them would
|
||||||
|
* mean every booking uses EDR haulage and none could ever self-haul.
|
||||||
|
*/
|
||||||
|
export function usesEdrMileService(booking: MileHaulageRow): boolean {
|
||||||
|
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||||
|
const hasLastMile = Boolean(booking.lastMile?.trim());
|
||||||
|
switch (booking.tradeDirection) {
|
||||||
|
case 'IMPORT':
|
||||||
|
return hasLastMile;
|
||||||
|
case 'EXPORT':
|
||||||
|
return hasFirstMile;
|
||||||
|
default:
|
||||||
|
return hasFirstMile || hasLastMile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR haulage and a customer's own truck are alternatives, never both. Whichever
|
||||||
|
* side is being set up, it has to reject the other — a guard on only one side
|
||||||
|
* lets the two paths open on the same booking, each unaware of the other.
|
||||||
|
*/
|
||||||
|
export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||||
|
'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||||
|
|
||||||
|
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||||
|
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road legs are chosen on the contract. A booking whose contract bought
|
||||||
|
* neither has no business in the first/last-mile queues at all.
|
||||||
|
*/
|
||||||
|
export const NO_MILE_SERVICE_MESSAGE =
|
||||||
|
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';
|
||||||
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertBulkTonnageRemains,
|
||||||
|
assertTruckCountWithinContainers,
|
||||||
|
assertTruckLoad,
|
||||||
|
remainingBulkTons,
|
||||||
|
} from './truck-load.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical rule, shared by customer self-haul and EDR last-mile. It used to
|
||||||
|
* be written out three times (addTruck, updateTruck, departTruck) plus a fourth
|
||||||
|
* in LastMileService.
|
||||||
|
*/
|
||||||
|
describe('assertTruckLoad', () => {
|
||||||
|
const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'];
|
||||||
|
|
||||||
|
it('accepts two 20ft containers on one truck', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ABCD1234567', 'ABCD7654321'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['20ft', '20ft'],
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a single 40ft container', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ABCD1234567'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['40ft'],
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a 40ft sharing the truck — it fills the bed', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ABCD1234567', 'ABCD7654321'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['40ft', '20ft'],
|
||||||
|
}),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more than two containers', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['20ft', '20ft', '20ft'],
|
||||||
|
}),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a container that is not on the booking', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ZZZZ9999999'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['20ft'],
|
||||||
|
}),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a container already riding another truck', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['ABCD1234567'],
|
||||||
|
bookingContainers: booking,
|
||||||
|
sizes: ['20ft'],
|
||||||
|
assignedElsewhere: ['ABCD1234567'],
|
||||||
|
}),
|
||||||
|
).toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips membership checks when the booking has no containers (bulk)', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still caps the count when the booking has no containers', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: ['A', 'B', 'C'],
|
||||||
|
bookingContainers: [],
|
||||||
|
sizes: [],
|
||||||
|
}),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertBulkTonnageRemains', () => {
|
||||||
|
it('allows another truck while tonnage is left', () => {
|
||||||
|
expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a truck once the booking is fully hauled', () => {
|
||||||
|
expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cap a booking with no declared weight', () => {
|
||||||
|
// Nothing to draw down against — capping here would block every truck.
|
||||||
|
expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remainingBulkTons', () => {
|
||||||
|
const dataSourceReturning = (totalTons: string, hauledTons: string) =>
|
||||||
|
({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never;
|
||||||
|
|
||||||
|
it('counts trucks from both haulage paths against the declared weight', async () => {
|
||||||
|
const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
totalTons: 100,
|
||||||
|
hauledTons: 60,
|
||||||
|
remainingTons: 40,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is complete once everything is hauled', async () => {
|
||||||
|
const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1');
|
||||||
|
|
||||||
|
expect(result.remainingTons).toBe(0);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reports negative tonnage when trucks overshoot', async () => {
|
||||||
|
const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1');
|
||||||
|
|
||||||
|
expect(result.remainingTons).toBe(0);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not complete for a booking with no declared weight', async () => {
|
||||||
|
const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1');
|
||||||
|
|
||||||
|
expect(result.complete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertTruckCountWithinContainers', () => {
|
||||||
|
it('allows one truck per container', () => {
|
||||||
|
expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more trucks than containers', () => {
|
||||||
|
expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cap a bulk booking, which has no container count', () => {
|
||||||
|
expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/** Two 20ft containers fit a truck bed; one 40ft fills it. */
|
||||||
|
export const MAX_CONTAINERS_PER_TRUCK = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one truck is being asked to carry, and the booking context to judge it
|
||||||
|
* against. `sizes` are the container_size labels of `containers`, in any order —
|
||||||
|
* only whether a 40ft is present matters.
|
||||||
|
*/
|
||||||
|
export interface TruckLoadCheck {
|
||||||
|
containers: string[];
|
||||||
|
/** Every container number on the booking. Empty means nothing to validate against. */
|
||||||
|
bookingContainers: string[];
|
||||||
|
sizes: string[];
|
||||||
|
/** Containers already riding another truck on this booking. */
|
||||||
|
assignedElsewhere?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The physical rule for loading one truck, shared by both haulage paths.
|
||||||
|
*
|
||||||
|
* A customer's own truck and an EDR last-mile truck obey the same physics, but
|
||||||
|
* the rule was implemented twice — once in CustomerTruckService, once in
|
||||||
|
* LastMileService — along with a byte-identical container-size query. Two copies
|
||||||
|
* of one rule drift, and that is exactly how the self-haul guard ended up
|
||||||
|
* enforced on one side only.
|
||||||
|
*/
|
||||||
|
export function assertTruckLoad({
|
||||||
|
containers,
|
||||||
|
bookingContainers,
|
||||||
|
sizes,
|
||||||
|
assignedElsewhere = [],
|
||||||
|
}: TruckLoadCheck): void {
|
||||||
|
if (containers.length > MAX_CONTAINERS_PER_TRUCK) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no container list on the booking there is nothing to check membership
|
||||||
|
// against — bulk bookings take this path.
|
||||||
|
if (!bookingContainers.length) return;
|
||||||
|
|
||||||
|
for (const number of containers) {
|
||||||
|
if (!bookingContainers.includes(number)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Container ${number} is not one of this booking's containers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (assignedElsewhere.includes(number)) {
|
||||||
|
throw new ConflictException(`Container ${number} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 40ft fills the bed, so it travels alone.
|
||||||
|
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never put more trucks on a booking than it has containers to fill them. */
|
||||||
|
export function assertTruckCountWithinContainers(
|
||||||
|
truckCount: number,
|
||||||
|
bookingContainerCount: number,
|
||||||
|
): void {
|
||||||
|
if (bookingContainerCount > 0 && truckCount > bookingContainerCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a bulk booking is still to be hauled. Counts trucks from BOTH
|
||||||
|
* haulage paths — a booking uses one or the other, and the rule ("trucks until
|
||||||
|
* no tonnage is left") is the same either way, so a single sum keeps them from
|
||||||
|
* disagreeing.
|
||||||
|
*
|
||||||
|
* Only departed trucks count: tonnage is known once the truck is weighed out.
|
||||||
|
*/
|
||||||
|
export async function remainingBulkTons(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> {
|
||||||
|
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(va.net_weight_tons)
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm
|
||||||
|
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
WHERE lm.booking_id = b.id
|
||||||
|
AND va.deleted_at IS NULL
|
||||||
|
AND va.departed_at IS NOT NULL
|
||||||
|
), 0)
|
||||||
|
+ COALESCE((
|
||||||
|
SELECT SUM(a.net_weight_tons)
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
WHERE a.booking_id = b.id
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
|
AND a.departed_at IS NOT NULL
|
||||||
|
), 0) AS "hauledTons"
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const totalTons = Number(row?.totalTons ?? 0);
|
||||||
|
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||||
|
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||||
|
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully-hauled bulk booking has nothing left for another truck to carry. */
|
||||||
|
export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void {
|
||||||
|
if (totalTons > 0 && remainingTons <= 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* container_size labels for the given container numbers on a booking. Shared so
|
||||||
|
* the two haulage paths read sizes the same way.
|
||||||
|
*/
|
||||||
|
export async function bookingContainerSizes(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
numbers: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!numbers.length) return [];
|
||||||
|
const rows: Array<{ size: string | null }> = await dataSource.query(
|
||||||
|
`SELECT bc.container_size AS "size"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND UPPER(bcu.container_number) = ANY($2)
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, numbers],
|
||||||
|
);
|
||||||
|
return rows.map((row) => (row.size ?? '').trim());
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { IsISO8601, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator';
|
||||||
|
|
||||||
|
class Subject {
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
@IsNotBackdated()
|
||||||
|
occurredAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectWith = (occurredAt?: string) => {
|
||||||
|
const subject = new Subject();
|
||||||
|
subject.occurredAt = occurredAt;
|
||||||
|
return subject;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt));
|
||||||
|
|
||||||
|
const backdatedErrors = (errors: Awaited<ReturnType<typeof errorsFor>>) =>
|
||||||
|
errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated'));
|
||||||
|
|
||||||
|
describe('IsNotBackdated', () => {
|
||||||
|
it('rejects a timestamp from the past', async () => {
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const errors = await errorsFor(yesterday);
|
||||||
|
|
||||||
|
expect(backdatedErrors(errors)).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints?.IsNotBackdated).toBe(
|
||||||
|
'occurredAt cannot be backdated — it must be now or later',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts now', async () => {
|
||||||
|
const errors = await errorsFor(new Date().toISOString());
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a value stale only by transit and clock skew', async () => {
|
||||||
|
// What an honest caller sends: "now" as of when the request was built.
|
||||||
|
const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString();
|
||||||
|
|
||||||
|
const errors = await errorsFor(almostNow);
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a value staler than the skew allowance', async () => {
|
||||||
|
const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString();
|
||||||
|
|
||||||
|
const errors = await errorsFor(tooStale);
|
||||||
|
|
||||||
|
expect(backdatedErrors(errors)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an absent value so @IsOptional decides', async () => {
|
||||||
|
const errors = await errorsFor(undefined);
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unparseable value to the format validator', async () => {
|
||||||
|
const errors = await errorsFor('not-a-date');
|
||||||
|
|
||||||
|
// Reported as a format problem, not as a backdate.
|
||||||
|
expect(backdatedErrors(errors)).toHaveLength(0);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isIso8601');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A caller may not stamp an event as having happened before now.
|
||||||
|
*
|
||||||
|
* A request cannot reach the server at the instant it was built, and a caller's
|
||||||
|
* clock is not the server's, so a timestamp that honestly means "now" always
|
||||||
|
* arrives a little stale. Comparing straight against `Date.now()` would reject
|
||||||
|
* it. The skew allowance below is what makes an honest "now" pass — it is not a
|
||||||
|
* window for backdating, and it is deliberately far too small to reach any
|
||||||
|
* earlier event worth backdating to.
|
||||||
|
*/
|
||||||
|
export const CLOCK_SKEW_TOLERANCE_MS = 60_000;
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'IsNotBackdated', async: false })
|
||||||
|
export class IsNotBackdatedConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown, args: ValidationArguments): boolean {
|
||||||
|
// Absence is not this validator's business; pair with @IsOptional.
|
||||||
|
if (value === undefined || value === null || value === '') return true;
|
||||||
|
const parsed = new Date(value as string | Date);
|
||||||
|
// An unparseable value is a format error — let @IsISO8601/@IsDateString own
|
||||||
|
// that message rather than reporting it as a backdate.
|
||||||
|
if (Number.isNaN(parsed.getTime())) return true;
|
||||||
|
const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS;
|
||||||
|
return parsed.getTime() >= Date.now() - toleranceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${args.property} cannot be backdated — it must be now or later`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}.
|
||||||
|
* Pass a different tolerance only with a reason.
|
||||||
|
*/
|
||||||
|
export function IsNotBackdated(
|
||||||
|
toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS,
|
||||||
|
validationOptions?: ValidationOptions,
|
||||||
|
) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [toleranceMs],
|
||||||
|
validator: IsNotBackdatedConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
validate,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { IsTin, normalizeTin } from './is-tin.validator';
|
||||||
|
|
||||||
|
class Required {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
|
tin!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Optional {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
|
tin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errs(cls: any, tin: any) {
|
||||||
|
const o = new cls();
|
||||||
|
o.tin = tin;
|
||||||
|
return (await validate(o)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IsTin', () => {
|
||||||
|
it('accepts a real 10-digit TIN', async () => {
|
||||||
|
expect(await errs(Required, '0012345678')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['letters', 'ABCDEFGHIJ'],
|
||||||
|
['symbols', '!!!!!!!!!!'],
|
||||||
|
['too short', '123'],
|
||||||
|
['too long', '12345678901'],
|
||||||
|
['draft TIN', 'D123456789'],
|
||||||
|
['spaced', '012 345678'],
|
||||||
|
])('rejects %s', async (_label, value) => {
|
||||||
|
expect(await errs(Required, value)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty on the required DTO but allows omission on the optional one', async () => {
|
||||||
|
expect(await errs(Required, '')).toBeGreaterThan(0);
|
||||||
|
expect(await errs(Optional, undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes messy input', () => {
|
||||||
|
expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678');
|
||||||
|
expect(normalizeTin('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** An Ethiopian TIN is exactly 10 digits. */
|
||||||
|
export const TIN_REGEX = /^\d{10}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by
|
||||||
|
* CompaniesService.generateDraftTin(), because the column is NOT NULL + unique.
|
||||||
|
* Those never travel through a DTO, so this constraint deliberately rejects them
|
||||||
|
* — a "D…" value arriving on a request body is client-supplied and invalid.
|
||||||
|
*/
|
||||||
|
@ValidatorConstraint({ name: 'IsTin', async: false })
|
||||||
|
export class IsTinConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||||
|
if (value === undefined || value === null || value === '') return true;
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
return TIN_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${args.property} must be exactly 10 digits`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Class-validator decorator enforcing the 10-digit TIN format. */
|
||||||
|
export function IsTin(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsTinConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip everything that isn't a digit and cap at 10 characters. Tolerant —
|
||||||
|
* never throws; returns the value unchanged when empty/nullish.
|
||||||
|
*/
|
||||||
|
export function normalizeTin(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return value;
|
||||||
|
return value.replace(/\D/g, '').slice(0, 10);
|
||||||
|
}
|
||||||
@@ -9,6 +9,14 @@ export default registerAs("app", () => ({
|
|||||||
env: process.env.NODE_ENV ?? "development",
|
env: process.env.NODE_ENV ?? "development",
|
||||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||||
apiPrefix: "api",
|
apiPrefix: "api",
|
||||||
|
/**
|
||||||
|
* Public origin of the freight customer portal. Password-reset links mailed
|
||||||
|
* or SMS'd to customers are built against this, so it must be the address the
|
||||||
|
* customer's browser can actually reach — not an internal service name.
|
||||||
|
*/
|
||||||
|
portalBaseUrl: (
|
||||||
|
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||||
|
).replace(/\/+$/, ""),
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Let a support message carry files instead of text.
|
||||||
|
*
|
||||||
|
* No new table: chat attachments reuse the polymorphic `freight.files` record
|
||||||
|
* with `resource = 'support_message'` and `resource_id = <message id>`, the same
|
||||||
|
* way bookings/contracts/companies already store theirs.
|
||||||
|
*
|
||||||
|
* The only schema change is dropping NOT NULL from `support_messages.body`, so
|
||||||
|
* an attachment-only message can say "there is no text" rather than smuggling
|
||||||
|
* that fact through an empty string. DROP NOT NULL is a catalog-only change in
|
||||||
|
* Postgres — no table rewrite, no long lock — so this is safe on a live table.
|
||||||
|
*
|
||||||
|
* The partial index on (resource, resource_id) is what makes hydrating a page of
|
||||||
|
* messages one indexed lookup instead of a scan of every file row in the system.
|
||||||
|
*/
|
||||||
|
export class SupportChatAttachments2320000000000 implements MigrationInterface {
|
||||||
|
name = "SupportChatAttachments2320000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.support_messages
|
||||||
|
ALTER COLUMN body DROP NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP"
|
||||||
|
ON freight.files (resource, resource_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP"
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Re-imposing NOT NULL would fail on any attachment-only message written
|
||||||
|
// while this migration was applied. Backfill those to '' first so the
|
||||||
|
// rollback is deterministic rather than dependent on production data.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.support_messages SET body = '' WHERE body IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.support_messages
|
||||||
|
ALTER COLUMN body SET NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A facility handles what its equipment can handle. Containers need a reach
|
||||||
|
* stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs
|
||||||
|
* far less, so all five facilities load and unload it.
|
||||||
|
*
|
||||||
|
* Both default true — a facility handles everything unless someone says
|
||||||
|
* otherwise, which keeps existing rows working and makes the seeder the place
|
||||||
|
* where the real capability is stated.
|
||||||
|
*/
|
||||||
|
export class YardFacilityFreightTypes2320000000000 implements MigrationInterface {
|
||||||
|
name = 'YardFacilityFreightTypes2320000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yard_facilities
|
||||||
|
ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yard_facilities
|
||||||
|
DROP COLUMN IF EXISTS handles_container,
|
||||||
|
DROP COLUMN IF EXISTS handles_bulk
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New built-train lifecycle status DEACTIVATED: staff park a train indefinitely
|
||||||
|
* (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like
|
||||||
|
* UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never
|
||||||
|
* overwrites it and refuses to schedule a deactivated train.
|
||||||
|
*
|
||||||
|
* Postgres cannot drop an enum value, so down() is a no-op.
|
||||||
|
*/
|
||||||
|
export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface {
|
||||||
|
name = 'AddTrainDeactivatedStatus2380000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Enum values cannot be removed in Postgres; leaving the label is harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia)
|
||||||
|
* selectable in the Train Builder. The paired EXPORT number is derived
|
||||||
|
* (import − 1), so only the import side is configured. Seeded with the runs
|
||||||
|
* historically hardcoded in the backoffice's trainRuns constants; admins add
|
||||||
|
* new runs from the Dropdown Settings editor.
|
||||||
|
*/
|
||||||
|
export class SeedImportTrainNumbers2390000000000 implements MigrationInterface {
|
||||||
|
name = 'SeedImportTrainNumbers2390000000000';
|
||||||
|
private readonly code = 'import_train_numbers';
|
||||||
|
private readonly options: string[] = [
|
||||||
|
'8002',
|
||||||
|
'8102',
|
||||||
|
'8202',
|
||||||
|
'8302',
|
||||||
|
'8402',
|
||||||
|
'8502',
|
||||||
|
'8602',
|
||||||
|
'8702',
|
||||||
|
'8802',
|
||||||
|
'8902',
|
||||||
|
'9002',
|
||||||
|
];
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const existing = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const inserted = await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta)
|
||||||
|
VALUES ($1, $2, $3, false, $4::jsonb)
|
||||||
|
RETURNING id;`,
|
||||||
|
[
|
||||||
|
this.code,
|
||||||
|
'Import train numbers',
|
||||||
|
'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).',
|
||||||
|
JSON.stringify({ searchable: true, clearable: true }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const settingId = inserted[0].id;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.options.length; i++) {
|
||||||
|
const value = this.options[i];
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4);`,
|
||||||
|
[settingId, value, value, i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||||
|
this.code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard soft-delete now appends `@<epoch-ms>` to the unique code (SEBETA →
|
||||||
|
* SEBETA@1755612345678) so the name can be reused by a new yard while
|
||||||
|
* UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long
|
||||||
|
* codes plus the 14-char suffix, so widen to 40.
|
||||||
|
*/
|
||||||
|
export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface {
|
||||||
|
name = 'WidenYardCodeForSoftDeleteSuffix2390000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Narrowing would fail on suffixed codes; keep 40.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck exit weights for customer self-haul, mirroring what
|
||||||
|
* `last_mile_vehicle_assignments` already carries for EDR trucks.
|
||||||
|
*
|
||||||
|
* A bulk booking is hauled away truck by truck until no tonnage is left, and the
|
||||||
|
* EDR side enforces that by summing `net_weight_tons` of departed trucks. The
|
||||||
|
* customer side had no net and no tare — only `gross_weight_kg`, which nothing
|
||||||
|
* in the live flow ever wrote (the release flow updated the EDR table only). So
|
||||||
|
* a self-haul bulk booking could take unlimited trucks: hauled tonnage always
|
||||||
|
* summed to zero.
|
||||||
|
*
|
||||||
|
* `gross_weight_kg` is left alone but note it holds TONNES despite its name —
|
||||||
|
* the weighing UI is in tonnes throughout. The new columns are named for the
|
||||||
|
* unit they actually hold.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCustomerTruckExitWeights2400000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Departed trucks are what the drawdown sums, so it reads this index.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed"
|
||||||
|
ON freight.customer_truck_assignments (booking_id, departed_at)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
DROP COLUMN IF EXISTS tare_weight_tons,
|
||||||
|
DROP COLUMN IF EXISTS net_weight_tons
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `freight.companies.region` was free text until region became a closed set
|
||||||
|
* (see ETHIOPIAN_REGIONS in @edr/types). This normalizes the rows written under
|
||||||
|
* the old rules so they satisfy the new dropdown.
|
||||||
|
*
|
||||||
|
* Two classes of bad data exist, handled differently:
|
||||||
|
*
|
||||||
|
* - Unambiguous spelling/case drift ("Addis ababa", "oromoia") — rewritten to
|
||||||
|
* the canonical spelling.
|
||||||
|
* - Values that are not regions at all ("Arba Minch", a city), and rows whose
|
||||||
|
* region contradicts their own zone/woreda — set to NULL. These are NOT
|
||||||
|
* guessed at: inferring "Gurage/Meskan" means Central Ethiopia would silently
|
||||||
|
* overwrite what the customer actually submitted. NULL surfaces the gap and
|
||||||
|
* the required dropdown forces a deliberate pick on next edit.
|
||||||
|
*/
|
||||||
|
export class NormalizeCompanyRegions2400000000000 implements MigrationInterface {
|
||||||
|
name = 'NormalizeCompanyRegions2400000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Canonical spellings — case/whitespace insensitive, safe to re-run.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.companies
|
||||||
|
SET region = v.canonical
|
||||||
|
FROM (VALUES
|
||||||
|
('addis ababa', 'Addis Ababa'),
|
||||||
|
('addis abeba', 'Addis Ababa'),
|
||||||
|
('addisababa', 'Addis Ababa'),
|
||||||
|
('oromia', 'Oromia'),
|
||||||
|
('oromoia', 'Oromia'),
|
||||||
|
('oromiya', 'Oromia'),
|
||||||
|
('amhara', 'Amhara'),
|
||||||
|
('somali', 'Somali'),
|
||||||
|
('afar', 'Afar'),
|
||||||
|
('tigray', 'Tigray'),
|
||||||
|
('tigrai', 'Tigray'),
|
||||||
|
('sidama', 'Sidama'),
|
||||||
|
('harari', 'Harari'),
|
||||||
|
('gambela', 'Gambela'),
|
||||||
|
('gambella', 'Gambela'),
|
||||||
|
('dire dawa', 'Dire Dawa'),
|
||||||
|
('benishangul-gumuz', 'Benishangul-Gumuz'),
|
||||||
|
('benishangul gumuz', 'Benishangul-Gumuz'),
|
||||||
|
('central ethiopia', 'Central Ethiopia'),
|
||||||
|
('south ethiopia', 'South Ethiopia')
|
||||||
|
) AS v(variant, canonical)
|
||||||
|
WHERE freight.companies.region IS NOT NULL
|
||||||
|
AND lower(regexp_replace(btrim(freight.companies.region), '\\s+', ' ', 'g')) = v.variant
|
||||||
|
AND freight.companies.region <> v.canonical
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Anything still outside the canonical set is unresolvable — null it.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.companies
|
||||||
|
SET region = NULL
|
||||||
|
WHERE region IS NOT NULL
|
||||||
|
AND region <> ''
|
||||||
|
AND region NOT IN (
|
||||||
|
'Addis Ababa','Afar','Amhara','Benishangul-Gumuz','Central Ethiopia',
|
||||||
|
'Dire Dawa','Gambela','Harari','Oromia','Sidama','Somali',
|
||||||
|
'South Ethiopia','South West Ethiopia Peoples''','Tigray'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Normalize empty string to NULL so "unset" has one representation.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.companies SET region = NULL WHERE region = ''
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Irreversible by design: the original free-text values are not retained
|
||||||
|
// anywhere, so there is nothing to restore. Rolling back the code is safe —
|
||||||
|
// the column is still a nullable varchar(100) and accepts free text again.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookings no longer run an approval chain — accepting an intake approves the
|
||||||
|
* booking outright and generates its contract. The approval chain is now a
|
||||||
|
* contract-only concern, so `freight.approval_rules` is read by contracts alone.
|
||||||
|
*
|
||||||
|
* Also widens the role columns: chain steps now reference IAM position-type
|
||||||
|
* keys (`iam.position_types.key`), and real keys run past the old varchar(30)
|
||||||
|
* (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail
|
||||||
|
* on insert.
|
||||||
|
*/
|
||||||
|
export class DropBookingApprovalWidenRoles2410000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'DropBookingApprovalWidenRoles2410000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.booking_approval_step;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [table, column] of [
|
||||||
|
['approval_rules', 'required_role'],
|
||||||
|
['approval_rules', 'blocks_role'],
|
||||||
|
['contract_approval_steps', 'required_role'],
|
||||||
|
['contract_approval_steps', 'blocks_role'],
|
||||||
|
] as const) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op: the booking approval chain is retired, so re-creating the table
|
||||||
|
* would leave dead schema behind. Narrowing the role columns again would
|
||||||
|
* truncate any position-type key already stored.
|
||||||
|
*/
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit trail for contract document edits. The document stays editable through
|
||||||
|
* the whole approval chain (each approver may edit on their turn), so the
|
||||||
|
* contract itself only ever holds the current snapshot — this table records who
|
||||||
|
* changed which article, and when.
|
||||||
|
*/
|
||||||
|
export class CreateContractDocumentRevisions2420000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreateContractDocumentRevisions2420000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_document_revisions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
actor_id uuid,
|
||||||
|
actor_role varchar(64),
|
||||||
|
step_id uuid,
|
||||||
|
summary varchar(255),
|
||||||
|
changes jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract
|
||||||
|
ON freight.contract_document_revisions (contract_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||||
import { CustomerResetService } from "./customer-reset.service";
|
import {
|
||||||
|
CustomerResetService,
|
||||||
|
CustomerResetTarget,
|
||||||
|
} from "./customer-reset.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff-triggered password reset. The customer receives the code and sets their
|
* Staff-triggered password reset. The customer receives a single-use link and
|
||||||
* own password — staff never see or handle a credential.
|
* sets their own password — staff never see or handle a credential.
|
||||||
*/
|
*/
|
||||||
@ApiTags("backoffice")
|
@ApiTags("backoffice")
|
||||||
@Controller("backoffice/customers")
|
@Controller("backoffice/customers")
|
||||||
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
|
|||||||
export class CustomerResetController {
|
export class CustomerResetController {
|
||||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||||
|
|
||||||
|
@Get(":companyId/reset-target")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "The primary contact's IAM account a reset link would be sent to",
|
||||||
|
})
|
||||||
|
async resetTarget(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
): Promise<CustomerResetTarget> {
|
||||||
|
const target = await this.customerResetService.getResetTarget(companyId);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"This customer has no active primary-contact account to reset",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/reset-password")
|
@Post(":companyId/reset-password")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Send a password-reset code to a customer's primary contact",
|
summary: "Send a password-reset link to a customer's primary contact",
|
||||||
})
|
})
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@Body() dto: BackofficeResetPasswordDto,
|
@Body() dto: BackofficeResetPasswordDto,
|
||||||
) {
|
) {
|
||||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
const sent = await this.customerResetService.sendResetLinkToCustomer(
|
||||||
companyId,
|
companyId,
|
||||||
dto.channel,
|
dto.channel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maskedTarget) {
|
if (!sent) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { channel: dto.channel, maskedTarget };
|
return sent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||||
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||||
import { ForgotPasswordService } from "./forgot-password.service";
|
import {
|
||||||
|
ForgotPasswordService,
|
||||||
|
RESET_LINK_TTL_MS,
|
||||||
|
} from "./forgot-password.service";
|
||||||
|
import { maskOtpTarget } from "./mask-target.util";
|
||||||
|
|
||||||
|
/** The account a staff-triggered reset would land on. */
|
||||||
|
export interface CustomerResetTarget {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentResetLink {
|
||||||
|
channel: ResetChannel;
|
||||||
|
maskedTarget: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerResetService {
|
export class CustomerResetService {
|
||||||
@@ -14,19 +35,110 @@ export class CustomerResetService {
|
|||||||
@InjectRepository(ExternalProfile)
|
@InjectRepository(ExternalProfile)
|
||||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||||
private readonly forgotPasswordService: ForgotPasswordService,
|
private readonly forgotPasswordService: ForgotPasswordService,
|
||||||
|
private readonly emailClient: EmailClientService,
|
||||||
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a reset code to the company's primary contact. Returns the masked
|
* The IAM account a reset would actually reach. The backoffice shows these
|
||||||
* destination, or null when there is no eligible account for that channel.
|
* values rather than `company.email` / `company.phone`: the company row holds
|
||||||
|
* business contact detail, while the link is delivered to the primary
|
||||||
|
* contact's own login credentials — the two drift apart routinely, and showing
|
||||||
|
* the wrong one has staff telling customers to check an inbox nothing was sent
|
||||||
|
* to.
|
||||||
|
*/
|
||||||
|
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
|
||||||
|
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||||
|
if (!resolved) return null;
|
||||||
|
|
||||||
|
const { profile, user, userId } = resolved;
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||||
|
email: user.email ?? null,
|
||||||
|
phone: user.phoneNumber ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a password-reset link and send it to the company's primary contact.
|
||||||
|
* Returns the masked destination, or null when there is no eligible account
|
||||||
|
* for that channel.
|
||||||
*
|
*
|
||||||
* Unlike the public flow this reports failure honestly — the caller is an
|
* Unlike the public flow this reports failure honestly — the caller is an
|
||||||
* authenticated staff member, so there is nothing to enumerate.
|
* authenticated staff member, so there is nothing to enumerate.
|
||||||
*/
|
*/
|
||||||
async sendResetToCustomer(
|
async sendResetLinkToCustomer(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
channel: ResetChannel,
|
channel: ResetChannel,
|
||||||
): Promise<string | null> {
|
): Promise<SentResetLink | null> {
|
||||||
|
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||||
|
if (!resolved) return null;
|
||||||
|
|
||||||
|
const { user, userId } = resolved;
|
||||||
|
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||||
|
// expires, whereas sending a link before the ticket exists would hand the
|
||||||
|
// customer a URL that is dead on arrival.
|
||||||
|
const ticket = await this.forgotPasswordService.mintResetTicket(
|
||||||
|
userId,
|
||||||
|
RESET_LINK_TTL_MS,
|
||||||
|
);
|
||||||
|
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||||
|
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||||
|
|
||||||
|
const { queued } = target.email
|
||||||
|
? await this.emailClient.sendEmail({
|
||||||
|
to: target.email,
|
||||||
|
subject: "Reset your EDR Freight password",
|
||||||
|
text:
|
||||||
|
"A password reset was started for your EDR Freight account.\n\n" +
|
||||||
|
`Open this link to choose a new password:\n${link}\n\n` +
|
||||||
|
"The link expires in 24 hours and can only be used once. If you did " +
|
||||||
|
"not expect this, ignore this message — your password stays unchanged.",
|
||||||
|
})
|
||||||
|
: await this.smsClient.sendSms({
|
||||||
|
to: target.phone as string,
|
||||||
|
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!queued) {
|
||||||
|
// The ticket is committed and the backoffice is about to say "link sent",
|
||||||
|
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
|
||||||
|
// are no-ops. Without this line the only symptom is a customer who never
|
||||||
|
// receives anything, indistinguishable from carrier loss.
|
||||||
|
this.logger.error(
|
||||||
|
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
|
||||||
|
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
|
} — transport reported no hand-off; no link will arrive`,
|
||||||
|
);
|
||||||
|
// SECURITY: logs a live password-reset credential in cleartext. Same
|
||||||
|
// deliberate tradeoff the OTP service makes — this is the only way to
|
||||||
|
// complete a reset on an environment with no broker. Only reached when
|
||||||
|
// delivery already failed.
|
||||||
|
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
maskedTarget: maskOtpTarget(target),
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company's primary contact, gated on the same active-account rule the
|
||||||
|
* public flow uses — so a suspended customer cannot be reactivated by a
|
||||||
|
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
|
||||||
|
*/
|
||||||
|
private async resolvePrimaryContactUser(companyId: string) {
|
||||||
const profile = await this.externalProfileRepository.findOne({
|
const profile = await this.externalProfileRepository.findOne({
|
||||||
where: { companyId, isPrimaryContact: true },
|
where: { companyId, isPrimaryContact: true },
|
||||||
});
|
});
|
||||||
@@ -36,24 +148,28 @@ export class CustomerResetService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same active-account gate the public flow uses, so a
|
|
||||||
// suspended customer cannot be reactivated by a staff-triggered reset.
|
|
||||||
const user = await this.forgotPasswordService.resolveActiveUserById(
|
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||||
profile.userId,
|
profile.userId,
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user?.id) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
return { profile, user, userId: user.id };
|
||||||
if (!target) return null;
|
}
|
||||||
|
|
||||||
this.logger.log(
|
/**
|
||||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
* The portal route that trades the token for a set-password form. Params are
|
||||||
);
|
* URL-encoded because the token is base64url — safe as-is, but the encoding
|
||||||
return this.forgotPasswordService.maskTarget(target);
|
* keeps this correct if the token format ever changes.
|
||||||
|
*/
|
||||||
|
private buildResetLink(userId: string, token: string): string {
|
||||||
|
const base = this.config.get<string>("app.portalBaseUrl");
|
||||||
|
return `${base}/reset-password?uid=${encodeURIComponent(
|
||||||
|
userId,
|
||||||
|
)}&token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||||
|
|
||||||
/** The channel the reset code is delivered over. */
|
/**
|
||||||
|
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
|
||||||
|
* it sends to every contact on the account — but the staff-triggered link flow
|
||||||
|
* still delivers over exactly one transport.
|
||||||
|
*/
|
||||||
export enum ResetChannel {
|
export enum ResetChannel {
|
||||||
Email = "email",
|
Email = "email",
|
||||||
Phone = "phone",
|
Phone = "phone",
|
||||||
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
identifier!: string;
|
identifier!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: ResetChannel })
|
/**
|
||||||
|
* Accepted and ignored. The code now goes to the account's email AND phone,
|
||||||
|
* so there is nothing to choose — kept optional so clients still sending it
|
||||||
|
* (older portal/backoffice builds) are not rejected outright.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ResetChannel,
|
||||||
|
deprecated: true,
|
||||||
|
description: "Ignored — the code is sent to every contact on the account.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsEnum(ResetChannel)
|
@IsEnum(ResetChannel)
|
||||||
channel!: ResetChannel;
|
channel?: ResetChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
otp!: string;
|
otp!: string;
|
||||||
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
|
|||||||
@IsEnum(ResetChannel)
|
@IsEnum(ResetChannel)
|
||||||
channel!: ResetChannel;
|
channel!: ResetChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two halves of a reset link's query string. Together they stand in for the
|
||||||
|
* identifier + OTP pair of the typed flow: the token proves possession of the
|
||||||
|
* inbox/handset the link was delivered to.
|
||||||
|
*/
|
||||||
|
export class ResolveResetLinkDto {
|
||||||
|
@ApiProperty({ description: "IAM user id from the reset link's `uid` param" })
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Opaque token from the reset link's `token` param" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { Public } from "@edr/api-common";
|
|||||||
import {
|
import {
|
||||||
ForgotPasswordRequestDto,
|
ForgotPasswordRequestDto,
|
||||||
ForgotPasswordVerifyDto,
|
ForgotPasswordVerifyDto,
|
||||||
|
ResolveResetLinkDto,
|
||||||
} from "./dto/forgot-password.dto";
|
} from "./dto/forgot-password.dto";
|
||||||
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
|
import {
|
||||||
|
ForgotPasswordService,
|
||||||
|
ResetLinkAccount,
|
||||||
|
ResetTicket,
|
||||||
|
} from "./forgot-password.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||||
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
|
|||||||
|
|
||||||
@Post("forgot-password/request")
|
@Post("forgot-password/request")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Send a password-reset code over email or SMS",
|
summary: "Send a password-reset code to the account's email AND phone",
|
||||||
description:
|
description:
|
||||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
"One code, delivered over every contact the account has; either delivery " +
|
||||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
"verifies it. Always reports success — an unknown, inactive, or contactless " +
|
||||||
|
"account is indistinguishable from a real one, so this cannot be used to " +
|
||||||
|
"enumerate accounts.",
|
||||||
})
|
})
|
||||||
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
await this.forgotPasswordService.requestReset(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A delivery failure must not change the response shape either — log it
|
// A delivery failure must not change the response shape either — log it
|
||||||
// and let the caller sit on the OTP screen.
|
// and let the caller sit on the OTP screen.
|
||||||
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
|
|||||||
"alongside the same identifier and the new password.",
|
"alongside the same identifier and the new password.",
|
||||||
})
|
})
|
||||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||||
return this.forgotPasswordService.verifyAndMintTicket(
|
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
|
||||||
dto.identifier,
|
}
|
||||||
dto.channel,
|
|
||||||
dto.otp,
|
@Post("forgot-password/resolve-link")
|
||||||
);
|
@ApiOperation({
|
||||||
|
summary: "Validate a staff-issued reset link and return its set-password ticket",
|
||||||
|
description:
|
||||||
|
"Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " +
|
||||||
|
"is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " +
|
||||||
|
"A bad or expired link is rejected here rather than after the password is typed.",
|
||||||
|
})
|
||||||
|
resolveLink(@Body() dto: ResolveResetLinkDto): Promise<ResetLinkAccount> {
|
||||||
|
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
|||||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||||
import { DataSource, Repository } from "typeorm";
|
import { DataSource, Repository } from "typeorm";
|
||||||
|
|
||||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon";
|
||||||
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
||||||
@@ -22,11 +22,32 @@ const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
|
|||||||
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staff-triggered reset link lives longer than a typed OTP: the customer may
|
||||||
|
* only see the SMS/email hours after the call that prompted it.
|
||||||
|
*/
|
||||||
|
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** IAM refuses a ticket once its row hits this many failed attempts. */
|
||||||
|
const MAX_TICKET_ATTEMPTS = 5;
|
||||||
|
|
||||||
export interface ResetTicket {
|
export interface ResetTicket {
|
||||||
userId: string;
|
userId: string;
|
||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a valid reset link resolves to. `identifier` is the value IAM's
|
||||||
|
* `set-password` matches the user on (it accepts email / username / phone), so
|
||||||
|
* the portal can spend the ticket without the customer typing anything.
|
||||||
|
*/
|
||||||
|
export interface ResetLinkAccount {
|
||||||
|
userId: string;
|
||||||
|
identifier: string;
|
||||||
|
maskedIdentifier: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ForgotPasswordService {
|
export class ForgotPasswordService {
|
||||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||||
@@ -81,8 +102,12 @@ export class ForgotPasswordService {
|
|||||||
.orderBy("u.createdAt", "DESC");
|
.orderBy("u.createdAt", "DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The address the code goes to, taken from the account — never from input. */
|
/**
|
||||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
* A single channel of the account, for flows that genuinely deliver over one
|
||||||
|
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
|
||||||
|
* the account — never from input.
|
||||||
|
*/
|
||||||
|
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||||
if (channel === ResetChannel.Email) {
|
if (channel === ResetChannel.Email) {
|
||||||
return user.email ? { email: user.email } : null;
|
return user.email ? { email: user.email } : null;
|
||||||
}
|
}
|
||||||
@@ -90,20 +115,40 @@ export class ForgotPasswordService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a reset code to the account's own email/phone. Returns the target so
|
* Every contact the account has. The reset OTP goes to all of them and any one
|
||||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
* verifies it — a customer whose SMS never lands can finish from their inbox
|
||||||
* callers must discard it.
|
* without restarting the flow on a different channel. An account holding only
|
||||||
|
* one of the two degrades to that channel; only a contactless account is null.
|
||||||
|
*/
|
||||||
|
targetsFor(user: User): OtpTarget | null {
|
||||||
|
const target: OtpTarget = {};
|
||||||
|
if (user.email) target.email = user.email;
|
||||||
|
if (user.phoneNumber) target.phone = user.phoneNumber;
|
||||||
|
return target.email || target.phone ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value IAM's `set-password` will match this account on. It looks the user
|
||||||
|
* up by email OR username OR phoneNumber (and lowercases whatever it is
|
||||||
|
* given), so prefer email, then phone, and fall back to username last —
|
||||||
|
* a mixed-case username would not survive that lowercasing.
|
||||||
|
*/
|
||||||
|
private identifierFor(user: User): string | null {
|
||||||
|
return user.email ?? user.phoneNumber ?? user.username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one reset code to every contact on the account — email AND phone —
|
||||||
|
* returning the target so authenticated (backoffice) callers can echo a masked
|
||||||
|
* version; unauthenticated callers must discard it.
|
||||||
*
|
*
|
||||||
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||||
* upserts. A reset request therefore overwrites any pending signup code for
|
* replaces every row the target overlaps. A reset request therefore overwrites
|
||||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
* any pending signup code for the same addresses — last code sent wins. That
|
||||||
* between any two flows sharing this table.
|
* is the pre-existing behaviour between any two flows sharing this table.
|
||||||
*/
|
*/
|
||||||
async requestReset(
|
async requestReset(user: User): Promise<OtpTarget | null> {
|
||||||
user: User,
|
const target = this.targetsFor(user);
|
||||||
channel: ResetChannel,
|
|
||||||
): Promise<OtpTarget | null> {
|
|
||||||
const target = this.targetFor(user, channel);
|
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
await this.otpService.sendOtp(target);
|
await this.otpService.sendOtp(target);
|
||||||
@@ -120,11 +165,12 @@ export class ForgotPasswordService {
|
|||||||
*/
|
*/
|
||||||
async verifyAndMintTicket(
|
async verifyAndMintTicket(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
channel: ResetChannel,
|
|
||||||
otp: string,
|
otp: string,
|
||||||
): Promise<ResetTicket> {
|
): Promise<ResetTicket> {
|
||||||
const user = await this.resolveActiveUser(identifier);
|
const user = await this.resolveActiveUser(identifier);
|
||||||
const target = user && this.targetFor(user, channel);
|
// Same set of contacts `requestReset` sent to, so the code resolves whichever
|
||||||
|
// of the two the customer actually received it on.
|
||||||
|
const target = user && this.targetsFor(user);
|
||||||
|
|
||||||
if (!user?.id || !target) {
|
if (!user?.id || !target) {
|
||||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||||
@@ -134,9 +180,18 @@ export class ForgotPasswordService {
|
|||||||
|
|
||||||
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
||||||
|
|
||||||
|
return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
|
||||||
|
* is the proof of possession) and the staff-triggered link flow (where the
|
||||||
|
* ticket travels in the link and delivery to the account's own inbox/handset
|
||||||
|
* is the proof).
|
||||||
|
*/
|
||||||
|
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
|
||||||
const code = randomBytes(24).toString("base64url");
|
const code = randomBytes(24).toString("base64url");
|
||||||
const verificationCode = await hashPassword(code);
|
const verificationCode = await hashPassword(code);
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const repo = manager.getRepository(UserVerification);
|
const repo = manager.getRepository(UserVerification);
|
||||||
@@ -147,7 +202,7 @@ export class ForgotPasswordService {
|
|||||||
userId,
|
userId,
|
||||||
otpType: EOtpType.RESET_PASSWORD,
|
otpType: EOtpType.RESET_PASSWORD,
|
||||||
verificationCode,
|
verificationCode,
|
||||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
expiresAt: new Date(Date.now() + ttlMs),
|
||||||
isUsed: false,
|
isUsed: false,
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
});
|
});
|
||||||
@@ -157,6 +212,63 @@ export class ForgotPasswordService {
|
|||||||
return { userId, verificationCode: code };
|
return { userId, verificationCode: code };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a reset link and hand back everything the portal needs to spend it
|
||||||
|
* on IAM's `PATCH /api/auth/set-password`.
|
||||||
|
*
|
||||||
|
* The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
|
||||||
|
* argon match — so a link that resolves here is one IAM will honour. Doing
|
||||||
|
* them up front is what lets the page say "this link has expired" before the
|
||||||
|
* customer types a password rather than after.
|
||||||
|
*
|
||||||
|
* Every rejection is the same message: a link is a bearer credential, and the
|
||||||
|
* holder of a bad one learns nothing about why it failed or whether the user
|
||||||
|
* id exists.
|
||||||
|
*/
|
||||||
|
async resolveResetLink(
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<ResetLinkAccount> {
|
||||||
|
const invalid = new BadRequestException(
|
||||||
|
"This password-reset link is invalid or has expired. Request a new one.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await this.resolveActiveUserById(userId);
|
||||||
|
const identifier = user && this.identifierFor(user);
|
||||||
|
if (!user || !identifier) throw invalid;
|
||||||
|
|
||||||
|
const verification = await this.dataSource
|
||||||
|
.getRepository(UserVerification)
|
||||||
|
.findOne({
|
||||||
|
where: { userId, otpType: EOtpType.RESET_PASSWORD },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// `expiresAt` / `attemptCount` are optional on IAM's entity but always
|
||||||
|
// written by `mintResetTicket`. A row missing either is malformed, so treat
|
||||||
|
// it as expired rather than letting it through unchecked.
|
||||||
|
if (
|
||||||
|
!verification ||
|
||||||
|
verification.isUsed ||
|
||||||
|
!verification.expiresAt ||
|
||||||
|
verification.expiresAt < new Date() ||
|
||||||
|
(verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
|
||||||
|
!(await verifyPassword(token, verification.verificationCode))
|
||||||
|
) {
|
||||||
|
this.logger.warn(`Reset link rejected for user ${userId}`);
|
||||||
|
throw invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
identifier,
|
||||||
|
maskedIdentifier: maskOtpTarget(
|
||||||
|
identifier.includes("@") ? { email: identifier } : { phone: identifier },
|
||||||
|
),
|
||||||
|
verificationCode: token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||||
maskTarget(target: OtpTarget): string {
|
maskTarget(target: OtpTarget): string {
|
||||||
return maskOtpTarget(target);
|
return maskOtpTarget(target);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
|||||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||||
|
|
||||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
@@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service';
|
|||||||
Employee,
|
Employee,
|
||||||
]),
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
// Reset links go out over email/SMS directly, not through the OTP service.
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
FreightMeController,
|
FreightMeController,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { OtpTarget } from "../otp/otp.service";
|
import { OtpTarget } from "../otp/otp.service";
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = email.split("@");
|
||||||
|
const head = local.slice(0, 1);
|
||||||
|
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(phone: string): string {
|
||||||
|
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||||
* a caller who has not yet proven possession of the channel.
|
* a caller who has not yet proven possession of the channel.
|
||||||
|
*
|
||||||
|
* A dual-channel target masks both and joins them, so the UI can say exactly
|
||||||
|
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
|
||||||
|
* one of the two otherwise assumes the other never received anything.
|
||||||
*/
|
*/
|
||||||
export function maskOtpTarget(target: OtpTarget): string {
|
export function maskOtpTarget(target: OtpTarget): string {
|
||||||
if (target.email) {
|
const parts: string[] = [];
|
||||||
const [local, domain] = target.email.split("@");
|
if (target.email) parts.push(maskEmail(target.email));
|
||||||
const head = local.slice(0, 1);
|
if (target.phone) parts.push(maskPhone(target.phone));
|
||||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
return parts.join(" and ");
|
||||||
}
|
|
||||||
const phone = target.phone ?? "";
|
|
||||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,13 +157,13 @@ export class BookingLifecycleNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clearance finalized → customer can proceed to request operation. */
|
/** Document approval finalized → customer can proceed to request operation. */
|
||||||
clearanceReady(b: Booking): void {
|
clearanceReady(b: Booking): void {
|
||||||
const msg =
|
const msg =
|
||||||
`Clearance for booking ${b.reference} is complete. ` +
|
`Document approval for booking ${b.reference} is finalized. ` +
|
||||||
`You can now proceed to request operation from the portal.`;
|
`You can now proceed to request operation from the portal.`;
|
||||||
void this.notifyContact(b, msg, 'CLEARANCE READY');
|
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
|
||||||
this.inApp(b, 'Clearance complete', msg, {
|
this.inApp(b, 'Document approval finalized', msg, {
|
||||||
type: NotificationType.CLEARANCE_DECISION,
|
type: NotificationType.CLEARANCE_DECISION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -253,6 +253,19 @@ export class BookingLifecycleNotifierService {
|
|||||||
|
|
||||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking was created under a contract. Contract drawdowns never pass
|
||||||
|
* through submit, so this is the only point at which staff learn the booking
|
||||||
|
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
|
||||||
|
*/
|
||||||
|
createdToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'New booking created',
|
||||||
|
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer submitted a booking for review. */
|
/** Customer submitted a booking for review. */
|
||||||
submittedToStaff(b: Booking): void {
|
submittedToStaff(b: Booking): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
export interface BookingNextStep {
|
export interface BookingNextStep {
|
||||||
@@ -9,7 +8,11 @@ export interface BookingNextStep {
|
|||||||
|
|
||||||
export function computeNextStep(
|
export function computeNextStep(
|
||||||
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
|
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
|
||||||
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
|
/**
|
||||||
|
* Retained for call-site compatibility — bookings no longer run an approval
|
||||||
|
* chain, so this is always null. Approvals are a contract-only concern.
|
||||||
|
*/
|
||||||
|
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
|
||||||
): BookingNextStep | null {
|
): BookingNextStep | null {
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Inject, Injectable } from "@nestjs/common";
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
import { In, Not } from "typeorm";
|
|
||||||
|
|
||||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||||
@@ -34,8 +33,6 @@ import {
|
|||||||
BookingReferenceYardDto,
|
BookingReferenceYardDto,
|
||||||
} from "./dto/booking-reference-data.dto";
|
} from "./dto/booking-reference-data.dto";
|
||||||
|
|
||||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
|
||||||
|
|
||||||
export function buildCargoTypeTree(
|
export function buildCargoTypeTree(
|
||||||
rows: CargoType[],
|
rows: CargoType[],
|
||||||
): BookingReferenceCargoTypeGroupDto[] {
|
): BookingReferenceCargoTypeGroupDto[] {
|
||||||
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
|
|||||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.yardsRepository.findAll({
|
this.yardsRepository.findAll({
|
||||||
where: {
|
where: { isActive: true },
|
||||||
isActive: true,
|
|
||||||
code: Not(In([...LEGACY_YARD_CODES])),
|
|
||||||
},
|
|
||||||
order: { displayOrder: "ASC", code: "ASC" },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.containerTypesRepository.findAll({
|
this.containerTypesRepository.findAll({
|
||||||
|
|||||||
@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
};
|
};
|
||||||
const ruleEngineService = {
|
const ruleEngineService = {
|
||||||
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
|
assertNoHardBlocks: jest.fn(),
|
||||||
|
};
|
||||||
|
const contractService = {
|
||||||
|
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new BookingTransitionService(
|
const service = new BookingTransitionService(
|
||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
ruleEngineService as never,
|
ruleEngineService as never,
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
contractService as never,
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('rejects accept when validity days is missing or non-positive', async () => {
|
it('rejects accept when validity days is missing or non-positive', async () => {
|
||||||
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
const [id, updates] = bookingsRepository.update.mock.calls[0];
|
const [id, updates] = bookingsRepository.update.mock.calls[0];
|
||||||
expect(id).toBe('b-1');
|
expect(id).toBe('b-1');
|
||||||
expect(updates).toMatchObject({
|
expect(updates).toMatchObject({
|
||||||
status: 'PENDING_APPROVAL',
|
status: 'APPROVED',
|
||||||
approvedByStaffId: 'staff-1',
|
approvedByStaffId: 'staff-1',
|
||||||
contractValidityDays: 10,
|
contractValidityDays: 10,
|
||||||
});
|
});
|
||||||
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
|
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('instantiates the approval chain when accepting', async () => {
|
it('approves outright and generates the contract (no approval chain)', async () => {
|
||||||
const { service, ruleEngineService } = makeService();
|
const { service, contractService } = makeService();
|
||||||
await service.acceptIntake('b-1', 'staff-1', 30);
|
await service.acceptIntake('b-1', 'staff-1', 30);
|
||||||
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
|
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
|
||||||
'b-1',
|
|
||||||
expect.objectContaining({ freightType: 'CONTAINER' }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Optional,
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
|
||||||
|
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { isRoadService } from './road.util';
|
import { isRoadService } from './road.util';
|
||||||
@@ -248,16 +246,6 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
|
||||||
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
|
||||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
|
||||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
|
||||||
cargoTypeId: booking.cargoTypeId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptIntake(
|
async acceptIntake(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
@@ -283,21 +271,33 @@ export class BookingTransitionService {
|
|||||||
const validUntil = new Date(validFrom);
|
const validUntil = new Date(validFrom);
|
||||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
// Bookings no longer run a multi-step approval chain — accepting the intake
|
||||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
// approves the booking outright and generates its contract. (The approval
|
||||||
cargoTypeId: booking.cargoTypeId,
|
// chain is a contract-only concern now; see contract-transition.service.)
|
||||||
});
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: "APPROVED",
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: "PENDING_APPROVAL",
|
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
approvedByStaffAt: validFrom,
|
approvedByStaffAt: validFrom,
|
||||||
contractValidityDays: validityDays,
|
contractValidityDays: validityDays,
|
||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
const fresh = await this.bookingsService.findById(updated!.id);
|
|
||||||
|
// Generating the contract is best-effort: the acceptance is already
|
||||||
|
// committed, so a failure here must not roll it back. The booking stays
|
||||||
|
// APPROVED and staff can retry generation from the booking page.
|
||||||
|
try {
|
||||||
|
await this.contractService.generateContract(bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
|
||||||
|
`The booking is APPROVED — retry generation from the booking page.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.accepted(fresh);
|
this.notifier.accepted(fresh);
|
||||||
|
this.notifier.approved(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,140 +324,6 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveStep(
|
|
||||||
bookingId: string,
|
|
||||||
stepId: string,
|
|
||||||
actorId: string,
|
|
||||||
requiredRole: string,
|
|
||||||
authUser?: TCurrentUser,
|
|
||||||
): Promise<Booking> {
|
|
||||||
if (authUser) {
|
|
||||||
assertCanApproveBookingStep(authUser, requiredRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
let booking = await this.bookingsService.findById(bookingId);
|
|
||||||
assertBookingStatus(booking, [
|
|
||||||
"PENDING_APPROVAL",
|
|
||||||
"APPROVED_PENDING_SIGNATURE",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
|
||||||
await this.ensureBookingApprovalSteps(booking);
|
|
||||||
booking = await this.bookingsService.findById(bookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findApprovalStepById(
|
|
||||||
bookingId,
|
|
||||||
stepId,
|
|
||||||
);
|
|
||||||
if (!step || step.status !== "PENDING") {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Approval step not found or already actioned",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const next =
|
|
||||||
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
|
||||||
if (!next || next.id !== step.id) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Approval steps must be completed in order",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step.requiredRole !== requiredRole) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocksRole = step.blocksRole;
|
|
||||||
if (blocksRole && blocksRole === requiredRole) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Role ${requiredRole} is blocked for this step`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.bookingsRepository.completeApprovalStep(
|
|
||||||
step.id,
|
|
||||||
actorId,
|
|
||||||
"APPROVED",
|
|
||||||
);
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (requiredRole === "LINE_STAFF") {
|
|
||||||
updates.status = "APPROVED_PENDING_SIGNATURE";
|
|
||||||
updates.approvedByStaffId = actorId;
|
|
||||||
updates.approvedByStaffAt = now;
|
|
||||||
} else if (requiredRole === "DIRECTOR") {
|
|
||||||
updates.signedByDirectorId = actorId;
|
|
||||||
updates.signedByDirectorAt = now;
|
|
||||||
} else if (requiredRole === "CEO") {
|
|
||||||
updates.signedByCeoId = actorId;
|
|
||||||
updates.signedByCeoAt = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allDone =
|
|
||||||
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
|
||||||
if (allDone) {
|
|
||||||
updates.status = "APPROVED";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await this.bookingsRepository.update(bookingId, updates as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allDone) {
|
|
||||||
const generated = await this.contractService.generateContract(bookingId);
|
|
||||||
const fresh = await this.bookingsService.findById(generated.id);
|
|
||||||
this.notifier.approved(fresh);
|
|
||||||
return fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectStep(
|
|
||||||
bookingId: string,
|
|
||||||
stepId: string,
|
|
||||||
actorId: string,
|
|
||||||
reason: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
|
||||||
assertBookingStatus(booking, [
|
|
||||||
"PENDING_APPROVAL",
|
|
||||||
"APPROVED_PENDING_SIGNATURE",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findApprovalStepById(
|
|
||||||
bookingId,
|
|
||||||
stepId,
|
|
||||||
);
|
|
||||||
if (!step) throw new BadRequestException("Approval step not found");
|
|
||||||
|
|
||||||
await this.bookingsRepository.completeApprovalStep(
|
|
||||||
step.id,
|
|
||||||
actorId,
|
|
||||||
"REJECTED",
|
|
||||||
reason,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
|
||||||
bookingId,
|
|
||||||
reason,
|
|
||||||
"REJECTION",
|
|
||||||
actorId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: "REJECTED",
|
|
||||||
} as never);
|
|
||||||
const fresh = await this.bookingsService.findById(updated!.id);
|
|
||||||
this.notifier.rejected(fresh, reason);
|
|
||||||
return fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
async customerSign(bookingId: string): Promise<Booking> {
|
async customerSign(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||||
@@ -1298,12 +1164,9 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
let nextStep: BookingNextStep | null = null;
|
let nextStep: BookingNextStep | null = null;
|
||||||
try {
|
try {
|
||||||
const nextPending =
|
// Bookings no longer carry an approval chain, so there is never a pending
|
||||||
booking.status === "PENDING_APPROVAL" ||
|
// approval step to hint at.
|
||||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
nextStep = computeNextStep(booking, null);
|
||||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
|
||||||
: null;
|
|
||||||
nextStep = computeNextStep(booking, nextPending);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
|||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
AcceptIntakeDto,
|
AcceptIntakeDto,
|
||||||
ApproveStepDto,
|
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
RejectBookingDto,
|
RejectBookingDto,
|
||||||
RejectStepDto,
|
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
@@ -1023,47 +1021,6 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/approval-steps/:stepId/approve")
|
|
||||||
@BookingStaff([
|
|
||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
|
||||||
FREIGHT_PERMS.bookings.approveDirector,
|
|
||||||
FREIGHT_PERMS.bookings.approveCeo,
|
|
||||||
])
|
|
||||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
|
||||||
async approveStep(
|
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
|
||||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
|
||||||
@Body() dto: ApproveStepDto,
|
|
||||||
@CurrentUser() user: TCurrentUser,
|
|
||||||
) {
|
|
||||||
const booking = await this.transitionService.approveStep(
|
|
||||||
id,
|
|
||||||
stepId,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
dto.requiredRole,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/approval-steps/:stepId/reject")
|
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
|
||||||
@ApiOperation({ summary: "Reject at approval step" })
|
|
||||||
async rejectStep(
|
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
|
||||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
|
||||||
@Body() dto: RejectStepDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
const booking = await this.transitionService.rejectStep(
|
|
||||||
id,
|
|
||||||
stepId,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
dto.reason,
|
|
||||||
);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/contract/generate")
|
@Post(":id/contract/generate")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
|
|||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
@@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
Booking,
|
Booking,
|
||||||
BookingContainer,
|
BookingContainer,
|
||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
|
||||||
BookingDocumentReview,
|
BookingDocumentReview,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
|||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import {
|
import {
|
||||||
BookingDocumentReview,
|
BookingDocumentReview,
|
||||||
@@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.originYard', 'oy')
|
.leftJoinAndSelect('booking.originYard', 'oy')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'dy')
|
.leftJoinAndSelect('booking.destinationYard', 'dy')
|
||||||
.leftJoinAndSelect('booking.shippingLine', 'sl')
|
.leftJoinAndSelect('booking.shippingLine', 'sl')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
|
||||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||||
@@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lowest-order pending approval step (sequential enforcement). */
|
|
||||||
async findNextPendingApprovalStep(
|
|
||||||
bookingId: string,
|
|
||||||
): Promise<BookingApprovalStep | null> {
|
|
||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
|
||||||
where: { bookingId, status: 'PENDING' },
|
|
||||||
order: { stepOrder: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findApprovalStepById(
|
|
||||||
bookingId: string,
|
|
||||||
stepId: string,
|
|
||||||
): Promise<BookingApprovalStep | null> {
|
|
||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
|
||||||
where: { bookingId, id: stepId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get pending approval step for a role (must match next in sequence). */
|
|
||||||
async findPendingApprovalStep(
|
|
||||||
bookingId: string,
|
|
||||||
requiredRole: string,
|
|
||||||
): Promise<BookingApprovalStep | null> {
|
|
||||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
|
||||||
if (!next || next.requiredRole !== requiredRole) return null;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark an approval step complete. */
|
|
||||||
async completeApprovalStep(
|
|
||||||
stepId: string,
|
|
||||||
actorId: string,
|
|
||||||
status: 'APPROVED' | 'REJECTED',
|
|
||||||
remarks?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
|
|
||||||
status,
|
|
||||||
actionedByStaffId: actorId,
|
|
||||||
actionedAt: new Date(),
|
|
||||||
remarks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if all approval steps are approved. */
|
|
||||||
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
|
|
||||||
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
|
|
||||||
where: { bookingId, status: 'PENDING' },
|
|
||||||
});
|
|
||||||
return pending === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Clearance document reviews ────────────────────────────────────────────
|
// ── Clearance document reviews ────────────────────────────────────────────
|
||||||
|
|
||||||
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
||||||
@@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
|
||||||
.where('booking.status IN (:...statuses)', { statuses });
|
.where('booking.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
if (options.excludeBulk) {
|
if (options.excludeBulk) {
|
||||||
@@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
|
||||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||||
// Contract reference for the list column + search (no entity relation on
|
// Contract reference for the list column + search (no entity relation on
|
||||||
// Booking → contract, so join the entity by id and select just the
|
// Booking → contract, so join the entity by id and select just the
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
|||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
|
import {
|
||||||
|
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||||
|
usesEdrMileService,
|
||||||
|
} from '../../common/mile-haulage.util';
|
||||||
|
import {
|
||||||
|
assertBulkTonnageRemains,
|
||||||
|
assertTruckCountWithinContainers,
|
||||||
|
assertTruckLoad,
|
||||||
|
bookingContainerSizes,
|
||||||
|
remainingBulkTons,
|
||||||
|
} from '../../common/truck-load.util';
|
||||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
@@ -67,39 +78,27 @@ export class CustomerTruckService {
|
|||||||
if (!isBulk && requested.length < 1) {
|
if (!isBulk && requested.length < 1) {
|
||||||
throw new BadRequestException('Select at least one container for this truck');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
if (requested.length > 2) {
|
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
// Bulk is capped by tonnage, not container count: trucks may be added until
|
||||||
|
// the booking's declared weight has been hauled away. Container bookings are
|
||||||
|
// capped below by #trucks <= #containers.
|
||||||
|
if (isBulk) {
|
||||||
|
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||||
|
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requested.length) {
|
if (requested.length) {
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
// Never assign more trucks than the booking has containers.
|
|
||||||
const existingTrucks = await this.dataSource
|
const existingTrucks = await this.dataSource
|
||||||
.getRepository(CustomerTruckAssignment)
|
.getRepository(CustomerTruckAssignment)
|
||||||
.count({ where: { bookingId } });
|
.count({ where: { bookingId } });
|
||||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
|
||||||
throw new BadRequestException(
|
assertTruckLoad({
|
||||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
containers: requested,
|
||||||
);
|
bookingContainers: bookingNumbers,
|
||||||
}
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
|
||||||
if (!bookingNumbers.includes(n)) {
|
});
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
|
||||||
for (const n of requested) {
|
|
||||||
if (alreadyAssigned.includes(n)) {
|
|
||||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Size cap: a 40ft container fills the truck.
|
|
||||||
const sizes = await this.containerSizes(bookingId, requested);
|
|
||||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -191,28 +190,13 @@ export class CustomerTruckService {
|
|||||||
if (requested.length < 1) {
|
if (requested.length < 1) {
|
||||||
throw new BadRequestException('Select at least one container for this truck');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
if (requested.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: requested,
|
||||||
}
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||||
if (!bookingNumbers.includes(n)) {
|
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
|
||||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
|
||||||
for (const n of requested) {
|
|
||||||
if (assignedElsewhere.includes(n)) {
|
|
||||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sizes = await this.containerSizes(bookingId, requested);
|
|
||||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
@@ -340,27 +324,12 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
||||||
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||||
if (requested.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: requested,
|
||||||
}
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
for (const n of requested) {
|
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||||
if (!bookingNumbers.includes(n)) {
|
});
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
|
||||||
for (const n of requested) {
|
|
||||||
if (elsewhere.includes(n)) {
|
|
||||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sizes = await this.containerSizes(bookingId, requested);
|
|
||||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'A 40ft container fills the truck — load only 1 container onto this truck',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -534,18 +503,11 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||||
const hasLastMile = Boolean(booking.lastMile?.trim());
|
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||||
const usesMileService =
|
// with a customer truck and an EDR leg at once.
|
||||||
booking.tradeDirection === 'IMPORT'
|
if (usesEdrMileService(booking)) {
|
||||||
? hasLastMile
|
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||||
: booking.tradeDirection === 'EXPORT'
|
|
||||||
? hasFirstMile
|
|
||||||
: hasFirstMile || hasLastMile;
|
|
||||||
if (usesMileService) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -614,18 +576,4 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
|
||||||
if (!numbers.length) return [];
|
|
||||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
|
||||||
`SELECT bc.container_size AS "size"
|
|
||||||
FROM freight.booking_container_units bcu
|
|
||||||
JOIN freight.booking_container bc
|
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
|
||||||
WHERE bc.booking_id = $1
|
|
||||||
AND UPPER(bcu.container_number) = ANY($2)
|
|
||||||
AND bcu.deleted_at IS NULL`,
|
|
||||||
[bookingId, numbers],
|
|
||||||
);
|
|
||||||
return rows.map((r) => (r.size ?? '').trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
|
||||||
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
|
|
||||||
import { Booking } from './booking.entity';
|
|
||||||
|
|
||||||
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
|
|
||||||
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_approval_step' })
|
|
||||||
@Index(['bookingId'])
|
|
||||||
@Index(['status'])
|
|
||||||
@Index(['bookingId', 'stepOrder'])
|
|
||||||
export class BookingApprovalStep extends BaseEntity {
|
|
||||||
@Column({ name: 'booking_id', type: 'uuid' })
|
|
||||||
bookingId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'booking_id' })
|
|
||||||
booking?: Booking;
|
|
||||||
|
|
||||||
@Column({ name: 'approval_rule_id', type: 'uuid' })
|
|
||||||
approvalRuleId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => ApprovalRule)
|
|
||||||
@JoinColumn({ name: 'approval_rule_id' })
|
|
||||||
approvalRule?: ApprovalRule;
|
|
||||||
|
|
||||||
@Column({ name: 'step_order', type: 'smallint' })
|
|
||||||
stepOrder!: number;
|
|
||||||
|
|
||||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
|
||||||
requiredRole!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
|
||||||
blocksRole?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
|
||||||
status!: ApprovalStepStatus;
|
|
||||||
|
|
||||||
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
|
|
||||||
actionedByStaffId?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
|
|
||||||
actionedAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
|
||||||
remarks?: string | null;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
|||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
import { Train } from '../../trains/entities/train.entity';
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
import { FileRecord } from '../../files/entities/file.entity';
|
import { FileRecord } from '../../files/entities/file.entity';
|
||||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
|
||||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './booking-container.entity';
|
import { BookingContainer } from './booking-container.entity';
|
||||||
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||||
@@ -557,8 +556,6 @@ export class Booking extends BaseEntity {
|
|||||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||||
cargoModifiers?: BookingCargoModifier[];
|
cargoModifiers?: BookingCargoModifier[];
|
||||||
|
|
||||||
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
|
|
||||||
approvalSteps?: BookingApprovalStep[];
|
|
||||||
|
|
||||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||||
rateSnapshots?: BookingRateSnapshot[];
|
rateSnapshots?: BookingRateSnapshot[];
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity {
|
|||||||
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
grossWeightKg?: number | null;
|
grossWeightKg?: number | null;
|
||||||
|
|
||||||
|
/** Empty truck weight at the gate, in tonnes. Null until the truck departs. */
|
||||||
|
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
tareWeightTons?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown:
|
||||||
|
* a bulk booking is hauled until the sum of this across departed trucks
|
||||||
|
* reaches its declared VGM. Mirrors last_mile_vehicle_assignments.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
netWeightTons?: number | null;
|
||||||
|
|
||||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
kind,
|
kind,
|
||||||
status,
|
status,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
|
sortBy = 'name',
|
||||||
|
sortOrder = 'ASC',
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
@@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
.orderBy('company.name', 'ASC')
|
.orderBy(`company.${sortBy}`, sortOrder)
|
||||||
|
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||||
|
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||||
|
.addOrderBy('company.id', 'ASC')
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
|||||||
import { CompanyType } from '../entities/company.entity';
|
import { CompanyType } from '../entities/company.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class CompanyProfileInputDto {
|
export class CompanyProfileInputDto {
|
||||||
@IsEnum(ProfileType)
|
@IsEnum(ProfileType)
|
||||||
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(10)
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator';
|
||||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class CreateCompanyDto {
|
export class CreateCompanyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { IsString, IsNotEmpty, Length } from "class-validator";
|
import { IsString, IsNotEmpty } from "class-validator";
|
||||||
|
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||||
|
|
||||||
export class FetchETradeDto {
|
export class FetchETradeDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,19 @@ export class ListCompaniesQueryDto {
|
|||||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["name", "createdAt", "updatedAt"],
|
||||||
|
default: "name",
|
||||||
|
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["name", "createdAt", "updatedAt"])
|
||||||
|
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
|
@IsIn(["ASC", "DESC"])
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
|
||||||
|
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -34,7 +36,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -137,10 +139,14 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
renewedTo?: string;
|
renewedTo?: string;
|
||||||
|
|
||||||
|
// Zone/woreda/kebele below stay free text: there is no authoritative dataset
|
||||||
|
// of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns
|
||||||
|
// them uncoded. Only region is a closed set today.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(ETHIOPIAN_REGIONS as unknown as string[], {
|
||||||
@MaxLength(100)
|
message: "region must be a recognised Ethiopian region",
|
||||||
region?: string;
|
})
|
||||||
|
region?: EthiopianRegion;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ETradeCompanyInfo,
|
ETradeCompanyInfo,
|
||||||
ETradeBusinessInfo,
|
ETradeBusinessInfo,
|
||||||
CompanyRegistrationData,
|
CompanyRegistrationData,
|
||||||
|
normalizeRegion,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -108,7 +109,11 @@ export class ETradeService {
|
|||||||
renewedFrom: businessInfo.RenewedFrom,
|
renewedFrom: businessInfo.RenewedFrom,
|
||||||
renewalDate: businessInfo.RenewalDate,
|
renewalDate: businessInfo.RenewalDate,
|
||||||
renewedTo: businessInfo.RenewedTo,
|
renewedTo: businessInfo.RenewedTo,
|
||||||
region: businessInfo.AddressInfo?.Region || "",
|
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
||||||
|
// Region slot. Map it onto the canonical list; an unresolved value yields
|
||||||
|
// "" so the form asks the user to pick rather than failing validation on
|
||||||
|
// save with a value they never typed.
|
||||||
|
region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "",
|
||||||
zone: businessInfo.AddressInfo?.Zone || "",
|
zone: businessInfo.AddressInfo?.Zone || "",
|
||||||
woreda: businessInfo.AddressInfo?.Woreda || "",
|
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||||
kebele: businessInfo.AddressInfo?.Kebele || "",
|
kebele: businessInfo.AddressInfo?.Kebele || "",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ETHIOPIAN_REGIONS, normalizeRegion } from '@edr/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalizeRegion lives in @edr/types (no jest there), but it exists to keep
|
||||||
|
* eTrade autofill from feeding UpdateProfileDto a region its @IsIn will reject.
|
||||||
|
* That contract is an API concern, so it is guarded here.
|
||||||
|
*/
|
||||||
|
describe('normalizeRegion', () => {
|
||||||
|
it('passes through every canonical region unchanged', () => {
|
||||||
|
for (const region of ETHIOPIAN_REGIONS) {
|
||||||
|
expect(normalizeRegion(region)).toBe(region);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ADDIS ABABA', 'Addis Ababa'],
|
||||||
|
['Addis ababa', 'Addis Ababa'],
|
||||||
|
[' addis ababa ', 'Addis Ababa'],
|
||||||
|
['oromoia', 'Oromia'],
|
||||||
|
['OROMIYA', 'Oromia'],
|
||||||
|
['gambella', 'Gambela'],
|
||||||
|
['TIGRAI', 'Tigray'],
|
||||||
|
['benishangul gumuz', 'Benishangul-Gumuz'],
|
||||||
|
])('resolves the variant %s', (input, expected) => {
|
||||||
|
expect(normalizeRegion(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a zone name in the region slot back to its parent region', () => {
|
||||||
|
// eTrade's own placeholder data does this — "EASTERN TIGRAY" is a zone.
|
||||||
|
expect(normalizeRegion('EASTERN TIGRAY')).toBe('Tigray');
|
||||||
|
expect(normalizeRegion('North Wollo')).toBe('Amhara');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a city, not a region', 'Arba Minch'],
|
||||||
|
['unknown text', 'Nowhere Land'],
|
||||||
|
['empty', ''],
|
||||||
|
['whitespace only', ' '],
|
||||||
|
['null', null],
|
||||||
|
['undefined', undefined],
|
||||||
|
])('returns null for %s rather than guessing', (_label, input) => {
|
||||||
|
expect(normalizeRegion(input as string | null | undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a value outside the canonical set', () => {
|
||||||
|
const samples = ['ADDIS ABABA', 'oromoia', 'EASTERN TIGRAY', 'garbage', ''];
|
||||||
|
for (const s of samples) {
|
||||||
|
const out = normalizeRegion(s);
|
||||||
|
if (out !== null) {
|
||||||
|
expect(ETHIOPIAN_REGIONS).toContain(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import {
|
||||||
|
ClearanceMilestone,
|
||||||
|
type RiskAssignmentRecord,
|
||||||
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
|
|||||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
riskLevel?: string | null;
|
riskLevel?: string | null;
|
||||||
riskAssignedAt?: string | null;
|
riskAssignedAt?: string | null;
|
||||||
|
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||||
|
riskHistory?: RiskAssignmentRecord[];
|
||||||
/** Post-arrival additional duty/tax round (import). */
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
secondDuty?: ClearanceSecondDuty | null;
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
importReleaseGranted?: boolean;
|
importReleaseGranted?: boolean;
|
||||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
|||||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
? riskMilestone.triggeredAt.toISOString()
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
||||||
|
// the current one; this is the trail behind it.
|
||||||
|
riskHistory:
|
||||||
|
riskMilestone?.status === 'COMPLETED'
|
||||||
|
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||||
|
: [],
|
||||||
secondDuty,
|
secondDuty,
|
||||||
importReleaseGranted:
|
importReleaseGranted:
|
||||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
|
|||||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
|||||||
expect(saved.status).toBe('COMPLETED');
|
expect(saved.status).toBe('COMPLETED');
|
||||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level is customer-visible and stays correctable until duty is advised,
|
||||||
|
* so a changed level must leave a trail rather than overwrite the last one.
|
||||||
|
*/
|
||||||
|
describe('risk history', () => {
|
||||||
|
it('records the first assignment with no previous level', async () => {
|
||||||
|
const { service } = makeService('COMPLETED');
|
||||||
|
|
||||||
|
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
||||||
|
|
||||||
|
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||||
|
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||||
|
level: 'RED',
|
||||||
|
assignedByUserId: 'user-1',
|
||||||
|
assignedBy: 'Abebe K.',
|
||||||
|
note: 'initial rating',
|
||||||
|
});
|
||||||
|
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the earlier decision when the level is reassigned', async () => {
|
||||||
|
const { service } = makeService('COMPLETED');
|
||||||
|
|
||||||
|
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
||||||
|
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
||||||
|
|
||||||
|
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
||||||
|
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
||||||
|
// The original RED decision survives, with who made it.
|
||||||
|
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||||
|
level: 'RED',
|
||||||
|
assignedBy: 'Abebe K.',
|
||||||
|
});
|
||||||
|
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
||||||
|
level: 'GREEN',
|
||||||
|
previousLevel: 'RED',
|
||||||
|
assignedByUserId: 'user-2',
|
||||||
|
assignedBy: 'Sara M.',
|
||||||
|
note: 'downgraded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
||||||
|
const { service } = makeService('COMPLETED');
|
||||||
|
|
||||||
|
await service.assignRisk('b-1', 'GREEN');
|
||||||
|
await service.assignRisk('b-1', 'YELLOW');
|
||||||
|
const saved = await service.assignRisk('b-1', 'RED');
|
||||||
|
|
||||||
|
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
||||||
|
'GREEN',
|
||||||
|
'YELLOW',
|
||||||
|
'RED',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record a repeat of the level already assigned', async () => {
|
||||||
|
const { service } = makeService('COMPLETED');
|
||||||
|
|
||||||
|
await service.assignRisk('b-1', 'GREEN');
|
||||||
|
const saved = await service.assignRisk('b-1', 'GREEN');
|
||||||
|
|
||||||
|
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always leaves riskLevel equal to the last history entry', async () => {
|
||||||
|
const { service } = makeService('COMPLETED');
|
||||||
|
|
||||||
|
await service.assignRisk('b-1', 'RED');
|
||||||
|
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||||
|
|
||||||
|
const history = saved.metadata?.riskHistory ?? [];
|
||||||
|
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
|
|||||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||||
|
*
|
||||||
|
* The level stays correctable until duty is advised off it, so each assignment
|
||||||
|
* is appended to `riskHistory` instead of silently replacing the last one — a
|
||||||
|
* customer-visible level that changes needs a trail of who changed it and when.
|
||||||
*/
|
*/
|
||||||
async assignRisk(
|
async assignRisk(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
riskLevel: CustomsRiskLevel,
|
riskLevel: CustomsRiskLevel,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
|
actor?: string,
|
||||||
): Promise<ClearanceMilestone> {
|
): Promise<ClearanceMilestone> {
|
||||||
await this.assertT1Closed(bookingId);
|
await this.assertT1Closed(bookingId);
|
||||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
|
||||||
|
const existing = await this.repo.findOne({
|
||||||
|
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
|
||||||
|
});
|
||||||
|
const previousLevel = existing?.metadata?.riskLevel;
|
||||||
|
const history = existing?.metadata?.riskHistory ?? [];
|
||||||
|
|
||||||
|
// A repeat of the level already assigned is not a decision — recording it
|
||||||
|
// would pad the trail with entries that changed nothing.
|
||||||
|
const entries =
|
||||||
|
previousLevel === riskLevel
|
||||||
|
? history
|
||||||
|
: [
|
||||||
|
...history,
|
||||||
|
{
|
||||||
|
level: riskLevel,
|
||||||
|
...(previousLevel ? { previousLevel } : {}),
|
||||||
|
assignedAt: new Date().toISOString(),
|
||||||
|
assignedByUserId: userId ?? null,
|
||||||
|
assignedBy: actor ?? null,
|
||||||
|
note: note ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.completeWithMetadata(
|
||||||
|
bookingId,
|
||||||
|
'RISK_ASSIGNED',
|
||||||
|
{ riskLevel, riskHistory: entries },
|
||||||
|
userId,
|
||||||
|
note,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
@@ -97,6 +98,7 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
private readonly clearanceFeeService: ClearanceFeeService,
|
||||||
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
@@ -352,6 +354,12 @@ export class ContractBookingService {
|
|||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||||
booking.id,
|
booking.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tell staff the booking exists. Placed after the zero-price rollback (which
|
||||||
|
// hard-deletes the row) and before the consolidation gate, so it fires
|
||||||
|
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||||
|
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||||
|
|
||||||
const intendedStatus =
|
const intendedStatus =
|
||||||
generalCustoms || generalSelfClear
|
generalCustoms || generalSelfClear
|
||||||
? 'AWAITING_DOCUMENTS'
|
? 'AWAITING_DOCUMENTS'
|
||||||
@@ -481,6 +489,7 @@ export class ContractBookingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||||
return { booking: result ?? booking, warnings: [] };
|
return { booking: result ?? booking, warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +577,10 @@ export class ContractBookingService {
|
|||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
const created =
|
||||||
|
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
|
this.bookingNotifier.createdToStaff(created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
|||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import {
|
||||||
|
ClearanceMilestone,
|
||||||
|
type RiskAssignmentRecord,
|
||||||
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
|
|||||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
riskLevel?: string | null;
|
riskLevel?: string | null;
|
||||||
riskAssignedAt?: string | null;
|
riskAssignedAt?: string | null;
|
||||||
|
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||||
|
riskHistory?: RiskAssignmentRecord[];
|
||||||
/** Post-arrival additional duty/tax round (import). */
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
secondDuty?: ClearanceSecondDuty | null;
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
importReleaseGranted?: boolean;
|
importReleaseGranted?: boolean;
|
||||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
|||||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
? riskMilestone.triggeredAt.toISOString()
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
// Every risk decision, oldest first — see booking-clearance.service.
|
||||||
|
riskHistory:
|
||||||
|
riskMilestone?.status === 'COMPLETED'
|
||||||
|
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||||
|
: [],
|
||||||
secondDuty,
|
secondDuty,
|
||||||
importReleaseGranted:
|
importReleaseGranted:
|
||||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
@@ -446,7 +456,7 @@ export class ContractClearanceService {
|
|||||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Cannot finalize clearance on status "${contract.status}".`,
|
`Cannot finalize document approval on status "${contract.status}".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type {
|
||||||
|
ContractDocumentArticle,
|
||||||
|
ContractDocumentSnapshot,
|
||||||
|
} from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One recorded change between two document snapshots. Granularity is per
|
||||||
|
* article: a body edit is reported as "the body changed", not as a text diff.
|
||||||
|
*/
|
||||||
|
export type ContractDocumentChange =
|
||||||
|
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||||
|
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
|
||||||
|
| {
|
||||||
|
kind: 'ARTICLE_RENAMED';
|
||||||
|
articleId: string;
|
||||||
|
title: string;
|
||||||
|
fromTitle: string;
|
||||||
|
}
|
||||||
|
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
||||||
|
| {
|
||||||
|
kind: 'ARTICLE_REORDERED';
|
||||||
|
articleId: string;
|
||||||
|
title: string;
|
||||||
|
fromOrder: number;
|
||||||
|
toOrder: number;
|
||||||
|
}
|
||||||
|
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||||
|
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
||||||
|
|
||||||
|
type SnapshotLike = Pick<
|
||||||
|
ContractDocumentSnapshot,
|
||||||
|
'documentTitle' | 'whereasClauses' | 'articles'
|
||||||
|
> | null;
|
||||||
|
|
||||||
|
/** Match on id when present, else on normalized title (editors may omit ids). */
|
||||||
|
function articleKey(article: ContractDocumentArticle): string {
|
||||||
|
return article.id || `title:${article.title.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexArticles(
|
||||||
|
articles: ContractDocumentArticle[] | undefined,
|
||||||
|
): Map<string, ContractDocumentArticle> {
|
||||||
|
const map = new Map<string, ContractDocumentArticle>();
|
||||||
|
for (const article of articles ?? []) {
|
||||||
|
map.set(articleKey(article), article);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two document snapshots and describe what changed, article by article.
|
||||||
|
* Returns an empty array when the snapshots are equivalent, so callers can skip
|
||||||
|
* recording a no-op revision.
|
||||||
|
*/
|
||||||
|
export function diffSnapshots(
|
||||||
|
before: SnapshotLike,
|
||||||
|
after: SnapshotLike,
|
||||||
|
): ContractDocumentChange[] {
|
||||||
|
const changes: ContractDocumentChange[] = [];
|
||||||
|
|
||||||
|
const beforeTitle = before?.documentTitle ?? null;
|
||||||
|
const afterTitle = after?.documentTitle ?? null;
|
||||||
|
if (beforeTitle !== afterTitle && afterTitle !== null) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'DOCUMENT_TITLE_CHANGED',
|
||||||
|
title: afterTitle,
|
||||||
|
fromTitle: beforeTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeWhereas = before?.whereasClauses ?? [];
|
||||||
|
const afterWhereas = after?.whereasClauses ?? [];
|
||||||
|
const beforeWhereasSet = new Set(beforeWhereas);
|
||||||
|
const afterWhereasSet = new Set(afterWhereas);
|
||||||
|
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
|
||||||
|
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
|
||||||
|
if (whereasAdded > 0 || whereasRemoved > 0) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'WHEREAS_CHANGED',
|
||||||
|
added: whereasAdded,
|
||||||
|
removed: whereasRemoved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeArticles = indexArticles(before?.articles);
|
||||||
|
const afterArticles = indexArticles(after?.articles);
|
||||||
|
|
||||||
|
for (const [key, article] of afterArticles) {
|
||||||
|
const previous = beforeArticles.get(key);
|
||||||
|
if (!previous) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'ARTICLE_ADDED',
|
||||||
|
articleId: article.id,
|
||||||
|
title: article.title,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.title !== article.title) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'ARTICLE_RENAMED',
|
||||||
|
articleId: article.id,
|
||||||
|
title: article.title,
|
||||||
|
fromTitle: previous.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (previous.body !== article.body) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'ARTICLE_BODY_CHANGED',
|
||||||
|
articleId: article.id,
|
||||||
|
title: article.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (previous.order !== article.order) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'ARTICLE_REORDERED',
|
||||||
|
articleId: article.id,
|
||||||
|
title: article.title,
|
||||||
|
fromOrder: previous.order,
|
||||||
|
toOrder: article.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, article] of beforeArticles) {
|
||||||
|
if (afterArticles.has(key)) continue;
|
||||||
|
changes.push({
|
||||||
|
kind: 'ARTICLE_REMOVED',
|
||||||
|
articleId: article.id,
|
||||||
|
title: article.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||||
|
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||||
|
if (changes.length === 0) return 'No changes';
|
||||||
|
|
||||||
|
const articleVerbs: Record<string, string> = {
|
||||||
|
ARTICLE_ADDED: 'added',
|
||||||
|
ARTICLE_REMOVED: 'removed',
|
||||||
|
ARTICLE_RENAMED: 'renamed',
|
||||||
|
ARTICLE_BODY_CHANGED: 'edited',
|
||||||
|
ARTICLE_REORDERED: 'reordered',
|
||||||
|
};
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const verb = articleVerbs[change.kind];
|
||||||
|
if (verb) {
|
||||||
|
counts.set(verb, (counts.get(verb) ?? 0) + 1);
|
||||||
|
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
|
||||||
|
parts.push('document title changed');
|
||||||
|
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||||
|
parts.push('recitals changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleParts = [...counts.entries()].map(
|
||||||
|
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...articleParts, ...parts].join(', ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
||||||
|
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||||
|
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||||
|
|
||||||
|
export interface RecordRevisionInput {
|
||||||
|
contractId: string;
|
||||||
|
before: ContractDocumentSnapshot | null;
|
||||||
|
after: ContractDocumentSnapshot | null;
|
||||||
|
actorId?: string | null;
|
||||||
|
actorRole?: string | null;
|
||||||
|
stepId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractDocumentHistoryService {
|
||||||
|
private readonly logger = new Logger(ContractDocumentHistoryService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ContractDocumentRevision)
|
||||||
|
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a revision describing what an edit changed. Best-effort: recording
|
||||||
|
* history must never break the edit that triggered it, so failures are logged
|
||||||
|
* and swallowed. A no-op edit records nothing.
|
||||||
|
*/
|
||||||
|
async record(input: RecordRevisionInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const changes = diffSnapshots(input.before, input.after);
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
|
||||||
|
await this.revisionRepo.save(
|
||||||
|
this.revisionRepo.create({
|
||||||
|
contractId: input.contractId,
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
actorRole: input.actorRole ?? null,
|
||||||
|
stepId: input.stepId ?? null,
|
||||||
|
summary: summarizeChanges(changes),
|
||||||
|
changes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revision history for a contract, newest first. */
|
||||||
|
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||||
|
return this.revisionRepo.find({
|
||||||
|
where: { contractId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
@@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
|||||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
|
|||||||
articles: ContractDocumentArticle[];
|
articles: ContractDocumentArticle[];
|
||||||
code: string | null;
|
code: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
/** True once the document may no longer be edited/regenerated. */
|
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
/** Whether the requesting user is the approver whose turn it is to edit. */
|
||||||
|
editableByMe: boolean;
|
||||||
|
/** Role holding editing rights right now, for "locked because…" messaging. */
|
||||||
|
nextApproverRole: string | null;
|
||||||
generatedAt: Date | null;
|
generatedAt: Date | null;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
|
|||||||
*/
|
*/
|
||||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval chains are configured in IAM position types, so a step's role no
|
||||||
|
* longer maps onto the contract's fixed approver columns. These sets keep those
|
||||||
|
* legacy columns populated for the roles that still correspond to one — both the
|
||||||
|
* original role strings on historical rows and the position types that replaced
|
||||||
|
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
|
||||||
|
* which is the source of truth.
|
||||||
|
*/
|
||||||
|
const LEGACY_STAFF_ROLES = new Set([
|
||||||
|
'LINE_STAFF',
|
||||||
|
'employee',
|
||||||
|
'teamLeader',
|
||||||
|
'officeHead',
|
||||||
|
'recordOfficer',
|
||||||
|
]);
|
||||||
|
const LEGACY_DIRECTOR_ROLES = new Set([
|
||||||
|
'DIRECTOR',
|
||||||
|
'director',
|
||||||
|
'operation-director',
|
||||||
|
]);
|
||||||
|
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||||
@@ -73,6 +101,27 @@ function maskPhone(phone: string): string {
|
|||||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = email.trim().split('@');
|
||||||
|
if (!domain) return email.trim();
|
||||||
|
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
||||||
|
* Both contacts are listed when both were used — a signer who only watches their
|
||||||
|
* handset otherwise has no idea the email carries the same code.
|
||||||
|
*/
|
||||||
|
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
||||||
|
return [
|
||||||
|
contacts.email ? maskEmail(contacts.email) : null,
|
||||||
|
contacts.phone ? maskPhone(contacts.phone) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' and ');
|
||||||
|
}
|
||||||
|
|
||||||
/** Status-machine guard mirroring booking-status.util. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
@@ -87,6 +136,7 @@ export class ContractTransitionService {
|
|||||||
private readonly logger = new Logger(ContractTransitionService.name);
|
private readonly logger = new Logger(ContractTransitionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly documentHistory: ContractDocumentHistoryService,
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
private readonly pricingService: ContractPricingService,
|
private readonly pricingService: ContractPricingService,
|
||||||
@@ -109,34 +159,39 @@ export class ContractTransitionService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||||
* IAM account number.
|
* own IAM account phone AND email. One code goes to both and either delivery
|
||||||
|
* verifies it, so a signer whose SMS is delayed can still complete from their
|
||||||
|
* inbox instead of abandoning a ready contract.
|
||||||
*
|
*
|
||||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||||
* request body — a caller-supplied number would let an attacker point the code
|
* request body — caller-supplied contacts would let an attacker point the code
|
||||||
* at their own phone. Ownership is already gated separately by
|
* at their own phone or mailbox. Ownership is already gated separately by
|
||||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||||
* signature to the *person* signing rather than to a company landline that may
|
* signature to the *person* signing rather than to a company landline that may
|
||||||
* be shared, stale, or imported from eTrade.
|
* be shared, stale, or imported from eTrade.
|
||||||
*/
|
*/
|
||||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
private async resolveSignerContacts(
|
||||||
|
signerUserId?: string,
|
||||||
|
): Promise<{ phone?: string; email?: string }> {
|
||||||
if (!signerUserId) {
|
if (!signerUserId) {
|
||||||
// Unreachable in practice (the ownership gate rejects a missing user
|
// Unreachable in practice (the ownership gate rejects a missing user
|
||||||
// first), but never fall back to another number if it ever changes.
|
// first), but never fall back to another account if it ever changes.
|
||||||
throw new BadRequestException('Authentication required to sign');
|
throw new BadRequestException('Authentication required to sign');
|
||||||
}
|
}
|
||||||
const rows: Array<{ phone_number: string | null }> =
|
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||||
[signerUserId],
|
[signerUserId],
|
||||||
);
|
);
|
||||||
const phone = rows[0]?.phone_number?.trim();
|
const phone = rows[0]?.phone_number?.trim();
|
||||||
if (!phone) {
|
const email = rows[0]?.email?.trim();
|
||||||
|
if (!phone && !email) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return phone;
|
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -229,18 +284,22 @@ export class ContractTransitionService {
|
|||||||
*/
|
*/
|
||||||
async getContractDocumentDraft(
|
async getContractDocumentDraft(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
): Promise<ContractDocumentDraft> {
|
): Promise<ContractDocumentDraft> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
const snapshot =
|
const snapshot =
|
||||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||||
(await this.resolveDocumentSnapshot(contract));
|
(await this.resolveDocumentSnapshot(contract));
|
||||||
|
const editableByMe = await this.documentIsEditableBy(contract, user);
|
||||||
return {
|
return {
|
||||||
documentTitle: snapshot?.documentTitle ?? null,
|
documentTitle: snapshot?.documentTitle ?? null,
|
||||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||||
articles: snapshot?.articles ?? [],
|
articles: snapshot?.articles ?? [],
|
||||||
code: snapshot?.code ?? null,
|
code: snapshot?.code ?? null,
|
||||||
name: snapshot?.name ?? null,
|
name: snapshot?.name ?? null,
|
||||||
locked: !this.documentIsEditable(contract),
|
locked: !editableByMe,
|
||||||
|
editableByMe,
|
||||||
|
nextApproverRole: await this.nextApproverRole(contract),
|
||||||
generatedAt: contract.contractGeneratedAt ?? null,
|
generatedAt: contract.contractGeneratedAt ?? null,
|
||||||
status: contract.status,
|
status: contract.status,
|
||||||
};
|
};
|
||||||
@@ -255,10 +314,12 @@ export class ContractTransitionService {
|
|||||||
async updateContractDocument(
|
async updateContractDocument(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
input: ContractDocumentSnapshotInput,
|
input: ContractDocumentSnapshotInput,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
actorId?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||||
this.assertDocumentEditable(contract);
|
await this.assertDocumentEditable(contract, user);
|
||||||
|
|
||||||
const current =
|
const current =
|
||||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||||
@@ -270,9 +331,25 @@ export class ContractTransitionService {
|
|||||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||||
articles: input.articles ?? current?.articles ?? [],
|
articles: input.articles ?? current?.articles ?? [],
|
||||||
};
|
};
|
||||||
|
const next = this.normalizeSnapshot(merged);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
documentSnapshot: this.normalizeSnapshot(merged),
|
documentSnapshot: next,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
// Audit the edit after it lands. Recording history must never break the
|
||||||
|
// edit itself, so the history service swallows its own failures.
|
||||||
|
const step = await this.contractsRepository.findNextPendingApprovalStep(
|
||||||
|
contractId,
|
||||||
|
);
|
||||||
|
await this.documentHistory.record({
|
||||||
|
contractId,
|
||||||
|
before: current,
|
||||||
|
after: next,
|
||||||
|
actorId: actorId ?? null,
|
||||||
|
actorRole: step?.requiredRole ?? null,
|
||||||
|
stepId: step?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,23 +411,54 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-contract document may be edited/regenerated while the contract is at
|
* The contract document stays editable for the whole approval chain, but only
|
||||||
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
* by the approver whose turn it is: whoever can action the next pending step.
|
||||||
* yet. The first approval action freezes it.
|
* Approving therefore hands editing rights to the next approver in the chain.
|
||||||
|
*
|
||||||
|
* Edits never reset approvals already given — earlier approvers stay approved.
|
||||||
*/
|
*/
|
||||||
private documentIsEditable(contract: Contract): boolean {
|
private async documentIsEditableBy(
|
||||||
|
contract: Contract,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<boolean> {
|
||||||
if (contract.status === 'SUBMITTED') return true;
|
if (contract.status === 'SUBMITTED') return true;
|
||||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
|
||||||
|
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||||
|
contract.id,
|
||||||
|
);
|
||||||
|
if (!next) return false;
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCanApproveContractStep(user, next.requiredRole);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertDocumentEditable(contract: Contract): void {
|
/** The role that currently holds editing rights, for UI messaging. */
|
||||||
if (!this.documentIsEditable(contract)) {
|
private async nextApproverRole(contract: Contract): Promise<string | null> {
|
||||||
throw new ConflictException(
|
if (contract.status !== 'PENDING_APPROVAL') return null;
|
||||||
'The contract document is locked — an approver has already acted or the ' +
|
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||||
'contract has advanced. It can no longer be edited or regenerated.',
|
contract.id,
|
||||||
);
|
);
|
||||||
}
|
return next?.requiredRole ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertDocumentEditable(
|
||||||
|
contract: Contract,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await this.documentIsEditableBy(contract, user)) return;
|
||||||
|
|
||||||
|
const role = await this.nextApproverRole(contract);
|
||||||
|
throw new ConflictException(
|
||||||
|
role
|
||||||
|
? `The contract document can only be edited by the current approver (${role}).`
|
||||||
|
: 'The contract document is locked — the contract has advanced beyond approval.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -513,25 +621,11 @@ export class ContractTransitionService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
stepId: string,
|
stepId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
requiredRole: string,
|
|
||||||
authUser?: TCurrentUser,
|
authUser?: TCurrentUser,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
if (authUser) {
|
|
||||||
assertCanApproveBookingStep(authUser, requiredRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
|
||||||
// Approvers review the generated contract document, so it must exist before
|
|
||||||
// the first approval can be recorded. Staff generate it (from the frozen,
|
|
||||||
// optionally-edited snapshot) at the accept stage.
|
|
||||||
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Generate the contract document before it can be approved.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||||
if (!step || step.status !== 'PENDING') {
|
if (!step || step.status !== 'PENDING') {
|
||||||
throw new BadRequestException('Approval step not found or already actioned');
|
throw new BadRequestException('Approval step not found or already actioned');
|
||||||
@@ -541,31 +635,34 @@ export class ContractTransitionService {
|
|||||||
if (!next || next.id !== step.id) {
|
if (!next || next.id !== step.id) {
|
||||||
throw new BadRequestException('Approval steps must be completed in order');
|
throw new BadRequestException('Approval steps must be completed in order');
|
||||||
}
|
}
|
||||||
if (step.requiredRole !== requiredRole) {
|
|
||||||
throw new BadRequestException(
|
// The role is the step's own — never the caller's claim about themselves.
|
||||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
const requiredRole = step.requiredRole;
|
||||||
);
|
if (authUser) {
|
||||||
}
|
assertCanApproveContractStep(authUser, requiredRole);
|
||||||
if (step.blocksRole && step.blocksRole === requiredRole) {
|
|
||||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||||
|
|
||||||
// Record who acted on this step, but DO NOT advance the contract status here —
|
// Record who acted on this step, but DO NOT advance the contract status here —
|
||||||
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
|
// approving one step must not finalize the chain while later steps are still
|
||||||
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
|
// pending. Status only moves to APPROVED once every step in the chain is
|
||||||
// every step in the chain is complete; until then the contract stays in
|
// complete; until then the contract stays in PENDING_APPROVAL so the next
|
||||||
// PENDING_APPROVAL so the next required role can act.
|
// required approver can act.
|
||||||
|
//
|
||||||
|
// `contract_approval_steps` is the source of truth for who approved what — a
|
||||||
|
// chain is an arbitrary sequence of position types and cannot be represented
|
||||||
|
// by fixed columns. The legacy columns below are still stamped, best-effort,
|
||||||
|
// for the three roles that map onto them so older readers keep working.
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (requiredRole === 'LINE_STAFF') {
|
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
|
||||||
updates.approvedByStaffId = actorId;
|
updates.approvedByStaffId = actorId;
|
||||||
updates.approvedByStaffAt = now;
|
updates.approvedByStaffAt = now;
|
||||||
} else if (requiredRole === 'DIRECTOR') {
|
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
|
||||||
updates.signedByDirectorId = actorId;
|
updates.signedByDirectorId = actorId;
|
||||||
updates.signedByDirectorAt = now;
|
updates.signedByDirectorAt = now;
|
||||||
} else if (requiredRole === 'CEO') {
|
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
|
||||||
updates.signedByCeoId = actorId;
|
updates.signedByCeoId = actorId;
|
||||||
updates.signedByCeoAt = now;
|
updates.signedByCeoAt = now;
|
||||||
}
|
}
|
||||||
@@ -579,14 +676,19 @@ export class ContractTransitionService {
|
|||||||
const updated = await this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
this.notifier.approved(updated);
|
this.notifier.approved(updated);
|
||||||
// Every step approved → CONTRACT_READY. The document was already generated
|
// Final approval is what produces the contract PDF — until now there was
|
||||||
// (and reviewed) at the accept stage, so we reuse it rather than
|
// only a live preview. The approval steps are already committed, so a
|
||||||
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
// render failure must not roll them back; surface it instead of swallowing
|
||||||
|
// it, since an APPROVED contract with no document needs operator action.
|
||||||
try {
|
try {
|
||||||
return await this.finalizeApprovedContract(contractId);
|
return await this.finalizeApprovedContract(contractId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.error(
|
||||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'All approvals were recorded, but generating the contract PDF failed. ' +
|
||||||
|
'Retry generation from the contract page.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,24 +696,13 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff (re)generate the contract PDF. Two stages:
|
* Retry path for a contract that finished approval but whose PDF failed to
|
||||||
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
* render (Chromium unavailable, etc.). The normal flow generates the document
|
||||||
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
* automatically on the final approval — there is no manual generate step
|
||||||
* it is blocked once an approver has acted (the document is then locked).
|
* before that, only the live preview.
|
||||||
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
|
||||||
* CONTRACT_READY.
|
|
||||||
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
|
||||||
* transition — the document re-renders lazily on view/download.
|
|
||||||
*/
|
*/
|
||||||
async generateContract(contractId: string): Promise<Contract> {
|
async generateContract(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
if (contract.status === 'PENDING_APPROVAL') {
|
|
||||||
this.assertDocumentEditable(contract);
|
|
||||||
await this.renderContractDocument(contract);
|
|
||||||
return this.contractsService.findById(contractId);
|
|
||||||
}
|
|
||||||
|
|
||||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
await this.renderContractDocument(contract);
|
await this.renderContractDocument(contract);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
@@ -626,11 +717,17 @@ export class ContractTransitionService {
|
|||||||
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
||||||
* (it re-renders on view/download) but the timestamp is still stamped.
|
* (it re-renders on view/download) but the timestamp is still stamped.
|
||||||
*/
|
*/
|
||||||
private async renderContractDocument(contract: Contract): Promise<void> {
|
private async renderContractDocument(
|
||||||
|
contract: Contract,
|
||||||
|
options: { strict?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||||
try {
|
try {
|
||||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Strict callers (final approval) need to know the PDF is missing — it is
|
||||||
|
// the artifact of the completed chain, not a cache that can refill later.
|
||||||
|
if (options.strict) throw err;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||||
);
|
);
|
||||||
@@ -642,15 +739,14 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every approval step landed → CONTRACT_READY. The document was already
|
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
|
||||||
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
* This is the only point at which the document is produced: approvers review a
|
||||||
* if it was somehow never generated. Never re-renders over an existing file.
|
* live preview, and the final approval is what turns it into a PDF. Renders
|
||||||
|
* unconditionally so the file reflects every edit made during the chain.
|
||||||
*/
|
*/
|
||||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (!contract.contractGeneratedAt) {
|
await this.renderContractDocument(contract, { strict: true });
|
||||||
await this.renderContractDocument(contract);
|
|
||||||
}
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'CONTRACT_READY',
|
status: 'CONTRACT_READY',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -839,11 +935,11 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||||
* same number {@link sign} verifies against. The client never picks the number
|
* email — the same contacts {@link sign} verifies against. The client never
|
||||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||||
* say where the code went without exposing the full number.
|
* UI can say where the code went without exposing the full values.
|
||||||
*/
|
*/
|
||||||
async sendSigningOtp(
|
async sendSigningOtp(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -858,9 +954,9 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||||
|
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
await this.otpService.sendOtp({ phone: signerPhone });
|
await this.otpService.sendOtp(signerContacts);
|
||||||
return { sentTo: maskPhone(signerPhone) };
|
return { sentTo: maskSignerContacts(signerContacts) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
@@ -887,17 +983,17 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||||
// phone, resolved server-side from the authenticated user id — never a
|
// contacts, resolved server-side from the authenticated user id — never
|
||||||
// caller-supplied number, which an attacker could point at their own
|
// caller-supplied ones, which an attacker could point at their own phone
|
||||||
// phone. Ownership is already asserted above, so this proves the specific
|
// or mailbox. Ownership is already asserted above, so this proves the
|
||||||
// person holding the account is present, not merely that someone reached a
|
// specific person holding the account is present, not merely that someone
|
||||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
// reached a shared company line. Must resolve identically to
|
||||||
// and verify would target different numbers.
|
// sendSigningOtp, or send and verify would target different contacts.
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
if (!dto.otp) {
|
if (!dto.otp) {
|
||||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||||
}
|
}
|
||||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
||||||
await this.applySignature(contract, dto, options);
|
await this.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { actorLabel } from '../warehouses/current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import {
|
import {
|
||||||
assertFreightPermission,
|
assertFreightPermission,
|
||||||
@@ -60,7 +62,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
|||||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||||
import {
|
import {
|
||||||
ApproveStepDto,
|
|
||||||
RejectContractDto,
|
RejectContractDto,
|
||||||
RejectStepDto,
|
RejectStepDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
@@ -90,6 +91,7 @@ import {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class ContractsController {
|
export class ContractsController {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly documentHistory: ContractDocumentHistoryService,
|
||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
private readonly pricingService: ContractPricingService,
|
private readonly pricingService: ContractPricingService,
|
||||||
private readonly transitionService: ContractTransitionService,
|
private readonly transitionService: ContractTransitionService,
|
||||||
@@ -352,8 +354,22 @@ export class ContractsController {
|
|||||||
summary:
|
summary:
|
||||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||||
})
|
})
|
||||||
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
|
getContractDocumentDraft(
|
||||||
return this.transitionService.getContractDocumentDraft(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Editability depends on WHO is asking — only the approver whose turn it is
|
||||||
|
// may edit — so the caller is part of the draft lookup.
|
||||||
|
return this.transitionService.getContractDocumentDraft(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/document/revisions')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Audit trail of edits to this contract\'s document (newest first)',
|
||||||
|
})
|
||||||
|
getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.documentHistory.list(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/document/articles')
|
@Put(':id/document/articles')
|
||||||
@@ -365,8 +381,14 @@ export class ContractsController {
|
|||||||
updateContractDocument(
|
updateContractDocument(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateContractDocumentDto,
|
@Body() dto: UpdateContractDocumentDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.updateContractDocument(id, dto);
|
return this.transitionService.updateContractDocument(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/request-changes')
|
@Post(':id/staff/request-changes')
|
||||||
@@ -396,23 +418,20 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff([
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
|
||||||
FREIGHT_PERMS.contracts.approveDirector,
|
|
||||||
FREIGHT_PERMS.contracts.approveCeo,
|
|
||||||
])
|
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
approveStep(
|
approveStep(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
@Body() dto: ApproveStepDto,
|
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
// Whether this caller may approve depends on the step's own required role
|
||||||
|
// (an IAM position type), so the service resolves the step and authorizes
|
||||||
|
// against it — the client never declares its own role.
|
||||||
return this.transitionService.approveStep(
|
return this.transitionService.approveStep(
|
||||||
id,
|
id,
|
||||||
stepId,
|
stepId,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.requiredRole,
|
|
||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -968,13 +987,16 @@ export class ContractsController {
|
|||||||
assignRisk(
|
assignRisk(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: AssignRiskDto,
|
@Body() dto: AssignRiskDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.milestoneService.assignRisk(
|
return this.milestoneService.assignRisk(
|
||||||
bookingId,
|
bookingId,
|
||||||
dto.riskLevel,
|
dto.riskLevel,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.note,
|
dto.note,
|
||||||
|
// Risk history is read by people, so resolve the name now — the id alone
|
||||||
|
// would render as a UUID in the trail.
|
||||||
|
actorLabel(user),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
|||||||
import { ContractSignature } from './entities/contract-signature.entity';
|
import { ContractSignature } from './entities/contract-signature.entity';
|
||||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||||
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||||
|
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
@@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractSignature,
|
ContractSignature,
|
||||||
ContractApprovalStep,
|
ContractApprovalStep,
|
||||||
ContractReviewNote,
|
ContractReviewNote,
|
||||||
|
ContractDocumentRevision,
|
||||||
ContractClearanceCycle,
|
ContractClearanceCycle,
|
||||||
ContractDocumentReview,
|
ContractDocumentReview,
|
||||||
ClearanceMilestone,
|
ClearanceMilestone,
|
||||||
@@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ClearanceFeeService,
|
ClearanceFeeService,
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
|
ContractDocumentHistoryService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ClearanceWorkflowService,
|
ClearanceWorkflowService,
|
||||||
BookingClearanceService,
|
BookingClearanceService,
|
||||||
|
|||||||
@@ -374,23 +374,18 @@ export class ContractsService {
|
|||||||
if (companyProfileId) {
|
if (companyProfileId) {
|
||||||
// Business-license files are FileRecords (resource "company_profiles");
|
// Business-license files are FileRecords (resource "company_profiles");
|
||||||
// carry the live ones by reference. Staged/pending uploads are excluded by
|
// carry the live ones by reference. Staged/pending uploads are excluded by
|
||||||
// code. Codes are slugged from each document name so they group under
|
// code. The `business_license` prefix is preserved so the portal groups
|
||||||
// "Profile documents" on the contract detail page.
|
// them under "Business license" instead of the clearance catch-all — the
|
||||||
|
// index suffix keeps multiple licences distinct.
|
||||||
const records = await this.filesService.findByResource(
|
const records = await this.filesService.findByResource(
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
'company_profiles',
|
'company_profiles',
|
||||||
);
|
);
|
||||||
const slug = (name: string) =>
|
|
||||||
name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\.[a-z0-9]+$/, '')
|
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '') || 'profile_document';
|
|
||||||
|
|
||||||
records
|
records
|
||||||
.filter((r) => r.code === 'business_license')
|
.filter((r) => r.code === 'business_license')
|
||||||
.forEach((r, i) => {
|
.forEach((r, i) => {
|
||||||
const code = `${slug(r.name)}_${i + 1}`;
|
const code = `business_license_${i + 1}`;
|
||||||
if (existingCodes.has(code)) return;
|
if (existingCodes.has(code)) return;
|
||||||
docs.push({
|
docs.push({
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
|||||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One customs risk decision. Risk stays correctable until duty is advised off
|
||||||
|
* it, and the level is customer-visible, so every assignment is kept rather than
|
||||||
|
* overwritten — a disputed level needs to show what was set, by whom, and when.
|
||||||
|
*/
|
||||||
|
export interface RiskAssignmentRecord {
|
||||||
|
level: CustomsRiskLevel;
|
||||||
|
/** The level this replaced; absent on the first assignment. */
|
||||||
|
previousLevel?: CustomsRiskLevel;
|
||||||
|
assignedAt: string;
|
||||||
|
assignedByUserId?: string | null;
|
||||||
|
/** Display name resolved at assignment time, so the trail never shows a UUID. */
|
||||||
|
assignedBy?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
||||||
* - RISK_ASSIGNED → `riskLevel`
|
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
|
||||||
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||||
* Stored on the milestone so the timeline can render the value inline.
|
* Stored on the milestone so the timeline can render the value inline.
|
||||||
*/
|
*/
|
||||||
export interface MilestoneMetadata {
|
export interface MilestoneMetadata {
|
||||||
riskLevel?: CustomsRiskLevel;
|
riskLevel?: CustomsRiskLevel;
|
||||||
|
/**
|
||||||
|
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||||
|
* equals the last entry's `level`.
|
||||||
|
*/
|
||||||
|
riskHistory?: RiskAssignmentRecord[];
|
||||||
dutyAmount?: number;
|
dutyAmount?: number;
|
||||||
dutyCurrency?: string;
|
dutyCurrency?: string;
|
||||||
declarationSerial?: string;
|
declarationSerial?: string;
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity {
|
|||||||
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
||||||
stepOrder!: number;
|
stepOrder!: number;
|
||||||
|
|
||||||
@Column({ name: 'required_role', type: 'varchar', length: 40 })
|
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||||
requiredRole!: string;
|
requiredRole!: string;
|
||||||
|
|
||||||
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||||
blocksRole?: string | null;
|
blocksRole?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import type { ContractDocumentChange } from '../contract-document-diff.util';
|
||||||
|
import { Contract } from './contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit of contract document edits. The document stays editable
|
||||||
|
* through the whole approval chain, so this records who changed which article
|
||||||
|
* and when — the contract itself only ever holds the current snapshot.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'contract_document_revisions' })
|
||||||
|
@Index(['contractId'])
|
||||||
|
export class ContractDocumentRevision extends BaseEntity {
|
||||||
|
@Column({ name: 'contract_id', type: 'uuid' })
|
||||||
|
contractId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'contract_id' })
|
||||||
|
contract?: Contract;
|
||||||
|
|
||||||
|
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||||
|
actorId?: string | null;
|
||||||
|
|
||||||
|
/** The approval step's required role at the time of the edit. */
|
||||||
|
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||||
|
actorRole?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'step_id', type: 'uuid', nullable: true })
|
||||||
|
stepId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'summary', type: 'varchar', length: 255, nullable: true })
|
||||||
|
summary?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` })
|
||||||
|
changes!: ContractDocumentChange[];
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
|
||||||
import { FilesService } from "./files.service";
|
import { FilesService } from "./files.service";
|
||||||
@@ -23,13 +30,16 @@ export class FilesController {
|
|||||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||||
// caller's booking/company before streaming).
|
// caller's booking/company before streaming). Until that lands, any resource
|
||||||
|
// whose files are cross-tenant sensitive must opt OUT of this route and expose
|
||||||
|
// its own checked endpoint — see the support_message case below.
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Stream a file by ID",
|
summary: "Stream a file by ID",
|
||||||
description:
|
description:
|
||||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||||
"No resource context (e.g. booking ID) required. Serves inline by default so " +
|
"No resource context (e.g. booking ID) required. Serves inline by default so " +
|
||||||
"the browser can preview it; pass ?download=1 to force a download.",
|
"the browser can preview it; pass ?download=1 to force a download. " +
|
||||||
|
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
|
||||||
})
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "download",
|
name: "download",
|
||||||
@@ -41,7 +51,19 @@ export class FilesController {
|
|||||||
@Query("download") download: string | undefined,
|
@Query("download") download: string | undefined,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const { stream, record } = await this.filesService.streamById(fileId);
|
const record = await this.filesService.findById(fileId);
|
||||||
|
|
||||||
|
// Chat attachments are cross-tenant sensitive and this route has no
|
||||||
|
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||||
|
// another. SupportAttachmentController scopes the caller to the owning
|
||||||
|
// thread; refuse here rather than quietly serving the bytes.
|
||||||
|
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stream } = await this.filesService.streamById(fileId);
|
||||||
const forceDownload = download === "1" || download === "true";
|
const forceDownload = download === "1" || download === "true";
|
||||||
const disposition = forceDownload ? "attachment" : "inline";
|
const disposition = forceDownload ? "attachment" : "inline";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
|
|
||||||
import { FileRecord } from "./entities/file.entity";
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
return this.repository.find({ where: { resourceId, resource } });
|
return this.repository.find({ where: { resourceId, resource } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch sibling of {@link findByResource} for hydrating a page of resources at
|
||||||
|
* once (a thread of chat messages, say) instead of one query per row.
|
||||||
|
*/
|
||||||
|
async findByResourceIds(
|
||||||
|
resourceIds: string[],
|
||||||
|
resource: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
if (resourceIds.length === 0) return [];
|
||||||
|
return this.repository.find({
|
||||||
|
where: { resourceId: In(resourceIds), resource },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
findByCode(
|
findByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
import { MinioService } from "../minio/minio.service";
|
import { MinioService } from "../minio/minio.service";
|
||||||
@@ -78,8 +79,19 @@ export class FilesService {
|
|||||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||||
// human-readable name is preserved separately on the record below.
|
// human-readable name is preserved separately on the record below.
|
||||||
const safeName = sanitizeObjectName(file.originalname);
|
const safeName = sanitizeObjectName(file.originalname);
|
||||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
|
// The random segment is load-bearing, not decoration. `Date.now()` alone is
|
||||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
// NOT unique across a batch: callers upload with Promise.all, every callback
|
||||||
|
// runs to its first await in the same tick, so they all read the same
|
||||||
|
// millisecond. Two files with one name in one batch — e.g. pasting two
|
||||||
|
// screenshots, which browsers both call "image.png" — would build identical
|
||||||
|
// keys, and the second putObject would overwrite the first while both rows
|
||||||
|
// persisted pointing at the same object.
|
||||||
|
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||||
|
const url = await this.minioService.uploadFile(
|
||||||
|
objectName,
|
||||||
|
file.buffer,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
|
||||||
return this.filesRepository.create({
|
return this.filesRepository.create({
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -175,6 +187,27 @@ export class FilesService {
|
|||||||
return this.filesRepository.findByResource(resourceId, resource);
|
return this.filesRepository.findByResource(resourceId, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files for many resources of one kind, grouped by resource id. Resources with
|
||||||
|
* no files are absent from the map (callers should default to `[]`).
|
||||||
|
*/
|
||||||
|
async findByResourceIdsGrouped(
|
||||||
|
resourceIds: string[],
|
||||||
|
resource: string,
|
||||||
|
): Promise<Map<string, FileRecord[]>> {
|
||||||
|
const records = await this.filesRepository.findByResourceIds(
|
||||||
|
resourceIds,
|
||||||
|
resource,
|
||||||
|
);
|
||||||
|
const grouped = new Map<string, FileRecord[]>();
|
||||||
|
for (const record of records) {
|
||||||
|
const bucket = grouped.get(record.resourceId);
|
||||||
|
if (bucket) bucket.push(record);
|
||||||
|
else grouped.set(record.resourceId, [record]);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||||
* `url` is an un-signed object path that a browser cannot fetch directly;
|
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||||
@@ -190,7 +223,11 @@ export class FilesService {
|
|||||||
resource: string,
|
resource: string,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<FileRecord> {
|
): Promise<FileRecord> {
|
||||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
const record = await this.filesRepository.findByCode(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (!record)
|
if (!record)
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||||
@@ -198,7 +235,9 @@ export class FilesService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
async streamById(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||||
const record = await this.findById(id);
|
const record = await this.findById(id);
|
||||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
|||||||
@@ -333,10 +333,12 @@ export class FirstMileService {
|
|||||||
firstMilePickupAddress?: string | null;
|
firstMilePickupAddress?: string | null;
|
||||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
// The pickup address is the only record of what the contract chose.
|
||||||
|
// `serviceType.includesFirstMile` used to satisfy this too, but every
|
||||||
|
// service type ships with it set to true, so the OR made the address check
|
||||||
|
// dead and admitted every paid export booking into the queue.
|
||||||
return Boolean(
|
return Boolean(
|
||||||
booking.tradeDirection === 'EXPORT' &&
|
booking.tradeDirection === 'EXPORT' && booking.firstMilePickupAddress?.trim(),
|
||||||
(booking.firstMilePickupAddress?.trim() ||
|
|
||||||
booking.serviceType?.includesFirstMile),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal file
108
apps/edr-freight-api/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// health.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, HttpStatus, Res } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
|
|
||||||
|
type CheckStatus = "ok" | "error" | "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness normally stays green when only the broker is down.
|
||||||
|
*
|
||||||
|
* A 503 pulls the pod out of the load balancer, which would take booking,
|
||||||
|
* tracking and billing offline because SMS is unreachable — a strictly worse
|
||||||
|
* outcome than degraded notifications. The broker check is therefore reported,
|
||||||
|
* not enforced, and `READINESS_REQUIRES_BROKER=true` opts into hard-failing for
|
||||||
|
* deployments where a silent OTP black hole is the greater risk.
|
||||||
|
*/
|
||||||
|
const READINESS_REQUIRES_BROKER =
|
||||||
|
process.env.READINESS_REQUIRES_BROKER === "true";
|
||||||
|
|
||||||
|
@ApiTags("Health")
|
||||||
|
@Controller("health")
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly emailClient: EmailClientService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: "Liveness probe" })
|
||||||
|
liveness() {
|
||||||
|
return { status: "ok", timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("ready")
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.",
|
||||||
|
})
|
||||||
|
async readiness(@Res() res: Response) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
let database: { status: CheckStatus; latencyMs: number; error?: string };
|
||||||
|
try {
|
||||||
|
await this.dataSource.query("SELECT 1");
|
||||||
|
database = { status: "ok", latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
database = {
|
||||||
|
status: "error",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `null` from the client means the connection manager was not reachable
|
||||||
|
// through Nest's internals — surfaced as "unknown" so a shape change in
|
||||||
|
// @nestjs/microservices degrades to honest ignorance, not a false "ok".
|
||||||
|
const toStatus = (connected: boolean | null): CheckStatus =>
|
||||||
|
connected === null ? "unknown" : connected ? "ok" : "error";
|
||||||
|
|
||||||
|
const broker = {
|
||||||
|
sms: { status: toStatus(this.smsClient.brokerConnected) },
|
||||||
|
email: { status: toStatus(this.emailClient.brokerConnected) },
|
||||||
|
// Every OTP, and every booking/billing notification, publishes through
|
||||||
|
// these. `error` here means codes are being generated and silently dropped.
|
||||||
|
enabled: process.env.RABBITMQ_ENABLED !== "false",
|
||||||
|
};
|
||||||
|
|
||||||
|
const brokerDown =
|
||||||
|
broker.sms.status === "error" || broker.email.status === "error";
|
||||||
|
const failed =
|
||||||
|
database.status === "error" ||
|
||||||
|
(READINESS_REQUIRES_BROKER && brokerDown);
|
||||||
|
|
||||||
|
const status = failed ? "error" : brokerDown ? "degraded" : "ok";
|
||||||
|
|
||||||
|
return res
|
||||||
|
.status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
|
||||||
|
.json({
|
||||||
|
status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: { database, broker },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("info")
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: "App info — version, environment, uptime" })
|
||||||
|
info() {
|
||||||
|
return {
|
||||||
|
name: "edr-freight-api",
|
||||||
|
version: process.env.npm_package_version ?? "1.0.0",
|
||||||
|
environment: process.env.NODE_ENV ?? "development",
|
||||||
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal file
14
apps/edr-freight-api/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// health.module.ts
|
||||||
|
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { HealthController } from "./health.controller";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
// NotificationsModule exports the SMS/email clients; the readiness probe reads
|
||||||
|
// their broker connection state rather than opening a second connection.
|
||||||
|
imports: [NotificationsModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { LastMileService } from './last-mile.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking reaches the last-mile queue only if its contract bought EDR
|
||||||
|
* delivery, and never if the customer is hauling it themselves. Creation used
|
||||||
|
* to check payment alone, so any paid booking could be accepted — which put a
|
||||||
|
* self-haul booking and an EDR leg on the same shipment at once.
|
||||||
|
*/
|
||||||
|
type BookingRow = { tradeDirection: string; firstMile: string | null; lastMile: string | null };
|
||||||
|
|
||||||
|
function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) {
|
||||||
|
const booking = opts.booking ?? {
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
firstMile: null,
|
||||||
|
lastMile: 'Bole, Addis Ababa',
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = jest.fn((sql: string) => {
|
||||||
|
if (sql.includes('customer_truck_assignments')) {
|
||||||
|
return Promise.resolve(opts.hasCustomerTruck ? [{ '?column?': 1 }] : []);
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM freight.bookings')) {
|
||||||
|
return Promise.resolve([booking]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMileRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new LastMileService(
|
||||||
|
lastMileRepository as never,
|
||||||
|
{} as never, // bookingsRepository
|
||||||
|
{ setAvailability: jest.fn() } as never, // vehiclesService
|
||||||
|
{} as never, // driversService
|
||||||
|
{} as never, // smsClient
|
||||||
|
{ query } as unknown as DataSource,
|
||||||
|
{ record: jest.fn() } as never, // history
|
||||||
|
{} as never, // billing
|
||||||
|
{} as never, // filesService
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, lastMileRepository, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LastMileService.create — haulage guard', () => {
|
||||||
|
it('accepts a booking whose contract chose EDR delivery', async () => {
|
||||||
|
const { service, lastMileRepository } = makeService({});
|
||||||
|
|
||||||
|
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||||
|
|
||||||
|
expect(lastMileRepository.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a booking that chose no road legs on its contract', async () => {
|
||||||
|
const { service, lastMileRepository } = makeService({
|
||||||
|
booking: { tradeDirection: 'IMPORT', firstMile: null, lastMile: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a booking already hauled by the customer’s own truck', async () => {
|
||||||
|
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an import that only chose collection — that is the export leg', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
booking: { tradeDirection: 'IMPORT', firstMile: 'Modjo', lastMile: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the existing leg without re-checking, so the queue stays idempotent', async () => {
|
||||||
|
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||||
|
lastMileRepository.findAll.mockResolvedValue([{ id: 'lm-existing' }]);
|
||||||
|
|
||||||
|
const result = await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 'lm-existing' });
|
||||||
|
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,18 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NO_MILE_SERVICE_MESSAGE,
|
||||||
|
SELF_HAUL_CONFLICT_MESSAGE,
|
||||||
|
usesEdrMileService,
|
||||||
|
} from '../../common/mile-haulage.util';
|
||||||
|
import {
|
||||||
|
assertBulkTonnageRemains,
|
||||||
|
assertTruckCountWithinContainers,
|
||||||
|
assertTruckLoad,
|
||||||
|
bookingContainerSizes,
|
||||||
|
remainingBulkTons,
|
||||||
|
} from '../../common/truck-load.util';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
@@ -126,6 +138,47 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a booking that actually bought EDR delivery belongs in the last-mile
|
||||||
|
* queue, and a booking hauled by the customer's own truck must never also get
|
||||||
|
* an EDR leg.
|
||||||
|
*
|
||||||
|
* Both halves were missing: creation checked payment alone, so any paid
|
||||||
|
* booking could be accepted into the queue — including one whose contract
|
||||||
|
* chose no road legs at all, and one already carrying a customer truck. The
|
||||||
|
* mirror rule existed on the truck side only
|
||||||
|
* (CustomerTruckService.assertSelfHaulPaid), so whichever side acted second
|
||||||
|
* silently opened a competing delivery on the same booking.
|
||||||
|
*/
|
||||||
|
private async assertEdrHaulsThisBooking(bookingId?: string | null): Promise<void> {
|
||||||
|
if (!bookingId) return;
|
||||||
|
|
||||||
|
const [booking] = await this.dataSource.query(
|
||||||
|
`SELECT trade_direction AS "tradeDirection",
|
||||||
|
first_mile_pickup_address AS "firstMile",
|
||||||
|
last_mile_delivery_address AS "lastMile"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
// The road legs are chosen on the contract and copied onto the booking, so
|
||||||
|
// the booking's own addresses answer this without a join.
|
||||||
|
if (booking && !usesEdrMileService(booking)) {
|
||||||
|
throw new BadRequestException(NO_MILE_SERVICE_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [truck] = await this.dataSource.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM freight.customer_truck_assignments
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (truck) {
|
||||||
|
throw new BadRequestException(SELF_HAUL_CONFLICT_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
@@ -352,6 +405,8 @@ export class LastMileService {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertEdrHaulsThisBooking(dto.bookingId);
|
||||||
|
|
||||||
const record = await this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
@@ -573,20 +628,6 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
|
||||||
if (!numbers.length) return [];
|
|
||||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
|
||||||
`SELECT bc.container_size AS "size"
|
|
||||||
FROM freight.booking_container_units bcu
|
|
||||||
JOIN freight.booking_container bc
|
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
|
||||||
WHERE bc.booking_id = $1
|
|
||||||
AND UPPER(bcu.container_number) = ANY($2)
|
|
||||||
AND bcu.deleted_at IS NULL`,
|
|
||||||
[bookingId, numbers],
|
|
||||||
);
|
|
||||||
return rows.map((r) => (r.size ?? '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||||
@@ -599,26 +640,9 @@ export class LastMileService {
|
|||||||
remainingTons: number;
|
remainingTons: number;
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}> {
|
}> {
|
||||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
// Counts customer trucks as well as EDR ones — a booking hauls by one path
|
||||||
await this.dataSource.query(
|
// or the other, and "until no tonnage is left" means the same either way.
|
||||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
return remainingBulkTons(this.dataSource, bookingId);
|
||||||
COALESCE((
|
|
||||||
SELECT SUM(va.net_weight_tons)
|
|
||||||
FROM freight.last_mile_vehicle_assignments va
|
|
||||||
JOIN freight.last_mile lm
|
|
||||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
|
||||||
WHERE lm.booking_id = b.id
|
|
||||||
AND va.deleted_at IS NULL
|
|
||||||
AND va.departed_at IS NOT NULL
|
|
||||||
), 0) AS "hauledTons"
|
|
||||||
FROM freight.bookings b
|
|
||||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
|
||||||
[bookingId],
|
|
||||||
);
|
|
||||||
const totalTons = Number(row?.totalTons ?? 0);
|
|
||||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
|
||||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
|
||||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -643,11 +667,7 @@ export class LastMileService {
|
|||||||
);
|
);
|
||||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||||
if (totalTons > 0 && remainingTons <= 0) {
|
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||||
throw new BadRequestException(
|
|
||||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,33 +677,41 @@ export class LastMileService {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const vehicleId of desired) {
|
for (const vehicleId of desired) {
|
||||||
const load = loads.get(vehicleId) ?? [];
|
const load = loads.get(vehicleId) ?? [];
|
||||||
if (load.length > 2) {
|
assertTruckLoad({
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
containers: load,
|
||||||
}
|
bookingContainers: bookingNumbers,
|
||||||
for (const n of load) {
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
|
||||||
if (!bookingNumbers.includes(n)) {
|
assignedElsewhere: [...seen],
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
});
|
||||||
}
|
load.forEach((n) => seen.add(n));
|
||||||
if (seen.has(n)) {
|
|
||||||
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
|
||||||
}
|
|
||||||
seen.add(n);
|
|
||||||
}
|
|
||||||
// A 40ft container fills the truck; only two 20ft share one.
|
|
||||||
if (load.length > 1) {
|
|
||||||
const sizes = await this.containerSizes(bookingId, load);
|
|
||||||
if (sizes.some((s) => s.includes('40'))) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desired.length > bookingNumbers.length) {
|
assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
|
||||||
throw new BadRequestException(
|
}
|
||||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
|
||||||
|
/**
|
||||||
|
* A truck that has already reached the customer cannot have its load rewritten
|
||||||
|
* — the containers on it are a delivered fact, not a plan. The customer side
|
||||||
|
* has locked this since it was built (`Cannot edit a truck that has already
|
||||||
|
* arrived`); the EDR side let a reassignment silently rewrite history.
|
||||||
|
*/
|
||||||
|
private async assertNoArrivedVehicleChanged(
|
||||||
|
current: LastMileVehicleAssignment[],
|
||||||
|
desiredMap: Map<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||||
|
for (const assignment of current) {
|
||||||
|
if (!assignment.arrivedAt) continue;
|
||||||
|
const stillPresent = desiredMap.has(assignment.vehicleId);
|
||||||
|
const load = desiredMap.get(assignment.vehicleId) ?? [];
|
||||||
|
const currentLoad = (assignment.containers ?? []).map((c) =>
|
||||||
|
c.containerNumber.trim().toUpperCase(),
|
||||||
);
|
);
|
||||||
|
if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This truck has already arrived — its load can no longer be changed or removed',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,6 +746,8 @@ export class LastMileService {
|
|||||||
where: { lastMileId: id },
|
where: { lastMileId: id },
|
||||||
relations: { containers: true },
|
relations: { containers: true },
|
||||||
});
|
});
|
||||||
|
await this.assertNoArrivedVehicleChanged(current, desiredMap);
|
||||||
|
|
||||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
// old single-vehicle path has no junction row but must still be freed.
|
// old single-vehicle path has no junction row but must still be freed.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
|
import { NEVER, Observable, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { isBrokerConnected, publishConfirmed } from './broker.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ClientProxy.emit()` returns a cold Observable that, for RMQ, completes without
|
||||||
|
* emitting once `dispatchEvent` settles — and rejects if the publish fails. These
|
||||||
|
* fakes reproduce each of those three shapes.
|
||||||
|
*/
|
||||||
|
function clientEmitting(source: Observable<unknown>): ClientProxy {
|
||||||
|
return { emit: jest.fn().mockReturnValue(source) } as unknown as ClientProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('publishConfirmed', () => {
|
||||||
|
const logger = { error: jest.fn() } as unknown as Logger;
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('is true when the publish completes (broker confirmed)', async () => {
|
||||||
|
// Completes with no value — the success shape, and the case that throws
|
||||||
|
// EmptyError without a defaultIfEmpty.
|
||||||
|
const client = clientEmitting(new Observable<never>((s) => s.complete()));
|
||||||
|
await expect(publishConfirmed(client, 'send-sms', {}, logger)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the publish never settles, rather than hanging', async () => {
|
||||||
|
// A broker that is down: amqp-connection-manager buffers the publish and the
|
||||||
|
// promise would never resolve. The timeout is what stops one dead broker from
|
||||||
|
// hanging every caller of sendSms/sendEmail.
|
||||||
|
const client = clientEmitting(NEVER);
|
||||||
|
await expect(publishConfirmed(client, 'send-sms', {}, logger, 20)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the publish errors', async () => {
|
||||||
|
const client = clientEmitting(throwError(() => new Error('channel closed')));
|
||||||
|
await expect(publishConfirmed(client, 'send-email', {}, logger)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBrokerConnected', () => {
|
||||||
|
/** Stands in for `ClientProxy.unwrap()`, which returns the AmqpConnectionManager. */
|
||||||
|
function clientUnwrapping(manager: unknown): ClientProxy {
|
||||||
|
return { unwrap: () => manager } as unknown as ClientProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports the connection manager state', () => {
|
||||||
|
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => true }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isBrokerConnected(clientUnwrapping({ isConnected: () => false }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when unwrap throws — the client never connected', () => {
|
||||||
|
// ClientRMQ.unwrap() throws "Not initialized" while its internal client is
|
||||||
|
// null, which is what a failed boot-time connect leaves behind. That is a
|
||||||
|
// real down signal and must not be softened to "unknown".
|
||||||
|
const uninitialised = {
|
||||||
|
unwrap: () => {
|
||||||
|
throw new Error('Not initialized. Please call the "connect" method first.');
|
||||||
|
},
|
||||||
|
} as unknown as ClientProxy;
|
||||||
|
expect(isBrokerConnected(uninitialised)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null — not a guess — when the manager lacks isConnected or it throws', () => {
|
||||||
|
// Guards the health endpoint against reporting "ok" if amqp-connection-manager
|
||||||
|
// or Nest changes shape and the accessor we rely on disappears.
|
||||||
|
expect(isBrokerConnected(clientUnwrapping(null))).toBeNull();
|
||||||
|
expect(isBrokerConnected(clientUnwrapping({}))).toBeNull();
|
||||||
|
expect(
|
||||||
|
isBrokerConnected(
|
||||||
|
clientUnwrapping({
|
||||||
|
isConnected: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// broker.util.ts
|
||||||
|
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
|
import { defaultIfEmpty, lastValueFrom, timeout } from "rxjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a publisher confirm before giving up on a message.
|
||||||
|
*
|
||||||
|
* Load-bearing, not a nicety: when the broker is unreachable
|
||||||
|
* amqp-connection-manager buffers the publish and retries it on reconnect, so the
|
||||||
|
* underlying promise never settles. Without a bound, one dead broker turns every
|
||||||
|
* caller of sendSms/sendEmail into a hung request.
|
||||||
|
*/
|
||||||
|
export const PUBLISH_CONFIRM_TIMEOUT_MS = Number(
|
||||||
|
process.env.RABBITMQ_PUBLISH_TIMEOUT_MS ?? 5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event and wait for RabbitMQ to confirm it.
|
||||||
|
*
|
||||||
|
* `ClientProxy.emit()` returns a *cold* Observable. Called without subscribing —
|
||||||
|
* as this codebase did everywhere — nothing forces the publish to be observed, so
|
||||||
|
* the caller reports success whether or not the broker ever accepted the message.
|
||||||
|
* Awaiting it drives `dispatchEvent`, which resolves only once
|
||||||
|
* amqp-connection-manager's ChannelWrapper has a publisher confirm.
|
||||||
|
*
|
||||||
|
* So `true` here means the broker took ownership of the message. It still says
|
||||||
|
* nothing about the consumer, the SMS gateway, or delivery to a handset — those
|
||||||
|
* remain outside this process's knowledge.
|
||||||
|
*/
|
||||||
|
export async function publishConfirmed(
|
||||||
|
client: ClientProxy,
|
||||||
|
pattern: string,
|
||||||
|
payload: unknown,
|
||||||
|
logger: Logger,
|
||||||
|
timeoutMs: number = PUBLISH_CONFIRM_TIMEOUT_MS,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// `emit` completes without emitting a value, so lastValueFrom needs a default
|
||||||
|
// or it rejects with EmptyError on the success path.
|
||||||
|
await lastValueFrom(
|
||||||
|
client
|
||||||
|
.emit(pattern, payload)
|
||||||
|
.pipe(timeout(timeoutMs), defaultIfEmpty(undefined)),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`broker.publish.failed pattern='${pattern}' timeoutMs=${timeoutMs}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client's connection manager currently believes it is connected.
|
||||||
|
*
|
||||||
|
* Uses `ClientProxy.unwrap()` — Nest's public accessor for the underlying
|
||||||
|
* transport client, which for `ClientRMQ` is the `AmqpConnectionManager`. Calling
|
||||||
|
* `connect()` instead cannot answer this: it resolves against a *disconnected*
|
||||||
|
* manager too, so it never distinguishes up from down.
|
||||||
|
*
|
||||||
|
* Three outcomes, deliberately distinct:
|
||||||
|
* - `false` when the manager reports disconnected, or when `unwrap()` throws
|
||||||
|
* because the client was never initialised (a failed boot-time connect leaves
|
||||||
|
* it null — genuinely down, not unknown);
|
||||||
|
* - `null` when the manager exists but has no `isConnected`, i.e. the library
|
||||||
|
* shape changed under us — the health endpoint reports "unknown" rather than
|
||||||
|
* quietly claiming health;
|
||||||
|
* - `true` only on an explicit positive from the manager.
|
||||||
|
*/
|
||||||
|
export function isBrokerConnected(client: ClientProxy): boolean | null {
|
||||||
|
let manager: unknown;
|
||||||
|
try {
|
||||||
|
manager = client.unwrap<unknown>();
|
||||||
|
} catch {
|
||||||
|
// "Not initialized. Please call the connect method first." — no connection
|
||||||
|
// was ever established, which is a real down signal, not an unknown one.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const probe = manager as { isConnected?: () => boolean } | null;
|
||||||
|
if (!probe || typeof probe.isConnected !== "function") return null;
|
||||||
|
try {
|
||||||
|
return probe.isConnected();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ClientProxy } from "@nestjs/microservices";
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
import { SendEmailDto } from "./dtos/email.dto";
|
import { SendEmailDto } from "./dtos/email.dto";
|
||||||
|
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailClientService implements OnApplicationBootstrap {
|
export class EmailClientService implements OnApplicationBootstrap {
|
||||||
@@ -33,19 +34,34 @@ export class EmailClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||||
return { queued: false };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
this.emailClient.emit("send-email", {
|
const queued = await publishConfirmed(
|
||||||
to: dto.to,
|
this.emailClient,
|
||||||
subject: dto.subject,
|
"send-email",
|
||||||
text: dto.text,
|
{
|
||||||
html: dto.html,
|
to: dto.to,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
subject: dto.subject,
|
||||||
});
|
text: dto.text,
|
||||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
html: dto.html,
|
||||||
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||||
|
// delivery — the consumer and the SMTP hop are downstream and invisible here.
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
// Recipient + content are PII — debug only.
|
// Recipient + content are PII — debug only.
|
||||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
||||||
return { queued: true };
|
return { queued };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection state for the health endpoint. `null` means the broker client did
|
||||||
|
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||||
|
*/
|
||||||
|
get brokerConnected(): boolean | null {
|
||||||
|
if (!this.enabled) return false;
|
||||||
|
return isBrokerConnected(this.emailClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ClientProxy } from "@nestjs/microservices";
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
||||||
|
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsClientService implements OnApplicationBootstrap {
|
export class SmsClientService implements OnApplicationBootstrap {
|
||||||
@@ -14,7 +15,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject("SMS_SERVICE")
|
@Inject("SMS_SERVICE")
|
||||||
private smsClient: ClientProxy,
|
private smsClient: ClientProxy,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.log("connected to SMS service");
|
this.logger.log("connected to SMS service");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Error happened at SMS service", err);
|
this.logger.error("Error happened at SMS service", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,34 +36,61 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||||
return { queued: false };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
this.smsClient.emit("send-sms", {
|
const queued = await publishConfirmed(
|
||||||
to: dto.to,
|
this.smsClient,
|
||||||
text: dto.message,
|
"send-sms",
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
{
|
||||||
});
|
to: dto.to,
|
||||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
text: dto.message,
|
||||||
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||||
|
// delivery — the consumer, the SMS gateway and the carrier are all downstream
|
||||||
|
// of this and invisible from here.
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
|
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
// Recipient + content are PII — debug only.
|
// Recipient + content are PII — debug only.
|
||||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||||
return { queued: true };
|
return { queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
this.logger.warn(
|
||||||
|
`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`,
|
||||||
|
);
|
||||||
return { queued: false };
|
return { queued: false };
|
||||||
}
|
}
|
||||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
const messages = (dto.messages ?? []).map((m) => ({
|
||||||
this.smsClient.emit("ozeking-bulk-sms", {
|
to: m.to,
|
||||||
messages,
|
text: m.message,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
from: m.from,
|
||||||
});
|
}));
|
||||||
|
const queued = await publishConfirmed(
|
||||||
|
this.smsClient,
|
||||||
|
"ozeking-bulk-sms",
|
||||||
|
{
|
||||||
|
messages,
|
||||||
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
|
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||||
return { queued: true };
|
return { queued };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection state for the health endpoint. `null` means the broker client did
|
||||||
|
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||||
|
*/
|
||||||
|
get brokerConnected(): boolean | null {
|
||||||
|
if (!this.enabled) return false;
|
||||||
|
return isBrokerConnected(this.smsClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import {
|
|||||||
import { OtpService, OtpTarget } from "./otp.service";
|
import { OtpService, OtpTarget } from "./otp.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
// Exactly one of phone/email must be present per request — the channel the
|
// At least one of phone/email must be present. When BOTH are given the code is
|
||||||
// code is sent through / checked against.
|
// sent to both and either one verifies it — the caller no longer picks a single
|
||||||
|
// channel, it just states every address it knows for the account.
|
||||||
function toTarget(phone?: string, email?: string): OtpTarget {
|
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||||
if (email) return { email };
|
const target: OtpTarget = {};
|
||||||
if (phone) return { phone };
|
if (email?.trim()) target.email = email;
|
||||||
throw new BadRequestException("phone or email is required");
|
if (phone?.trim()) target.phone = phone;
|
||||||
|
if (!target.email && !target.phone) {
|
||||||
|
throw new BadRequestException("phone or email is required");
|
||||||
|
}
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||||
@@ -41,7 +46,13 @@ export class OtpController {
|
|||||||
@Body("email")
|
@Body("email")
|
||||||
email?: string
|
email?: string
|
||||||
) {
|
) {
|
||||||
return this.otpService.sendOtp(toTarget(phone, email));
|
// `delivered` stays server-side: this route is @Public(), and whether our
|
||||||
|
// broker accepted the publish is infrastructure state an anonymous caller has
|
||||||
|
// no need for. It is on the `otp.dispatch` log line instead.
|
||||||
|
const { success, message } = await this.otpService.sendOtp(
|
||||||
|
toTarget(phone, email)
|
||||||
|
);
|
||||||
|
return { success, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { Injectable } from "@nestjs/common";
|
|||||||
|
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Repository } from "typeorm";
|
import { FindOptionsWhere, Repository } from "typeorm";
|
||||||
|
|
||||||
import { OtpVerification } from "./otp.entity";
|
import { OtpVerification } from "./otp.entity";
|
||||||
|
|
||||||
|
type Target = { phone?: string; email?: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpRepository {
|
export class OtpRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,54 +48,112 @@ export class OtpRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Find By Target (either channel)
|
// Find By Target (any named channel)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async findByTarget(
|
/**
|
||||||
target: { phone?: string; email?: string }
|
* OR across every channel the target names. A code sent to both phone and
|
||||||
) {
|
* email lives in ONE row carrying both values, so a verify that quotes either
|
||||||
return target.email
|
* one resolves the same row — that is what makes "sent to both, verify with
|
||||||
? this.findByEmail(target.email)
|
* either" work.
|
||||||
: this.findByPhone(target.phone!);
|
*/
|
||||||
}
|
private whereForTarget(
|
||||||
|
target: Target
|
||||||
|
): FindOptionsWhere<OtpVerification>[] {
|
||||||
|
const where: FindOptionsWhere<OtpVerification>[] =
|
||||||
|
[];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
if (target.email)
|
||||||
// Create OTP
|
where.push({
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async createOtp(
|
|
||||||
target: { phone?: string; email?: string },
|
|
||||||
otp: string
|
|
||||||
) {
|
|
||||||
const entity =
|
|
||||||
this.repository.create({
|
|
||||||
phone: target.phone,
|
|
||||||
email: target.email,
|
email: target.email,
|
||||||
otp,
|
|
||||||
verified: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.save(
|
if (target.phone)
|
||||||
entity
|
where.push({
|
||||||
|
phone: target.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByTarget(
|
||||||
|
target: Target
|
||||||
|
) {
|
||||||
|
const where =
|
||||||
|
this.whereForTarget(target);
|
||||||
|
|
||||||
|
if (!where.length) return [];
|
||||||
|
|
||||||
|
// Newest first: a target that somehow overlaps two legacy single-channel
|
||||||
|
// rows should resolve to the most recently issued code, not an arbitrary one.
|
||||||
|
return this.repository.find({
|
||||||
|
where,
|
||||||
|
order: { updatedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTarget(
|
||||||
|
target: Target
|
||||||
|
) {
|
||||||
|
const [
|
||||||
|
newest,
|
||||||
|
] = await this.findAllByTarget(
|
||||||
|
target
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return newest ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Update OTP
|
// Replace OTP (upsert across every channel the target names)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async updateOtp(
|
/**
|
||||||
otpVerification: OtpVerification,
|
* Drop every row this target overlaps and write a single fresh one holding
|
||||||
|
* all its channels.
|
||||||
|
*
|
||||||
|
* `phone` and `email` are each UNIQUE, so a dual-channel send can collide with
|
||||||
|
* up to two pre-existing single-channel rows (say an old signup code on the
|
||||||
|
* phone and a reset code on the email). Merging into one row instead of
|
||||||
|
* updating in place is what keeps that from raising a unique violation, and it
|
||||||
|
* preserves the single-use guarantee: consuming the code deletes one row and
|
||||||
|
* kills every channel it was sent to at once.
|
||||||
|
*
|
||||||
|
* "Last code sent wins" was already the behaviour between any two flows
|
||||||
|
* sharing this table — this only widens it from one channel to all of them.
|
||||||
|
*/
|
||||||
|
async replaceOtp(
|
||||||
|
target: Target,
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
): Promise<{
|
||||||
otpVerification.otp = otp;
|
record: OtpVerification;
|
||||||
|
rotated: boolean;
|
||||||
|
}> {
|
||||||
|
const existing =
|
||||||
|
await this.findAllByTarget(
|
||||||
|
target
|
||||||
|
);
|
||||||
|
|
||||||
otpVerification.verified =
|
if (existing.length) {
|
||||||
false;
|
await this.repository.remove(
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.repository.save(
|
const record =
|
||||||
otpVerification
|
await this.repository.save(
|
||||||
);
|
this.repository.create({
|
||||||
|
phone: target.phone,
|
||||||
|
email: target.email,
|
||||||
|
otp,
|
||||||
|
verified: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
rotated: existing.length > 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -115,8 +175,8 @@ export class OtpRepository {
|
|||||||
// Delete OTP (single-use consume)
|
// Delete OTP (single-use consume)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
|
||||||
// requested for the same number on the next action.
|
// be requested for the same target on the next action.
|
||||||
async deleteOtp(
|
async deleteOtp(
|
||||||
otpVerification: OtpVerification
|
otpVerification: OtpVerification
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
|
|||||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes email targets through untouched', () => {
|
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||||
|
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||||
|
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||||
|
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||||
|
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||||
@@ -21,41 +28,216 @@ describe('normalizeOtpTarget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OtpService — send/verify agree across phone formats', () => {
|
interface FakeRow {
|
||||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
id: string;
|
||||||
// the test proves normalisation makes send and verify collide on one key.
|
phone?: string;
|
||||||
function makeService() {
|
email?: string;
|
||||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
otp: string;
|
||||||
const repo = {
|
updatedAt: Date;
|
||||||
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
|
}
|
||||||
rows.get(t.email ?? t.phone!) ?? null,
|
|
||||||
),
|
|
||||||
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
|
|
||||||
existing.otp = otp;
|
|
||||||
}),
|
|
||||||
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
|
|
||||||
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
|
|
||||||
}),
|
|
||||||
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
|
|
||||||
rows.delete(row.phone ?? row.email!);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
|
||||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new OtpService(repo as never, sms as never, email as never);
|
|
||||||
return { service, rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory stand-in for OtpRepository, mirroring the two properties the service
|
||||||
|
* depends on: rows are matched by OR across every channel named, and a send
|
||||||
|
* replaces all overlapping rows with one row carrying every channel.
|
||||||
|
*/
|
||||||
|
function makeService(
|
||||||
|
transports: {
|
||||||
|
sms?: () => Promise<{ queued: boolean }>;
|
||||||
|
email?: () => Promise<{ queued: boolean }>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
let rows: FakeRow[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||||
|
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
||||||
|
|
||||||
|
const repo = {
|
||||||
|
findByTarget: jest.fn(
|
||||||
|
async (t: { phone?: string; email?: string }) =>
|
||||||
|
rows.filter((row) => matches(row, t))[0] ?? null,
|
||||||
|
),
|
||||||
|
replaceOtp: jest.fn(
|
||||||
|
async (t: { phone?: string; email?: string }, otp: string) => {
|
||||||
|
const overlapping = rows.filter((row) => matches(row, t));
|
||||||
|
rows = rows.filter((row) => !overlapping.includes(row));
|
||||||
|
const record: FakeRow = {
|
||||||
|
id: String(nextId++),
|
||||||
|
...t,
|
||||||
|
otp,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
rows.push(record);
|
||||||
|
return { record, rotated: overlapping.length > 0 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deleteOtp: jest.fn(async (row: FakeRow) => {
|
||||||
|
rows = rows.filter((r) => r !== row);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both clients return `{ queued }` — the service reads it to tell a published
|
||||||
|
// code apart from one the transport silently dropped.
|
||||||
|
const sms = {
|
||||||
|
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
|
||||||
|
};
|
||||||
|
const email = {
|
||||||
|
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
|
||||||
|
};
|
||||||
|
const service = new OtpService(repo as never, sms as never, email as never);
|
||||||
|
return { service, sms, email, rows: () => rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OtpService — send/verify agree across phone formats', () => {
|
||||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ phone: '+251986680099' });
|
await service.sendOtp({ phone: '+251986680099' });
|
||||||
const stored = [...rows.values()][0]!.otp;
|
|
||||||
|
|
||||||
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
|
|
||||||
[...rows.values()][0]!.updatedAt = new Date();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OtpService — dual-channel send', () => {
|
||||||
|
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||||
|
|
||||||
|
it('sends ONE code to both transports', async () => {
|
||||||
|
const { service, sms, email, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
// Same secret on both messages — the user types whichever arrives first.
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: '+251986680099',
|
||||||
|
message: expect.stringContaining(otp),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'user@example.com',
|
||||||
|
text: expect.stringContaining(otp),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// One row, both channels canonicalised.
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({
|
||||||
|
phone: '+251986680099',
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['phone alone', { phone: '0986680099' }],
|
||||||
|
['email alone', { email: 'user@example.com' }],
|
||||||
|
['both', both],
|
||||||
|
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consuming the code via one channel kills the other', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
|
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
||||||
|
|
||||||
|
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||||
|
).rejects.toThrow(/No verification code was requested/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
// A pending signup code on the phone only, then a dual-channel send.
|
||||||
|
await service.sendOtp({ phone: '0986680099' });
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to one channel when the account has only one contact', async () => {
|
||||||
|
const { service, sms, email } = makeService();
|
||||||
|
await service.sendOtp({ phone: '0986680099' });
|
||||||
|
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still succeeds when one transport throws', async () => {
|
||||||
|
const { service, rows } = makeService({
|
||||||
|
sms: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sendOtp(both)).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
delivered: true,
|
||||||
|
});
|
||||||
|
// The code is live and verifiable on the channel that worked.
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the request when every transport throws', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
sms: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
email: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one brute-force budget across both channels', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
|
// Alternating channels must not hand the attacker two independent budgets:
|
||||||
|
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||||
|
for (const target of [
|
||||||
|
{ phone: '0986680099' },
|
||||||
|
{ email: 'user@example.com' },
|
||||||
|
{ phone: '0986680099' },
|
||||||
|
{ email: 'user@example.com' },
|
||||||
|
]) {
|
||||||
|
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||||
|
'Invalid verification code',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||||
|
).rejects.toThrow(/Too many incorrect attempts/);
|
||||||
|
|
||||||
|
// Burned: even the correct code no longer works.
|
||||||
|
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
|
||||||
|
/No verification code was requested/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,10 +8,24 @@ import { OtpRepository } from "./otp.repository";
|
|||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
|
||||||
// Exactly one of phone/email is set — enforced by the controller before it
|
/**
|
||||||
// reaches here.
|
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||||
|
* controller and re-checked here. When BOTH are set the same code is sent to
|
||||||
|
* both and either one can be used to verify it: a user who never receives the
|
||||||
|
* SMS can still finish from their inbox, and vice versa. Callers that resolve
|
||||||
|
* contacts from IAM pass whatever the account actually has, so an account with
|
||||||
|
* only one of the two silently degrades to a single channel.
|
||||||
|
*/
|
||||||
export type OtpTarget = { phone?: string; email?: string };
|
export type OtpTarget = { phone?: string; email?: string };
|
||||||
|
|
||||||
|
/** Which transports a target resolves to, in a stable order for logging. */
|
||||||
|
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||||
|
const channels: Array<"email" | "sms"> = [];
|
||||||
|
if (target.email) channels.push("email");
|
||||||
|
if (target.phone) channels.push("sms");
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
||||||
* up on verify collide regardless of how the number was typed. Without this,
|
* up on verify collide regardless of how the number was typed. Without this,
|
||||||
@@ -19,19 +33,51 @@ export type OtpTarget = { phone?: string; email?: string };
|
|||||||
* a code sent to one is invisible to the others — the send/verify halves must
|
* a code sent to one is invisible to the others — the send/verify halves must
|
||||||
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||||
* Email targets pass through untouched.
|
|
||||||
*/
|
*/
|
||||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
function normalizePhone(rawPhone: string): string {
|
||||||
if (target.email || !target.phone) return target;
|
const raw = rawPhone.trim();
|
||||||
const raw = target.phone.trim();
|
|
||||||
const digits = raw.replace(/[^\d+]/g, '');
|
const digits = raw.replace(/[^\d+]/g, '');
|
||||||
if (digits.startsWith('+')) return { phone: digits };
|
if (digits.startsWith('+')) return digits;
|
||||||
const bare = digits.replace(/^0+/, '');
|
const bare = digits.replace(/^0+/, '');
|
||||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
// it looks like a full international number, else leave as typed.
|
// it looks like a full international number, else leave as typed.
|
||||||
return { phone: digits.length >= 11 ? `+${digits}` : raw };
|
return digits.length >= 11 ? `+${digits}` : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalise every channel present on the target. Each field is normalised
|
||||||
|
* independently — a dual-channel target must end up with both halves in their
|
||||||
|
* canonical form, since verify may arrive naming either one.
|
||||||
|
*/
|
||||||
|
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||||
|
const normalized: OtpTarget = {};
|
||||||
|
|
||||||
|
if (target.email?.trim()) {
|
||||||
|
// Same contract as the phone branch: the string stored on send and the one
|
||||||
|
// looked up on verify must be byte-identical, or the code is invisible to
|
||||||
|
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
|
||||||
|
// or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
|
||||||
|
// trailing space are three different keys for one mailbox. Domains are
|
||||||
|
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
|
||||||
|
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
|
||||||
|
// matching what users expect beats matching the letter of the spec here.
|
||||||
|
normalized.email = target.email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.phone?.trim()) {
|
||||||
|
normalized.phone = normalizePhone(target.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transport's hand-off outcome. Never thrown — collected and reported. */
|
||||||
|
interface DispatchOutcome {
|
||||||
|
channel: "email" | "sms";
|
||||||
|
queued: boolean;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -58,25 +104,36 @@ export class OtpService {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(rawTarget: OtpTarget) {
|
async sendOtp(rawTarget: OtpTarget) {
|
||||||
// Store under the canonical E.164 key so verify (which normalises the same
|
// Store under the canonical keys so verify (which normalises the same way)
|
||||||
// way) always finds this row regardless of how either side typed the number.
|
// always finds this row regardless of how either side typed the number.
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
|
const channels = channelsOf(target);
|
||||||
|
const label = this.targetLabel(target);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
if (channels.length === 0) {
|
||||||
|
throw new BadRequestException("phone or email is required");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// The verification code is generated server-side — never supplied by the
|
||||||
// caller — so the OTP stays a secret known only to the server and the
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
// recipient of the SMS/email.
|
// recipient of the SMS/email. ONE code covers every channel: the user
|
||||||
|
// types whichever message reaches them first.
|
||||||
const otp = this.generateOtp();
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing row for this channel
|
// Replaces every row this target overlaps with, so a dual-channel send
|
||||||
const existing = await this.otpRepository.findByTarget(target);
|
// leaves exactly one row holding both halves — verify then resolves the
|
||||||
|
// same row whichever channel it is given.
|
||||||
|
const { rotated } = await this.otpRepository.replaceOtp(target, otp);
|
||||||
|
|
||||||
// update existing otp
|
// `rotate` means a code already existed for this target and was replaced —
|
||||||
if (existing) {
|
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||||
await this.otpRepository.updateOtp(existing, otp);
|
// typing its code will fail against the row; this line is how that shows up
|
||||||
} else {
|
// in the log rather than as an unexplained "invalid OTP" report.
|
||||||
// create new otp
|
this.logger.log(
|
||||||
await this.otpRepository.createOtp(target, otp);
|
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||||
}
|
);
|
||||||
|
|
||||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||||
// here let an attacker wipe the per-target guess budget just by calling
|
// here let an attacker wipe the per-target guess budget just by calling
|
||||||
@@ -86,40 +143,180 @@ export class OtpService {
|
|||||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||||
// in the codebase yet.
|
// in the codebase yet.
|
||||||
|
|
||||||
if (target.email) {
|
// Fan out to every channel the target has, independently: one transport
|
||||||
// send email (queued to RabbitMQ via the shared Email service)
|
// being down must not suppress the other, which is the whole point of
|
||||||
await this.emailClient.sendEmail({
|
// sending to both. Each helper swallows its own failure so a rejected
|
||||||
to: target.email,
|
// email publish still leaves the SMS delivered (and the code valid).
|
||||||
subject: "Your EDR Freight verification code",
|
const outcomes = (
|
||||||
text: `Your verification code is ${otp}`,
|
await Promise.all([
|
||||||
});
|
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||||
} else {
|
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
])
|
||||||
await this.smsClient.sendSms({
|
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||||
to: target.phone as string,
|
|
||||||
message: `Your verification code is ${otp}`,
|
for (const outcome of outcomes) {
|
||||||
});
|
this.logger.log(
|
||||||
|
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||||
|
outcome.queued
|
||||||
|
} latencyMs=${Date.now() - startedAt}${
|
||||||
|
outcome.error ? ` error=${outcome.error}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
// Every channel threw. Nothing can arrive and there is no partial success
|
||||||
|
// to preserve — fail the request the way a single-channel send always did.
|
||||||
|
if (outcomes.every((outcome) => outcome.error)) {
|
||||||
|
throw new Error(
|
||||||
|
outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both clients report hand-off, not delivery — capture it rather than
|
||||||
|
// discarding it, so "delivered=false" is distinguishable from a code that
|
||||||
|
// was published fine and lost downstream at the carrier.
|
||||||
|
const delivered = outcomes.some((outcome) => outcome.queued);
|
||||||
|
|
||||||
|
if (!delivered) {
|
||||||
|
// The row is committed and we are about to answer "OTP sent successfully",
|
||||||
|
// but nothing left this process. Without this line the only symptom is a
|
||||||
|
// user who never receives a code — indistinguishable from carrier loss,
|
||||||
|
// and the misleading success response makes it look like our side worked.
|
||||||
|
this.logger.error(
|
||||||
|
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||||
|
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
|
} — no transport reported hand-off; no code will arrive for this send`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: this logs a live credential in cleartext. Anyone with read
|
||||||
|
// access to the log stream can complete a password reset or a contract
|
||||||
|
// signature for the address on the same line. Kept deliberately (log
|
||||||
|
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
|
||||||
|
// is ever revisited, gate on an env flag rather than deleting the line, so
|
||||||
|
// dev keeps its workflow.
|
||||||
|
this.logger.log(`OTP send for ${label}: ${otp}`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
|
// Distinguishes "we published it" from "the transport is a no-op". The
|
||||||
|
// HTTP response shape is unchanged; the controller drops this field.
|
||||||
|
delivered,
|
||||||
|
|
||||||
message: "OTP sent successfully",
|
message: "OTP sent successfully",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||||
error instanceof Error ? error.message : String(error)
|
Date.now() - startedAt
|
||||||
}`,
|
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
throw new BadRequestException("Failed to send OTP");
|
throw new BadRequestException("Failed to send OTP");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish to one transport, converting a throw into a reported outcome. A
|
||||||
|
* broker error on one channel must not abort the other — with dual-channel
|
||||||
|
* sends the user still has a working route to the code.
|
||||||
|
*/
|
||||||
|
private async dispatchEmail(
|
||||||
|
email: string,
|
||||||
|
otp: string,
|
||||||
|
): Promise<DispatchOutcome> {
|
||||||
|
try {
|
||||||
|
const { queued } = await this.emailClient.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Your EDR Freight verification code",
|
||||||
|
text: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
return { channel: "email", queued };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
channel: "email",
|
||||||
|
queued: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
|
||||||
|
private async dispatchSms(
|
||||||
|
phone: string,
|
||||||
|
otp: string,
|
||||||
|
): Promise<DispatchOutcome> {
|
||||||
|
try {
|
||||||
|
const { queued } = await this.smsClient.sendSms({
|
||||||
|
to: phone,
|
||||||
|
message: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
return { channel: "sms", queued };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
channel: "sms",
|
||||||
|
queued: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation key shared by every `otp.*` line for one target, so a send and
|
||||||
|
* its later verify can be joined with a single grep. The raw values are used
|
||||||
|
* because the code itself is already logged in cleartext above — hashing the
|
||||||
|
* address while printing the credential next to it would buy nothing.
|
||||||
|
*/
|
||||||
|
private targetLabel(target: OtpTarget): string {
|
||||||
|
return (
|
||||||
|
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line per verify exit path. `result` is a closed set — ok | invalid |
|
||||||
|
* expired | exhausted | not_found — so failures can be counted by reason
|
||||||
|
* instead of inferred from error strings that the frontend also depends on.
|
||||||
|
*/
|
||||||
|
private logVerify(
|
||||||
|
target: OtpTarget,
|
||||||
|
mode: "simple" | "action",
|
||||||
|
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
|
||||||
|
detail?: string,
|
||||||
|
) {
|
||||||
|
const line = `otp.verify channels=${channelsOf(target).join(
|
||||||
|
"+",
|
||||||
|
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||||
|
detail ? ` ${detail}` : ""
|
||||||
|
}`;
|
||||||
|
if (result === "ok") this.logger.log(line);
|
||||||
|
else this.logger.warn(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No code for this target" phrased for whichever channels were named. A
|
||||||
|
* dual-channel caller gets a neutral message — naming one channel would be
|
||||||
|
* misleading when the code went to both.
|
||||||
|
*/
|
||||||
|
private notFoundMessage(target: OtpTarget, requested: boolean): string {
|
||||||
|
const channels = channelsOf(target);
|
||||||
|
if (channels.length !== 1) {
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this account"
|
||||||
|
: "No verification code found for this account";
|
||||||
|
}
|
||||||
|
if (target.email) {
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this email"
|
||||||
|
: "Email address not found";
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this phone"
|
||||||
|
: "Phone number not found";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify OTP
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -128,23 +325,36 @@ export class OtpService {
|
|||||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||||
// when verify is called with 09… (or any equivalent form).
|
// when verify is called with 09… (or any equivalent form).
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
// find the channel's row
|
// Matches on ANY channel the caller named, so a code sent to both phone and
|
||||||
|
// email verifies whichever one the user quotes back.
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
// not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
// No row for this target. Most often a normalisation mismatch or a code
|
||||||
target.email ? "Email address not found" : "Phone number not found",
|
// that was already consumed/burned — not necessarily a caller who never
|
||||||
);
|
// asked.
|
||||||
|
this.logVerify(target, "simple", "not_found");
|
||||||
|
throw new BadRequestException(this.notFoundMessage(target, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key the attempt budget on the ROW, not on the channels the caller happened
|
||||||
|
// to name — otherwise guessing alternately by phone and by email would hand
|
||||||
|
// an attacker two independent budgets against the same code.
|
||||||
|
const key = otpData.id;
|
||||||
|
|
||||||
// TTL: reuse the same age window as the hardened action verifier — an old
|
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||||
// code can't be verified.
|
// code can't be verified.
|
||||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"expired",
|
||||||
|
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
@@ -157,24 +367,36 @@ export class OtpService {
|
|||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"exhausted",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Too many incorrect attempts. Request a new code.",
|
"Too many incorrect attempts. Request a new code.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.actionAttempts.set(key, attempts);
|
this.actionAttempts.set(key, attempts);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"simple",
|
||||||
|
"invalid",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException("Invalid OTP");
|
throw new BadRequestException("Invalid OTP");
|
||||||
}
|
}
|
||||||
|
|
||||||
// single-use: consume the code on success so it can't be replayed.
|
// single-use: consume the code on success so it can't be replayed. One row
|
||||||
|
// covers every channel it was sent to, so this kills all of them at once.
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message: target.email
|
message: "Verification successful",
|
||||||
? "Email verified successfully"
|
|
||||||
: "Phone verified successfully",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +418,6 @@ export class OtpService {
|
|||||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||||
private readonly actionAttempts = new Map<string, number>();
|
private readonly actionAttempts = new Map<string, number>();
|
||||||
|
|
||||||
private targetKey(target: OtpTarget): string {
|
|
||||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyOtpForAction(
|
async verifyOtpForAction(
|
||||||
rawTarget: OtpTarget,
|
rawTarget: OtpTarget,
|
||||||
otp: string,
|
otp: string,
|
||||||
@@ -207,22 +425,21 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
this.logVerify(target, "action", "not_found");
|
||||||
target.email
|
throw new BadRequestException(this.notFoundMessage(target, true));
|
||||||
? "No verification code was requested for this email"
|
|
||||||
: "No verification code was requested for this phone",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row-keyed for the same reason as verifyOtp: one code, one budget.
|
||||||
|
const key = otpData.id;
|
||||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
|
|
||||||
if (ageMs > ttlMs) {
|
if (ageMs > ttlMs) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
|
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
@@ -235,18 +452,31 @@ export class OtpService {
|
|||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"exhausted",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Too many incorrect attempts. Request a new code.",
|
"Too many incorrect attempts. Request a new code.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.actionAttempts.set(key, attempts);
|
this.actionAttempts.set(key, attempts);
|
||||||
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"invalid",
|
||||||
|
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException("Invalid verification code");
|
throw new BadRequestException("Invalid verification code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// single-use: consume on success
|
// single-use: consume on success
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ export class ApprovalRulesController {
|
|||||||
return this.service.findChain(flag === 'true');
|
return this.service.findChain(flag === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('position-types')
|
||||||
|
@RuleEngineView('approval-rules')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'IAM position types to choose from when building an approval chain',
|
||||||
|
})
|
||||||
|
listPositionTypes() {
|
||||||
|
return this.service.listPositionTypes();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineManage('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
|
||||||
|
|
||||||
export class CreateApprovalRuleDto {
|
export class CreateApprovalRuleDto {
|
||||||
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
|
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -19,9 +17,12 @@ export class CreateApprovalRuleDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
insertAfterId?: string;
|
insertAfterId?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'IAM position-type key required to action this step (see GET /approval-rules/position-types)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(30)
|
@MaxLength(64)
|
||||||
requiredRole!: string;
|
requiredRole!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
|
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
|
||||||
@@ -29,9 +30,11 @@ export class CreateApprovalRuleDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
actionLabel!: string;
|
actionLabel!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'IAM position-type key explicitly blocked from actioning this step',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(30)
|
@MaxLength(64)
|
||||||
blocksRole?: string;
|
blocksRole?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity {
|
|||||||
@Column({ name: 'step_order', type: 'smallint' })
|
@Column({ name: 'step_order', type: 'smallint' })
|
||||||
stepOrder!: number;
|
stepOrder!: number;
|
||||||
|
|
||||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||||
requiredRole!: string;
|
requiredRole!: string;
|
||||||
|
|
||||||
@Column({ name: 'action_label', type: 'varchar', length: 50 })
|
@Column({ name: 'action_label', type: 'varchar', length: 50 })
|
||||||
actionLabel!: string;
|
actionLabel!: string;
|
||||||
|
|
||||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||||
blocksRole?: string | null;
|
blocksRole?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
|||||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||||
hasWarehouse!: boolean;
|
hasWarehouse!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers need a reach stacker or gantry, so only the equipped facilities
|
||||||
|
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
|
||||||
|
* everywhere.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'handles_container', type: 'boolean', default: true })
|
||||||
|
handlesContainer!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
||||||
|
handlesBulk!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||||
equipmentNotes?: string | null;
|
equipmentNotes?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
|
|||||||
@Index(['country'])
|
@Index(['country'])
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
export class Yard extends BaseEntity {
|
export class Yard extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import { RuleEngineService } from './rule-engine.service';
|
|||||||
|
|
||||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
|
||||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
|
||||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||||
@@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
BookingContainer,
|
BookingContainer,
|
||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
]),
|
]),
|
||||||
// Team notifications for the priority-rule approval workflow.
|
// Team notifications for the priority-rule approval workflow.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
|
||||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||||
import {
|
import {
|
||||||
@@ -23,15 +22,10 @@ import {
|
|||||||
IRatesRepository,
|
IRatesRepository,
|
||||||
RATES_REPOSITORY,
|
RATES_REPOSITORY,
|
||||||
} from './interfaces/rates.repository.interface';
|
} from './interfaces/rates.repository.interface';
|
||||||
import {
|
|
||||||
IApprovalRulesRepository,
|
|
||||||
APPROVAL_RULES_REPOSITORY,
|
|
||||||
} from './interfaces/approval-rules.repository.interface';
|
|
||||||
import {
|
import {
|
||||||
IShippingLinesRepository,
|
IShippingLinesRepository,
|
||||||
SHIPPING_LINES_REPOSITORY,
|
SHIPPING_LINES_REPOSITORY,
|
||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
|
||||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
@@ -124,8 +118,6 @@ export class RuleEngineService {
|
|||||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||||
@Inject(RATES_REPOSITORY)
|
@Inject(RATES_REPOSITORY)
|
||||||
private readonly ratesRepo: IRatesRepository,
|
private readonly ratesRepo: IRatesRepository,
|
||||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
|
||||||
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
|
||||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||||
private readonly shippingLinesRepo: IShippingLinesRepository,
|
private readonly shippingLinesRepo: IShippingLinesRepository,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@@ -390,79 +382,6 @@ export class RuleEngineService {
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
|
||||||
*/
|
|
||||||
async ensureDefaultApprovalRules(): Promise<void> {
|
|
||||||
for (const flag of [false, true] as const) {
|
|
||||||
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
|
||||||
if (existing.length > 0) continue;
|
|
||||||
|
|
||||||
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
|
||||||
(r) => r.requiresDirectorApproval === flag,
|
|
||||||
);
|
|
||||||
for (const row of rows) {
|
|
||||||
await this.approvalRulesRepo.create({
|
|
||||||
requiresDirectorApproval: row.requiresDirectorApproval,
|
|
||||||
stepOrder: row.stepOrder,
|
|
||||||
requiredRole: row.requiredRole,
|
|
||||||
actionLabel: row.actionLabel,
|
|
||||||
blocksRole: row.blocksRole,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
|
||||||
*/
|
|
||||||
async instantiateApprovalSteps(
|
|
||||||
bookingId: string,
|
|
||||||
options: {
|
|
||||||
freightType: 'CONTAINER' | 'BULK';
|
|
||||||
cargoTypeId?: string | null;
|
|
||||||
},
|
|
||||||
): Promise<BookingApprovalStep[]> {
|
|
||||||
await this.ensureDefaultApprovalRules();
|
|
||||||
|
|
||||||
let requiresDirectorApproval = false;
|
|
||||||
|
|
||||||
if (options.cargoTypeId) {
|
|
||||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
|
||||||
if (!cargoType) {
|
|
||||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
|
||||||
}
|
|
||||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
|
||||||
requiresDirectorApproval,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (chain.length === 0) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
|
||||||
const steps: BookingApprovalStep[] = [];
|
|
||||||
|
|
||||||
for (const rule of chain) {
|
|
||||||
const step = stepRepo.create({
|
|
||||||
bookingId,
|
|
||||||
approvalRuleId: rule.id,
|
|
||||||
stepOrder: rule.stepOrder,
|
|
||||||
requiredRole: rule.requiredRole,
|
|
||||||
blocksRole: rule.blocksRole ?? null,
|
|
||||||
status: 'PENDING',
|
|
||||||
});
|
|
||||||
steps.push(await stepRepo.save(step));
|
|
||||||
}
|
|
||||||
|
|
||||||
return steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot only the rates used in a booking's final price.
|
* Snapshot only the rates used in a booking's final price.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PaginatedResponse } from '@edr/types';
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
@@ -17,6 +18,7 @@ export class ApprovalRulesService {
|
|||||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||||
private readonly repository: IApprovalRulesRepository,
|
private readonly repository: IApprovalRulesRepository,
|
||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List approval rules — standard paginated envelope with server-side search. */
|
/** List approval rules — standard paginated envelope with server-side search. */
|
||||||
@@ -29,6 +31,21 @@ export class ApprovalRulesService {
|
|||||||
return this.repository.findChainForCargo(requiresDirectorApproval);
|
return this.repository.findChainForCargo(requiresDirectorApproval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM position types, for the approval-step role picker. A chain step names
|
||||||
|
* the position type that must approve it, so this is the vocabulary an admin
|
||||||
|
* builds chains from. Read straight from the shared `iam` schema — the same
|
||||||
|
* pattern the freight API already uses for `iam.users`.
|
||||||
|
*/
|
||||||
|
async listPositionTypes(): Promise<Array<{ label: string; value: string }>> {
|
||||||
|
const rows = await this.dataSource.query<
|
||||||
|
Array<{ key: string; label: string }>
|
||||||
|
>(`SELECT key, COALESCE(name->>'en', key) AS label
|
||||||
|
FROM iam.position_types
|
||||||
|
ORDER BY 2`);
|
||||||
|
return rows.map((row) => ({ label: row.label, value: row.key }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Get an approval rule by ID. */
|
/** Get an approval rule by ID. */
|
||||||
async findById(id: string): Promise<ApprovalRule> {
|
async findById(id: string): Promise<ApprovalRule> {
|
||||||
const entity = await this.repository.findById(id);
|
const entity = await this.repository.findById(id);
|
||||||
|
|||||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
|||||||
hasFacility: boolean;
|
hasFacility: boolean;
|
||||||
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||||
hasWarehouse: boolean;
|
hasWarehouse: boolean;
|
||||||
|
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||||
|
handlesContainer: boolean;
|
||||||
|
handlesBulk: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which yards can handle cargo, and how.
|
* Which yards can handle cargo, and what kind.
|
||||||
*
|
*
|
||||||
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
||||||
* `yard_facilities` record says whether it also stores cargo. Facilities without a
|
* `yard_facilities` record says what it can actually do — whether it stores cargo
|
||||||
* warehouse move cargo on and off the train and nothing more — no storage, no
|
* (storage/demurrage), and which freight types its equipment can lift. Containers
|
||||||
* demurrage. This is the single resolver the journey and handling flows use, so
|
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
|
||||||
* they can't drift on what a facility is.
|
* bulk is handled at all five.
|
||||||
|
*
|
||||||
|
* This is the single resolver the journey and handling flows use, so they can't
|
||||||
|
* drift on what a facility is or what it can lift.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YardFacilitiesService {
|
export class YardFacilitiesService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
private readonly SELECT = `
|
||||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
SELECT y.id AS "yardId",
|
||||||
const [row]: Array<{
|
y.code AS "yardCode",
|
||||||
yardId: string;
|
y.label AS "yardLabel",
|
||||||
yardCode: string | null;
|
y.has_facility AS "hasFacility",
|
||||||
yardLabel: string | null;
|
f.has_warehouse AS "hasWarehouse",
|
||||||
hasFacility: boolean;
|
f.handles_container AS "handlesContainer",
|
||||||
hasWarehouse: boolean | null;
|
f.handles_bulk AS "handlesBulk"
|
||||||
}> = await this.dataSource.query(
|
FROM freight.yards y
|
||||||
`SELECT y.id AS "yardId",
|
LEFT JOIN freight.yard_facilities f
|
||||||
y.code AS "yardCode",
|
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||||
y.label AS "yardLabel",
|
|
||||||
y.has_facility AS "hasFacility",
|
private toInfo(row: {
|
||||||
f.has_warehouse AS "hasWarehouse"
|
yardId: string;
|
||||||
FROM freight.yards y
|
yardCode: string | null;
|
||||||
LEFT JOIN freight.yard_facilities f
|
yardLabel: string | null;
|
||||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
hasFacility: boolean;
|
||||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
hasWarehouse: boolean | null;
|
||||||
[yardId],
|
handlesContainer: boolean | null;
|
||||||
);
|
handlesBulk: boolean | null;
|
||||||
if (!row) return null;
|
}): YardFacilityInfo {
|
||||||
|
// No facility record means no capability, whatever the flag says.
|
||||||
|
const hasFacility = Boolean(row.hasFacility);
|
||||||
return {
|
return {
|
||||||
yardId: row.yardId,
|
yardId: row.yardId,
|
||||||
yardCode: row.yardCode,
|
yardCode: row.yardCode,
|
||||||
yardLabel: row.yardLabel,
|
yardLabel: row.yardLabel,
|
||||||
hasFacility: Boolean(row.hasFacility),
|
hasFacility,
|
||||||
// No facility record means no warehouse, whatever the flag says.
|
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||||
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
||||||
|
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||||
|
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||||
|
const [row] = await this.dataSource.query(
|
||||||
|
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||||
|
[yardId],
|
||||||
|
);
|
||||||
|
return row ? this.toInfo(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||||
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||||
const rows: Array<{
|
const rows = await this.dataSource.query(
|
||||||
yardId: string;
|
`${this.SELECT}
|
||||||
yardCode: string | null;
|
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
|
||||||
yardLabel: string | null;
|
|
||||||
hasFacility: boolean;
|
|
||||||
hasWarehouse: boolean | null;
|
|
||||||
}> = await this.dataSource.query(
|
|
||||||
`SELECT y.id AS "yardId",
|
|
||||||
y.code AS "yardCode",
|
|
||||||
y.label AS "yardLabel",
|
|
||||||
y.has_facility AS "hasFacility",
|
|
||||||
f.has_warehouse AS "hasWarehouse"
|
|
||||||
FROM freight.yards y
|
|
||||||
LEFT JOIN freight.yard_facilities f
|
|
||||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
|
||||||
WHERE y.deleted_at IS NULL
|
|
||||||
AND y.is_active = true
|
|
||||||
AND y.has_facility = true
|
|
||||||
ORDER BY y.display_order ASC, y.label ASC`,
|
ORDER BY y.display_order ASC, y.label ASC`,
|
||||||
);
|
);
|
||||||
return rows.map((r) => ({
|
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||||
yardId: r.yardId,
|
}
|
||||||
yardCode: r.yardCode,
|
|
||||||
yardLabel: r.yardLabel,
|
/**
|
||||||
hasFacility: true,
|
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||||
hasWarehouse: Boolean(r.hasWarehouse),
|
* so callers can't get it subtly wrong.
|
||||||
}));
|
*/
|
||||||
|
canHandleFreight(
|
||||||
|
facility: YardFacilityInfo | null,
|
||||||
|
freightType: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!facility?.hasFacility) return false;
|
||||||
|
return String(freightType).toUpperCase() === 'CONTAINER'
|
||||||
|
? facility.handlesContainer
|
||||||
|
: facility.handlesBulk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class YardsService {
|
|||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
async create(dto: CreateYardDto): Promise<Yard> {
|
async create(dto: CreateYardDto): Promise<Yard> {
|
||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label).slice(0, 40);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
|
|
||||||
@@ -58,9 +58,19 @@ export class YardsService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete a yard. */
|
/**
|
||||||
|
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||||
|
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||||
|
* same name can be created later without tripping UQ_yards_code, which spans
|
||||||
|
* soft-deleted rows too.
|
||||||
|
*/
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
const yard = await this.findById(id);
|
||||||
|
const suffix = `@${Date.now()}`;
|
||||||
|
await this.repository.update(id, {
|
||||||
|
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
|
||||||
|
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
|
||||||
|
});
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
|
||||||
|
|
||||||
|
/** Multipart field name carrying chat files. */
|
||||||
|
export const SUPPORT_ATTACHMENT_FIELD = "attachments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multer-level caps for the chat send routes.
|
||||||
|
*
|
||||||
|
* These duplicate the checks in `SupportChatService.assertSendable` on purpose,
|
||||||
|
* and are not a substitute for them: Multer stops reading the socket once a part
|
||||||
|
* exceeds `fileSize`, so an oversized upload is cut off mid-stream instead of
|
||||||
|
* being buffered into memory and rejected after the fact. The service-level
|
||||||
|
* check is what produces the readable error message and covers callers that
|
||||||
|
* don't come through this interceptor.
|
||||||
|
*/
|
||||||
|
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||||
|
limits: {
|
||||||
|
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||||
|
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||||
|
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||||
|
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
export class ListMessagesQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||||
|
"of messages immediately OLDER than the cursor. Omit for the newest page.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
before?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
minimum: 1,
|
||||||
|
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||||
|
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class SendMessageDto implements ISendSupportMessageDto {
|
export class SendMessageDto implements ISendSupportMessageDto {
|
||||||
@ApiProperty({ description: "Message text." })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Message text. Optional only when the request carries attachments — the " +
|
||||||
|
"service rejects a message that is neither text nor files.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(4000)
|
@MaxLength(4000)
|
||||||
body!: string;
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
|
|||||||
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||||
authorName?: string | null;
|
authorName?: string | null;
|
||||||
|
|
||||||
@Column({ name: "body", type: "text" })
|
/**
|
||||||
body!: string;
|
* NULL for an attachment-only message. Nullable rather than "" so the absence
|
||||||
|
* of text is representable instead of guessed at; the DTO maps NULL → "".
|
||||||
|
*/
|
||||||
|
@Column({ name: "body", type: "text", nullable: true })
|
||||||
|
body?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyset cursor for paging a thread backwards from newest.
|
||||||
|
*
|
||||||
|
* The cursor is just a message id. The sort key is the pair `(created_at, id)` —
|
||||||
|
* two messages can share a timestamp, and a cursor on a non-unique key either
|
||||||
|
* re-serves or skips the tied rows — but the *timestamp half is never sent over
|
||||||
|
* the wire*, because it cannot survive the trip.
|
||||||
|
*
|
||||||
|
* `support_messages.created_at` is `timestamptz(6)`; a JS `Date` holds only
|
||||||
|
* milliseconds, so the value TypeORM hands back is already truncated. Encoding
|
||||||
|
* that into the cursor and comparing against it would silently skip every row
|
||||||
|
* sharing the cursor's millisecond but earlier within it (`.254100` is not
|
||||||
|
* `< .254000`) — those rows would never appear on any page. Sending the id alone
|
||||||
|
* and letting Postgres look the real `(created_at, id)` up keeps the comparison
|
||||||
|
* at full precision on the server, where it was never lossy.
|
||||||
|
*
|
||||||
|
* Opaque on purpose (base64): clients must treat it as a token, so the sort key
|
||||||
|
* can change without a contract change.
|
||||||
|
*
|
||||||
|
* The passenger API's twin encodes a timestamp because its column is
|
||||||
|
* `TIMESTAMP(3)` — millisecond, matching JS exactly — so it has no such loss.
|
||||||
|
* The two formats are deliberately NOT interchangeable; each app reads only its
|
||||||
|
* own cursors.
|
||||||
|
*/
|
||||||
|
export function encodeMessageCursor(id: string): string {
|
||||||
|
return Buffer.from(id, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||||
|
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||||
|
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||||
|
*/
|
||||||
|
export function decodeMessageCursor(raw: string): string {
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
id = Buffer.from(raw, "base64url").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException("Malformed pagination cursor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id goes into a parameterized query, but validate the shape anyway: a
|
||||||
|
// non-uuid can only be a mangled cursor, and failing loudly here beats an
|
||||||
|
// empty page that reads as "start of conversation".
|
||||||
|
if (
|
||||||
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("Malformed pagination cursor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated download for chat attachments.
|
||||||
|
*
|
||||||
|
* This exists instead of reusing `GET /files/:fileId` because that route streams
|
||||||
|
* any file to any authenticated caller who knows its UUID — fine-ish for a
|
||||||
|
* booking document the caller already had a link to, not fine for chat, where
|
||||||
|
* one customer guessing another's file id would be a cross-tenant leak. That
|
||||||
|
* route now refuses `support_message` files outright and points here.
|
||||||
|
*
|
||||||
|
* Inline previews still use the short-lived signed URL on the message DTO — a
|
||||||
|
* browser `<img>` can't send a Bearer token. This route is for explicit
|
||||||
|
* downloads and for clients that would rather stream through the API.
|
||||||
|
*/
|
||||||
|
@ApiTags("support-chat")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("support/attachments")
|
||||||
|
export class SupportAttachmentController {
|
||||||
|
constructor(
|
||||||
|
private readonly files: FilesService,
|
||||||
|
private readonly chat: SupportChatService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(":fileId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Download a support chat attachment",
|
||||||
|
description:
|
||||||
|
"Streams the file only if the caller is backoffice staff or belongs to the " +
|
||||||
|
"company that owns the thread the attachment was posted in.",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "download",
|
||||||
|
required: false,
|
||||||
|
description: "Set to 1/true to force a download instead of inline preview.",
|
||||||
|
})
|
||||||
|
async download(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@Query("download") download: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const record = await this.files.findById(fileId);
|
||||||
|
|
||||||
|
// Don't let this route become a second general-purpose file endpoint: it can
|
||||||
|
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
|
||||||
|
// no reason to confirm the id exists).
|
||||||
|
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
|
||||||
|
throw new NotFoundException(`File ${fileId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await this.chat.canUserAccessMessage(
|
||||||
|
record.resourceId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This attachment belongs to another company's conversation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stream } = await this.files.streamById(fileId);
|
||||||
|
const forceDownload = download === "1" || download === "true";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", record.mimeType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
|
||||||
|
);
|
||||||
|
// Private only — this response is scoped to one caller's authorization, so a
|
||||||
|
// shared cache must never reuse it for the next person asking.
|
||||||
|
res.setHeader("Cache-Control", "private, max-age=300");
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SupportAuthorRole,
|
||||||
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -8,14 +11,22 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
} from "./attachment-upload.options";
|
||||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||||
import { SendMessageDto } from "./dto/send-message.dto";
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversations/:id/messages")
|
@Get("conversations/:id/messages")
|
||||||
@ApiOperation({ summary: "List messages in a thread" })
|
@ApiOperation({
|
||||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
summary: "List messages in a thread (newest page first)",
|
||||||
return this.service.getMessages(id);
|
description:
|
||||||
|
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||||
|
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||||
|
"back through history. `nextCursor: null` means the thread's start.",
|
||||||
|
})
|
||||||
|
messages(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query() query: ListMessagesQueryDto,
|
||||||
|
) {
|
||||||
|
return this.service.getMessages(id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("conversations/:id/messages")
|
@Post("conversations/:id/messages")
|
||||||
@ApiOperation({ summary: "Reply as an agent" })
|
@UseInterceptors(
|
||||||
|
FilesInterceptor(
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Accepts multipart (text + files) or plain JSON (text only) — Multer passes
|
||||||
|
// non-multipart requests straight through, so existing JSON clients are
|
||||||
|
// unaffected.
|
||||||
|
@ApiConsumes("multipart/form-data", "application/json")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
body: { type: "string" },
|
||||||
|
attachments: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Reply as an agent, optionally with attachments" })
|
||||||
send(
|
send(
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() body: SendMessageDto,
|
@Body() body: SendMessageDto,
|
||||||
|
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
|
return this.service.sendAsAgent(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
body.body,
|
||||||
|
attachments ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("conversations/:id/read")
|
@Post("conversations/:id/read")
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
SupportAuthorRole,
|
||||||
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
} from "./attachment-upload.options";
|
||||||
|
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||||
import { SendMessageDto } from "./dto/send-message.dto";
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
@@ -29,17 +46,55 @@ export class SupportChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversation/messages")
|
@Get("conversation/messages")
|
||||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
@ApiOperation({
|
||||||
messages(@CurrentUser() user: AuthUserPayload) {
|
summary: "Messages in my company's support thread (newest page first)",
|
||||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
description:
|
||||||
|
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||||
|
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||||
|
"back through history. `nextCursor: null` means the thread's start.",
|
||||||
|
})
|
||||||
|
messages(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Query() query: ListMessagesQueryDto,
|
||||||
|
) {
|
||||||
|
return this.service.getCustomerMessages(resolveAuthUserId(user), query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("conversation/messages")
|
@Post("conversation/messages")
|
||||||
@ApiOperation({
|
@UseInterceptors(
|
||||||
summary: "Send a message as the customer, opening the thread if needed",
|
FilesInterceptor(
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data", "application/json")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
body: { type: "string" },
|
||||||
|
attachments: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
|
@ApiOperation({
|
||||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
summary:
|
||||||
|
"Send a message as the customer (optionally with attachments), opening the thread if needed",
|
||||||
|
})
|
||||||
|
send(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Body() body: SendMessageDto,
|
||||||
|
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.service.sendAsCustomer(
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
body.body,
|
||||||
|
attachments ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("conversation/read")
|
@Post("conversation/read")
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { SupportAttachmentController } from "./support-attachment.controller";
|
||||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||||
import { SupportChatController } from "./support-chat.controller";
|
import { SupportChatController } from "./support-chat.controller";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// WsAuthService — reused handshake authentication for the gateway.
|
// WsAuthService — reused handshake authentication for the gateway.
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
// FilesService — chat attachments are stored as polymorphic file records.
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
SupportChatController,
|
||||||
|
SupportChatAgentController,
|
||||||
|
SupportAttachmentController,
|
||||||
],
|
],
|
||||||
controllers: [SupportChatController, SupportChatAgentController],
|
|
||||||
providers: [
|
providers: [
|
||||||
SupportConversationRepository,
|
SupportConversationRepository,
|
||||||
SupportMessageRepository,
|
SupportMessageRepository,
|
||||||
SupportChatGateway,
|
SupportChatGateway,
|
||||||
SupportChatService,
|
SupportChatService,
|
||||||
],
|
],
|
||||||
|
// FilesController's ownership check for `support_message` files defers to this
|
||||||
|
// service — see SupportAttachmentAccess.
|
||||||
|
exports: [SupportChatService],
|
||||||
})
|
})
|
||||||
export class SupportChatModule {}
|
export class SupportChatModule {}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
|
isSupportAttachmentAllowed,
|
||||||
SendSupportMessageResult,
|
SendSupportMessageResult,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SUPPORT_ATTACHMENT_RESOURCE,
|
||||||
|
SupportAttachmentDto,
|
||||||
SupportAuthorRole,
|
SupportAuthorRole,
|
||||||
SupportConversationDto,
|
SupportConversationDto,
|
||||||
SupportConversationListResult,
|
SupportConversationListResult,
|
||||||
SupportMessageDto,
|
SupportMessageDto,
|
||||||
|
SupportMessageListResult,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { QueryFailedError } from "typeorm";
|
import { QueryFailedError } from "typeorm";
|
||||||
|
|
||||||
|
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||||
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import {
|
||||||
|
ListMessagesQueryDto,
|
||||||
|
SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||||
|
} from "./dto/list-messages-query.dto";
|
||||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||||
import { SupportMessageRepository } from "./support-message.repository";
|
import { SupportMessageRepository } from "./support-message.repository";
|
||||||
@@ -30,6 +45,9 @@ interface CustomerContext {
|
|||||||
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||||
const PG_UNIQUE_VIOLATION = "23505";
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
/** Stand-in preview for a message that is nothing but files. */
|
||||||
|
const ATTACHMENT_ONLY_PREVIEW = "📎";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportChatService {
|
export class SupportChatService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -38,6 +56,8 @@ export class SupportChatService {
|
|||||||
private readonly gateway: SupportChatGateway,
|
private readonly gateway: SupportChatGateway,
|
||||||
private readonly externalProfiles: ExternalProfileRepository,
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly files: FilesService,
|
||||||
|
private readonly backoffice: BackofficeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---- customer (portal) -------------------------------------------------
|
// ---- customer (portal) -------------------------------------------------
|
||||||
@@ -51,7 +71,9 @@ export class SupportChatService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SupportConversationDto | null> {
|
): Promise<SupportConversationDto | null> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
|
ctx.companyId,
|
||||||
|
);
|
||||||
if (!conversation) return null;
|
if (!conversation) return null;
|
||||||
const unread = await this.messages.unreadCountsByConversation(
|
const unread = await this.messages.unreadCountsByConversation(
|
||||||
[conversation.id],
|
[conversation.id],
|
||||||
@@ -63,17 +85,23 @@ export class SupportChatService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
|
async getCustomerMessages(
|
||||||
|
userId: string,
|
||||||
|
query: ListMessagesQueryDto = {},
|
||||||
|
): Promise<SupportMessageListResult> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
if (!conversation) return [];
|
ctx.companyId,
|
||||||
return this.listMessages(conversation.id);
|
);
|
||||||
|
if (!conversation) return { items: [], nextCursor: null };
|
||||||
|
return this.listMessages(conversation.id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send as the customer, opening the thread if this is the first message. */
|
/** Send as the customer, opening the thread if this is the first message. */
|
||||||
async sendAsCustomer(
|
async sendAsCustomer(
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SendSupportMessageResult> {
|
): Promise<SendSupportMessageResult> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.getOrCreate(
|
const conversation = await this.getOrCreate(
|
||||||
@@ -87,16 +115,19 @@ export class SupportChatService {
|
|||||||
SupportAuthorRole.CUSTOMER,
|
SupportAuthorRole.CUSTOMER,
|
||||||
body,
|
body,
|
||||||
ctx.authorName,
|
ctx.authorName,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
conversation: this.toConversationDto(updated, 0),
|
conversation: this.toConversationDto(updated, 0),
|
||||||
message: this.toMessageDto(message),
|
message: message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
|
ctx.companyId,
|
||||||
|
);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
customerLastReadAt: new Date(),
|
customerLastReadAt: new Date(),
|
||||||
@@ -145,7 +176,8 @@ export class SupportChatService {
|
|||||||
async sendAsAgent(
|
async sendAsAgent(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SupportMessageDto> {
|
): Promise<SupportMessageDto> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
const { message } = await this.appendMessage(
|
const { message } = await this.appendMessage(
|
||||||
@@ -153,8 +185,10 @@ export class SupportChatService {
|
|||||||
userId,
|
userId,
|
||||||
SupportAuthorRole.AGENT,
|
SupportAuthorRole.AGENT,
|
||||||
body,
|
body,
|
||||||
|
undefined,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return this.toMessageDto(message);
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAgentRead(
|
async markAgentRead(
|
||||||
@@ -171,18 +205,56 @@ export class SupportChatService {
|
|||||||
// ---- shared ------------------------------------------------------------
|
// ---- shared ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
|
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
|
||||||
* company owns it (portal route); omit for agents, who see every thread.
|
* to enforce that the caller's company owns it (portal route); omit for
|
||||||
|
* agents, who see every thread.
|
||||||
*/
|
*/
|
||||||
async getMessages(
|
async getMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
query: ListMessagesQueryDto = {},
|
||||||
asCustomerUserId?: string,
|
asCustomerUserId?: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
): Promise<SupportMessageListResult> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
if (asCustomerUserId) {
|
if (asCustomerUserId) {
|
||||||
await this.assertCustomerOwns(conversation, asCustomerUserId);
|
await this.assertCustomerOwns(conversation, asCustomerUserId);
|
||||||
}
|
}
|
||||||
return this.listMessages(conversationId);
|
return this.listMessages(conversationId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May `userId` read the message that a chat attachment hangs off? Backstop for
|
||||||
|
* the file-download route, which otherwise streams any file to any
|
||||||
|
* authenticated caller who knows its UUID.
|
||||||
|
*
|
||||||
|
* Backoffice staff see every thread (they work a shared inbox); a portal user
|
||||||
|
* sees only their own company's. Fails **closed** — an unresolvable message,
|
||||||
|
* conversation, or staff list denies rather than falls through, since the
|
||||||
|
* caller uses this to decide whether to hand over raw bytes.
|
||||||
|
*/
|
||||||
|
async canUserAccessMessage(
|
||||||
|
messageId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const message = await this.messages.findById(messageId);
|
||||||
|
if (!message) return false;
|
||||||
|
const conversation = await this.conversations.findById(
|
||||||
|
message.conversationId,
|
||||||
|
);
|
||||||
|
if (!conversation) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
|
||||||
|
if (staffIds.includes(userId)) return true;
|
||||||
|
} catch {
|
||||||
|
// Staff lookup is best-effort for room-joining in the gateway, but here it
|
||||||
|
// gates bytes: on failure fall through to the (stricter) company check
|
||||||
|
// rather than assuming staff.
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.externalProfiles.findByUserId(userId);
|
||||||
|
return Boolean(
|
||||||
|
profile?.companyId && profile.companyId === conversation.companyId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unreadCount(
|
async unreadCount(
|
||||||
@@ -237,29 +309,86 @@ export class SupportChatService {
|
|||||||
|
|
||||||
private async listMessages(
|
private async listMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
query: ListMessagesQueryDto,
|
||||||
const rows = await this.messages.listByConversation(conversationId);
|
): Promise<SupportMessageListResult> {
|
||||||
return rows.map((m) => this.toMessageDto(m));
|
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||||
|
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||||
|
|
||||||
|
// The repo returns newest-first and over-fetches by one to probe for a
|
||||||
|
// further page.
|
||||||
|
const rows = await this.messages.listByConversation(
|
||||||
|
conversationId,
|
||||||
|
limit,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
|
||||||
|
const oldest = page[page.length - 1];
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
|
||||||
|
|
||||||
|
// Flip to oldest-first so the client can prepend a page as one block.
|
||||||
|
const items = await this.toMessageDtos([...page].reverse());
|
||||||
|
return { items, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
/**
|
||||||
|
* Persist a message (plus any attachments), bump the conversation's
|
||||||
|
* denormalized fields, emit live.
|
||||||
|
*
|
||||||
|
* Files are validated *before* the row is written: a rejected upload should
|
||||||
|
* leave no message behind, and a half-uploaded batch is worse than none.
|
||||||
|
*/
|
||||||
private async appendMessage(
|
private async appendMessage(
|
||||||
conversation: SupportConversation,
|
conversation: SupportConversation,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: SupportAuthorRole,
|
role: SupportAuthorRole,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
authorName?: string | null,
|
authorName?: string | null,
|
||||||
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
|
attachments: Express.Multer.File[] = [],
|
||||||
|
): Promise<{
|
||||||
|
conversation: SupportConversation;
|
||||||
|
message: SupportMessageDto;
|
||||||
|
}> {
|
||||||
|
const text = (body ?? "").trim();
|
||||||
|
this.assertSendable(text, attachments);
|
||||||
|
|
||||||
const message = await this.messages.create({
|
const message = await this.messages.create({
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
authorUserId: userId,
|
authorUserId: userId,
|
||||||
authorRole: role,
|
authorRole: role,
|
||||||
authorName: authorName ?? null,
|
authorName: authorName ?? null,
|
||||||
body,
|
// NULL, not "", so "this message has no text" is representable rather than
|
||||||
|
// inferred. The DTO flattens it back to "" for rendering.
|
||||||
|
body: text || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The row has to exist before the files, since each one is stored against
|
||||||
|
// `resourceId = message.id`. That leaves a window: if a upload fails here,
|
||||||
|
// the message is already committed. Undo it rather than leave the thread
|
||||||
|
// with a permanently blank bubble — there is no delete flow, so an orphan
|
||||||
|
// would be unremovable, and an attachment-only message that lost its files
|
||||||
|
// has no content at all.
|
||||||
|
let stored: FileRecord[];
|
||||||
|
try {
|
||||||
|
stored = await Promise.all(
|
||||||
|
attachments.map((file) =>
|
||||||
|
this.files.upload({
|
||||||
|
resourceId: message.id,
|
||||||
|
resource: SUPPORT_ATTACHMENT_RESOURCE,
|
||||||
|
code: "attachment",
|
||||||
|
file,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await this.messages.softDelete(message.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
conversation.lastMessageAt = message.createdAt;
|
conversation.lastMessageAt = message.createdAt;
|
||||||
conversation.lastMessagePreview = body.slice(0, 280);
|
conversation.lastMessagePreview = this.buildPreview(text, stored);
|
||||||
conversation.lastMessageAuthorRole = role;
|
conversation.lastMessageAuthorRole = role;
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
lastMessageAt: conversation.lastMessageAt,
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
@@ -267,13 +396,55 @@ export class SupportChatService {
|
|||||||
lastMessageAuthorRole: role,
|
lastMessageAuthorRole: role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const messageDto = await this.toMessageDto(message, stored);
|
||||||
const dto = this.toConversationDto(conversation, 0);
|
const dto = this.toConversationDto(conversation, 0);
|
||||||
this.gateway.emitMessage(
|
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
|
||||||
conversation.companyId,
|
return { conversation, message: messageDto };
|
||||||
dto,
|
}
|
||||||
this.toMessageDto(message),
|
|
||||||
);
|
/**
|
||||||
return { conversation, message };
|
* Guard the chat-specific upload rules. These are tighter than
|
||||||
|
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
|
||||||
|
* which exist for scanned business documents — chat files are pushed at
|
||||||
|
* another human, so the allowlist is narrower and SVG is excluded outright.
|
||||||
|
*/
|
||||||
|
private assertSendable(
|
||||||
|
text: string,
|
||||||
|
attachments: Express.Multer.File[],
|
||||||
|
): void {
|
||||||
|
if (!text && attachments.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A message needs text or at least one attachment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of attachments) {
|
||||||
|
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unsupported attachment type: ${file.mimetype}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`"${file.originalname}" exceeds the ${
|
||||||
|
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||||
|
}MB attachment limit.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbox preview line — falls back to the filenames when there's no text. */
|
||||||
|
private buildPreview(text: string, attachments: FileRecord[]): string {
|
||||||
|
if (text) return text.slice(0, 280);
|
||||||
|
if (attachments.length === 1) {
|
||||||
|
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||||
|
}
|
||||||
|
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildListResult(
|
private async buildListResult(
|
||||||
@@ -320,7 +491,9 @@ export class SupportChatService {
|
|||||||
): Promise<CustomerContext> {
|
): Promise<CustomerContext> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
if (conversation.companyId !== ctx.companyId) {
|
if (conversation.companyId !== ctx.companyId) {
|
||||||
throw new ForbiddenException("This conversation belongs to another company.");
|
throw new ForbiddenException(
|
||||||
|
"This conversation belongs to another company.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -353,15 +526,58 @@ export class SupportChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toMessageDto(m: SupportMessage): SupportMessageDto {
|
/** Hydrate + map a page of messages, batching the attachment lookup. */
|
||||||
|
private async toMessageDtos(
|
||||||
|
rows: SupportMessage[],
|
||||||
|
): Promise<SupportMessageDto[]> {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const grouped = await this.files.findByResourceIdsGrouped(
|
||||||
|
rows.map((r) => r.id),
|
||||||
|
SUPPORT_ATTACHMENT_RESOURCE,
|
||||||
|
);
|
||||||
|
return Promise.all(
|
||||||
|
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toMessageDto(
|
||||||
|
m: SupportMessage,
|
||||||
|
attachments: FileRecord[],
|
||||||
|
): Promise<SupportMessageDto> {
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
conversationId: m.conversationId,
|
conversationId: m.conversationId,
|
||||||
authorUserId: m.authorUserId,
|
authorUserId: m.authorUserId,
|
||||||
authorRole: m.authorRole,
|
authorRole: m.authorRole,
|
||||||
authorName: m.authorName ?? null,
|
authorName: m.authorName ?? null,
|
||||||
body: m.body,
|
body: m.body ?? "",
|
||||||
|
attachments: attachments.map((a) => this.toAttachmentDto(a)),
|
||||||
createdAt: new Date(m.createdAt).toISOString(),
|
createdAt: new Date(m.createdAt).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the browser fetches the bytes: the API's own ownership-checked stream
|
||||||
|
* route, NOT a presigned MinIO URL.
|
||||||
|
*
|
||||||
|
* Presigned object URLs are not reachable from the browser in this deployment
|
||||||
|
* — the same reason every other file in the app streams through
|
||||||
|
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
|
||||||
|
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
|
||||||
|
* attachments stream through `GET /api/support/attachments/:id`, which runs the
|
||||||
|
* same-company / staff ownership check before serving a byte.
|
||||||
|
*
|
||||||
|
* A root-relative path; the web app prepends its API origin. The `<img>` sends
|
||||||
|
* the `auth-token` cookie automatically (same-site across dev ports), which is
|
||||||
|
* how the guard authenticates a request that can't carry a bearer header.
|
||||||
|
*/
|
||||||
|
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
|
||||||
|
return {
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
size: f.size,
|
||||||
|
url: `/api/support/attachments/${f.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user