mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
fix: normalized the region and logged the otp properly
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsString, IsOptional, IsEmail, MaxLength, 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';
|
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||||
@@ -138,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
@@ -40,8 +47,10 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
|||||||
rows.delete(row.phone ?? row.email!);
|
rows.delete(row.phone ?? row.email!);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
// Both clients return `{ queued }` — the service reads it to tell a published
|
||||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
// code apart from one the transport silently dropped.
|
||||||
|
const sms = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
|
||||||
|
const email = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||||
const service = new OtpService(repo as never, sms as never, email as never);
|
const service = new OtpService(repo as never, sms as never, email as never);
|
||||||
return { service, rows };
|
return { service, rows };
|
||||||
}
|
}
|
||||||
@@ -58,4 +67,16 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
|||||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||||
).resolves.toEqual({ success: true });
|
).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 ' });
|
||||||
|
const stored = [...rows.values()][0]!.otp;
|
||||||
|
|
||||||
|
[...rows.values()][0]!.updatedAt = new Date();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, stored),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,18 @@ export type OtpTarget = { phone?: string; email?: string };
|
|||||||
* Email targets pass through untouched.
|
* Email targets pass through untouched.
|
||||||
*/
|
*/
|
||||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||||
if (target.email || !target.phone) return target;
|
if (target.email) {
|
||||||
|
// Same contract as the phone branch below: 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.
|
||||||
|
return { email: target.email.trim().toLowerCase() };
|
||||||
|
}
|
||||||
|
if (!target.phone) return target;
|
||||||
const raw = target.phone.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 { phone: digits };
|
||||||
@@ -61,6 +72,9 @@ export class OtpService {
|
|||||||
// Store under the canonical E.164 key so verify (which normalises the same
|
// Store under the canonical E.164 key so verify (which normalises the same
|
||||||
// way) always finds this row regardless of how either side typed the number.
|
// way) always finds this row regardless of how either side typed the number.
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
|
const channel = target.email ? "email" : "sms";
|
||||||
|
const label = this.targetLabel(target);
|
||||||
|
const startedAt = Date.now();
|
||||||
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
|
||||||
@@ -78,6 +92,14 @@ export class OtpService {
|
|||||||
await this.otpRepository.createOtp(target, otp);
|
await this.otpRepository.createOtp(target, otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `rotate` means a code already existed for this target and was replaced —
|
||||||
|
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||||
|
// typing its code will fail against the row; this line is how that shows up
|
||||||
|
// in the log rather than as an unexplained "invalid OTP" report.
|
||||||
|
this.logger.log(
|
||||||
|
`otp.issue channel=${channel} target=${label} action=${existing ? "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
|
||||||
// /otp/send between guesses. The counter is cleared only when the code is
|
// /otp/send between guesses. The counter is cleared only when the code is
|
||||||
@@ -86,40 +108,97 @@ 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) {
|
// Both clients report hand-off, not delivery — capture it rather than
|
||||||
// send email (queued to RabbitMQ via the shared Email service)
|
// discarding it, so "queued=false" is distinguishable from a code that was
|
||||||
await this.emailClient.sendEmail({
|
// published fine and lost downstream at the carrier.
|
||||||
to: target.email,
|
const { queued } = target.email
|
||||||
subject: "Your EDR Freight verification code",
|
? await this.emailClient.sendEmail({
|
||||||
text: `Your verification code is ${otp}`,
|
to: target.email,
|
||||||
});
|
subject: "Your EDR Freight verification code",
|
||||||
} else {
|
text: `Your verification code is ${otp}`,
|
||||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
})
|
||||||
await this.smsClient.sendSms({
|
: await this.smsClient.sendSms({
|
||||||
to: target.phone as string,
|
to: target.phone as string,
|
||||||
message: `Your verification code is ${otp}`,
|
message: `Your verification code is ${otp}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`otp.dispatch channel=${channel} target=${label} queued=${queued} latencyMs=${
|
||||||
|
Date.now() - startedAt
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!queued) {
|
||||||
|
// 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 channel=${channel} target=${label} rabbitmqEnabled=${
|
||||||
|
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
|
} — transport reported no 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 ${target.email ?? target.phone}: ${otp}`);
|
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${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: queued,
|
||||||
|
|
||||||
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 channel=${channel} 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation key shared by every `otp.*` line for one address, so a send and
|
||||||
|
* its later verify can be joined with a single grep. The raw target is 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 ?? "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 channel=${
|
||||||
|
target.email ? "email" : "sms"
|
||||||
|
} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||||
|
detail ? ` ${detail}` : ""
|
||||||
|
}`;
|
||||||
|
if (result === "ok") this.logger.log(line);
|
||||||
|
else this.logger.warn(line);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify OTP
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -134,6 +213,9 @@ export class OtpService {
|
|||||||
|
|
||||||
// not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
|
// No row for this key. Most often a normalisation mismatch or a code that
|
||||||
|
// was already consumed/burned — not necessarily a caller who never asked.
|
||||||
|
this.logVerify(target, "simple", "not_found");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
target.email ? "Email address not found" : "Phone number not found",
|
target.email ? "Email address not found" : "Phone number not found",
|
||||||
);
|
);
|
||||||
@@ -145,6 +227,12 @@ export class OtpService {
|
|||||||
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,17 +245,30 @@ 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.
|
||||||
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,
|
||||||
@@ -210,6 +311,7 @@ export class OtpService {
|
|||||||
const key = this.targetKey(target);
|
const key = this.targetKey(target);
|
||||||
|
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
|
this.logVerify(target, "action", "not_found");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
target.email
|
target.email
|
||||||
? "No verification code was requested for this email"
|
? "No verification code was requested for this email"
|
||||||
@@ -223,6 +325,7 @@ 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", "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 +338,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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -13,12 +14,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
|
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
@@ -675,12 +677,24 @@ export default function CompanyProfileForm({
|
|||||||
Address Information
|
Address Information
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<TextInput
|
<Controller
|
||||||
label="Region"
|
name="region"
|
||||||
placeholder="Tigray"
|
control={control}
|
||||||
required
|
render={({ field }) => (
|
||||||
error={errors.region?.message}
|
<Select
|
||||||
{...register("region")}
|
label="Region"
|
||||||
|
placeholder="Select region"
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||||
|
error={errors.region?.message}
|
||||||
|
// "" (unresolved eTrade value, or a legacy row whose
|
||||||
|
// region isn't in the list) must read as "nothing picked".
|
||||||
|
value={field.value || null}
|
||||||
|
onChange={(v) => field.onChange(v ?? "")}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Zone"
|
label="Zone"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||||
|
|
||||||
import { isValidPhone } from "@/components/PhoneField";
|
import { isValidPhone } from "@/components/PhoneField";
|
||||||
|
|
||||||
@@ -35,7 +36,9 @@ export const onboardingSchema = z.object({
|
|||||||
renewedTo: z.string().optional(),
|
renewedTo: z.string().optional(),
|
||||||
// Address fields are user-entered and required (the registration/license
|
// Address fields are user-entered and required (the registration/license
|
||||||
// fields above are read-only confirmations pulled from eTrade).
|
// fields above are read-only confirmations pulled from eTrade).
|
||||||
region: z.string().min(1, "Region is required"),
|
// Region is a closed set; zone/woreda/kebele stay free text until the platform
|
||||||
|
// has an authoritative dataset of Ethiopian zones/woredas/kebeles.
|
||||||
|
region: z.enum(ETHIOPIAN_REGIONS, { message: "Region is required" }),
|
||||||
zone: z.string().min(1, "Zone is required"),
|
zone: z.string().min(1, "Zone is required"),
|
||||||
woreda: z.string().min(1, "Woreda is required"),
|
woreda: z.string().min(1, "Woreda is required"),
|
||||||
kebele: z.string().min(1, "Kebele is required"),
|
kebele: z.string().min(1, "Kebele is required"),
|
||||||
|
|||||||
97
packages/types/src/freight/ethiopian-regions.catalog.ts
Normal file
97
packages/types/src/freight/ethiopian-regions.catalog.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Ethiopia's first-level administrative divisions: regional states plus the two
|
||||||
|
* chartered city administrations.
|
||||||
|
*
|
||||||
|
* NOTE — this list churned recently and should be re-confirmed with EDR before
|
||||||
|
* being treated as final. SNNPR was progressively dissolved: Sidama split off in
|
||||||
|
* 2020, South West Ethiopia Peoples' in 2021, and the remainder became Central
|
||||||
|
* Ethiopia and South Ethiopia in 2023. Records created before those splits may
|
||||||
|
* still carry "SNNPR" or "Southern Nations".
|
||||||
|
*/
|
||||||
|
export const ETHIOPIAN_REGIONS = [
|
||||||
|
"Addis Ababa",
|
||||||
|
"Afar",
|
||||||
|
"Amhara",
|
||||||
|
"Benishangul-Gumuz",
|
||||||
|
"Central Ethiopia",
|
||||||
|
"Dire Dawa",
|
||||||
|
"Gambela",
|
||||||
|
"Harari",
|
||||||
|
"Oromia",
|
||||||
|
"Sidama",
|
||||||
|
"Somali",
|
||||||
|
"South Ethiopia",
|
||||||
|
"South West Ethiopia Peoples'",
|
||||||
|
"Tigray",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EthiopianRegion = (typeof ETHIOPIAN_REGIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-text region values seen in the wild — eTrade returns uncoded uppercase
|
||||||
|
* strings, and pre-dropdown rows were hand-typed. Keys are lowercased and
|
||||||
|
* whitespace-collapsed before lookup, so only spelling/naming variants belong
|
||||||
|
* here, not case variants.
|
||||||
|
*/
|
||||||
|
const REGION_ALIASES: Record<string, EthiopianRegion> = {
|
||||||
|
// Spelling and transliteration variants.
|
||||||
|
oromoia: "Oromia",
|
||||||
|
oromiya: "Oromia",
|
||||||
|
oromina: "Oromia",
|
||||||
|
addisabeba: "Addis Ababa",
|
||||||
|
"addis abeba": "Addis Ababa",
|
||||||
|
"dire dawa city": "Dire Dawa",
|
||||||
|
"benishangul gumuz": "Benishangul-Gumuz",
|
||||||
|
"benshangul-gumuz": "Benishangul-Gumuz",
|
||||||
|
"beneshangul-gumuz": "Benishangul-Gumuz",
|
||||||
|
gambella: "Gambela",
|
||||||
|
tigrai: "Tigray",
|
||||||
|
tigre: "Tigray",
|
||||||
|
"south west ethiopia": "South West Ethiopia Peoples'",
|
||||||
|
"south west ethiopia peoples": "South West Ethiopia Peoples'",
|
||||||
|
"southwest ethiopia": "South West Ethiopia Peoples'",
|
||||||
|
|
||||||
|
// eTrade frequently returns a ZONE in the Region slot. These map the zone back
|
||||||
|
// to its parent region; the zone itself is preserved in the `zone` field.
|
||||||
|
"eastern tigray": "Tigray",
|
||||||
|
"western tigray": "Tigray",
|
||||||
|
"southern tigray": "Tigray",
|
||||||
|
"central tigray": "Tigray",
|
||||||
|
"north wollo": "Amhara",
|
||||||
|
"south wollo": "Amhara",
|
||||||
|
"east gojjam": "Amhara",
|
||||||
|
"west gojjam": "Amhara",
|
||||||
|
"north shewa": "Amhara",
|
||||||
|
"east shewa": "Oromia",
|
||||||
|
"west shewa": "Oromia",
|
||||||
|
"arsi": "Oromia",
|
||||||
|
"bale": "Oromia",
|
||||||
|
"borena": "Oromia",
|
||||||
|
"jimma": "Oromia",
|
||||||
|
"gamo": "South Ethiopia",
|
||||||
|
"gofa": "South Ethiopia",
|
||||||
|
"wolayita": "South Ethiopia",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collapse whitespace and case so lookups are tolerant of formatting noise. */
|
||||||
|
function canonicalKey(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an arbitrary region string (eTrade payload, legacy row, hand entry) onto a
|
||||||
|
* canonical region. Returns `null` when it cannot be resolved — callers should
|
||||||
|
* leave the field empty and let the user pick, rather than persisting a guess.
|
||||||
|
*/
|
||||||
|
export function normalizeRegion(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): EthiopianRegion | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const key = canonicalKey(value);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
const exact = ETHIOPIAN_REGIONS.find((r) => canonicalKey(r) === key);
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
return REGION_ALIASES[key] ?? null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export * from "./overview";
|
|||||||
export * from "./etrade";
|
export * from "./etrade";
|
||||||
export * from "./contracts";
|
export * from "./contracts";
|
||||||
export * from "./clearance-files.catalog";
|
export * from "./clearance-files.catalog";
|
||||||
|
export * from "./ethiopian-regions.catalog";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./booking-window-ws";
|
export * from "./booking-window-ws";
|
||||||
export * from "./support-chat";
|
export * from "./support-chat";
|
||||||
|
|||||||
Reference in New Issue
Block a user