mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #833 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
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 { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
@@ -166,6 +167,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
DropdownSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
@@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
const [items, total] = await qb
|
||||
.orderBy('company.name', 'ASC')
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IsString, IsNotEmpty, Length } from "class-validator";
|
||||
import { IsString, IsNotEmpty } from "class-validator";
|
||||
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||
|
||||
export class FetchETradeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin!: string;
|
||||
}
|
||||
|
||||
@@ -47,4 +47,19 @@ export class ListCompaniesQueryDto {
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@@ -34,7 +36,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -137,10 +139,14 @@ export class UpdateProfileDto {
|
||||
@MaxLength(50)
|
||||
renewedTo?: string;
|
||||
|
||||
// Zone/woreda/kebele below stay free text: there is no authoritative dataset
|
||||
// of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns
|
||||
// them uncoded. Only region is a closed set today.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
region?: string;
|
||||
@IsIn(ETHIOPIAN_REGIONS as unknown as string[], {
|
||||
message: "region must be a recognised Ethiopian region",
|
||||
})
|
||||
region?: EthiopianRegion;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
|
||||
@Injectable()
|
||||
@@ -108,7 +109,11 @@ export class ETradeService {
|
||||
renewedFrom: businessInfo.RenewedFrom,
|
||||
renewalDate: businessInfo.RenewalDate,
|
||||
renewedTo: businessInfo.RenewedTo,
|
||||
region: businessInfo.AddressInfo?.Region || "",
|
||||
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
||||
// Region slot. Map it onto the canonical list; an unresolved value yields
|
||||
// "" so the form asks the user to pick rather than failing validation on
|
||||
// save with a value they never typed.
|
||||
region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "",
|
||||
zone: businessInfo.AddressInfo?.Zone || "",
|
||||
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||
kebele: businessInfo.AddressInfo?.Kebele || "",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ETHIOPIAN_REGIONS, normalizeRegion } from '@edr/types';
|
||||
|
||||
/**
|
||||
* normalizeRegion lives in @edr/types (no jest there), but it exists to keep
|
||||
* eTrade autofill from feeding UpdateProfileDto a region its @IsIn will reject.
|
||||
* That contract is an API concern, so it is guarded here.
|
||||
*/
|
||||
describe('normalizeRegion', () => {
|
||||
it('passes through every canonical region unchanged', () => {
|
||||
for (const region of ETHIOPIAN_REGIONS) {
|
||||
expect(normalizeRegion(region)).toBe(region);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ADDIS ABABA', 'Addis Ababa'],
|
||||
['Addis ababa', 'Addis Ababa'],
|
||||
[' addis ababa ', 'Addis Ababa'],
|
||||
['oromoia', 'Oromia'],
|
||||
['OROMIYA', 'Oromia'],
|
||||
['gambella', 'Gambela'],
|
||||
['TIGRAI', 'Tigray'],
|
||||
['benishangul gumuz', 'Benishangul-Gumuz'],
|
||||
])('resolves the variant %s', (input, expected) => {
|
||||
expect(normalizeRegion(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('maps a zone name in the region slot back to its parent region', () => {
|
||||
// eTrade's own placeholder data does this — "EASTERN TIGRAY" is a zone.
|
||||
expect(normalizeRegion('EASTERN TIGRAY')).toBe('Tigray');
|
||||
expect(normalizeRegion('North Wollo')).toBe('Amhara');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a city, not a region', 'Arba Minch'],
|
||||
['unknown text', 'Nowhere Land'],
|
||||
['empty', ''],
|
||||
['whitespace only', ' '],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('returns null for %s rather than guessing', (_label, input) => {
|
||||
expect(normalizeRegion(input as string | null | undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('never returns a value outside the canonical set', () => {
|
||||
const samples = ['ADDIS ABABA', 'oromoia', 'EASTERN TIGRAY', 'garbage', ''];
|
||||
for (const s of samples) {
|
||||
const out = normalizeRegion(s);
|
||||
if (out !== null) {
|
||||
expect(ETHIOPIAN_REGIONS).toContain(out);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
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";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { SendEmailDto } from "./dtos/email.dto";
|
||||
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||
|
||||
@Injectable()
|
||||
export class EmailClientService implements OnApplicationBootstrap {
|
||||
@@ -33,19 +34,34 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.emailClient.emit("send-email", {
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
const queued = await publishConfirmed(
|
||||
this.emailClient,
|
||||
"send-email",
|
||||
{
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||
// delivery — the consumer and the SMTP hop are downstream and invisible here.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state for the health endpoint. `null` means the broker client did
|
||||
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||
*/
|
||||
get brokerConnected(): boolean | null {
|
||||
if (!this.enabled) return false;
|
||||
return isBrokerConnected(this.emailClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
||||
import { isBrokerConnected, publishConfirmed } from "./broker.util";
|
||||
|
||||
@Injectable()
|
||||
export class SmsClientService implements OnApplicationBootstrap {
|
||||
@@ -14,7 +15,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@Inject("SMS_SERVICE")
|
||||
private smsClient: ClientProxy,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
|
||||
@@ -26,7 +27,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.log("connected to SMS service");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error happened at SMS service", err);
|
||||
this.logger.error("Error happened at SMS service", err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,34 +36,61 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.smsClient.emit("send-sms", {
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
const queued = await publishConfirmed(
|
||||
this.smsClient,
|
||||
"send-sms",
|
||||
{
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
// Publisher-confirmed: RabbitMQ has taken ownership of the message. Still NOT
|
||||
// delivery — the consumer, the SMS gateway and the carrier are all downstream
|
||||
// of this and invisible from here.
|
||||
this.logger.log(
|
||||
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
|
||||
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||
this.logger.warn(
|
||||
`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`,
|
||||
);
|
||||
return { queued: false };
|
||||
}
|
||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||
this.smsClient.emit("ozeking-bulk-sms", {
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
const messages = (dto.messages ?? []).map((m) => ({
|
||||
to: m.to,
|
||||
text: m.message,
|
||||
from: m.from,
|
||||
}));
|
||||
const queued = await publishConfirmed(
|
||||
this.smsClient,
|
||||
"ozeking-bulk-sms",
|
||||
{
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
this.logger.log(
|
||||
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
|
||||
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
|
||||
);
|
||||
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||
return { queued: true };
|
||||
return { queued };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state for the health endpoint. `null` means the broker client did
|
||||
* not expose its manager — reported as "unknown" rather than assumed healthy.
|
||||
*/
|
||||
get brokerConnected(): boolean | null {
|
||||
if (!this.enabled) return false;
|
||||
return isBrokerConnected(this.smsClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ export class OtpController {
|
||||
@Body("email")
|
||||
email?: string
|
||||
) {
|
||||
return this.otpService.sendOtp(toTarget(phone, email));
|
||||
// `delivered` stays server-side: this route is @Public(), and whether our
|
||||
// broker accepted the publish is infrastructure state an anonymous caller has
|
||||
// no need for. It is on the `otp.dispatch` log line instead.
|
||||
const { success, message } = await this.otpService.sendOtp(
|
||||
toTarget(phone, email)
|
||||
);
|
||||
return { success, message };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('passes email targets through untouched', () => {
|
||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||
});
|
||||
|
||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
@@ -40,8 +47,10 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
||||
rows.delete(row.phone ?? row.email!);
|
||||
}),
|
||||
};
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
// 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().mockResolvedValue({ queued: true }) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, rows };
|
||||
}
|
||||
@@ -58,4 +67,16 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||
).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.
|
||||
*/
|
||||
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 digits = raw.replace(/[^\d+]/g, '');
|
||||
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
|
||||
// way) always finds this row regardless of how either side typed the number.
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const channel = target.email ? "email" : "sms";
|
||||
const label = this.targetLabel(target);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
@@ -78,6 +92,14 @@ export class OtpService {
|
||||
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
|
||||
// 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
|
||||
@@ -86,40 +108,97 @@ export class OtpService {
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
});
|
||||
} else {
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
// Both clients report hand-off, not delivery — capture it rather than
|
||||
// discarding it, so "queued=false" is distinguishable from a code that was
|
||||
// published fine and lost downstream at the carrier.
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
})
|
||||
: await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
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}`);
|
||||
return {
|
||||
success: true,
|
||||
|
||||
// Distinguishes "we published it" from "the transport is a no-op". The
|
||||
// HTTP response shape is unchanged; the controller drops this field.
|
||||
delivered: queued,
|
||||
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
`otp.dispatch.failed channel=${channel} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new BadRequestException("Failed to send OTP");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,6 +213,9 @@ export class OtpService {
|
||||
|
||||
// not found
|
||||
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(
|
||||
target.email ? "Email address not found" : "Phone number not found",
|
||||
);
|
||||
@@ -145,6 +227,12 @@ export class OtpService {
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"expired",
|
||||
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -157,17 +245,30 @@ export class OtpService {
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// single-use: consume the code on success so it can't be replayed.
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -210,6 +311,7 @@ export class OtpService {
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
this.logVerify(target, "action", "not_found");
|
||||
throw new BadRequestException(
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
@@ -223,6 +325,7 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -235,18 +338,31 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
@@ -696,6 +697,7 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route path="um/set-password" element={<SetPassword />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { api } from "./http";
|
||||
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
|
||||
import type {
|
||||
AuthTokens,
|
||||
AuthUser,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
LoginResponse,
|
||||
ResetTicket,
|
||||
SetPasswordPayload,
|
||||
} from "./types";
|
||||
|
||||
export const loginRequest = async (payload: {
|
||||
email: string;
|
||||
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
|
||||
const response = await api.get<AuthUser>("/me");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// The three calls below drive the unauthenticated forgot-password flow.
|
||||
// Responses under /api/auth are *flattened* by the API's response
|
||||
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||
|
||||
export const requestPasswordResetRequest = async (
|
||||
payload: ForgotPasswordRequestPayload,
|
||||
) => {
|
||||
await api.post("/auth/forgot-password/request", payload);
|
||||
};
|
||||
|
||||
export const verifyPasswordResetOtpRequest = async (
|
||||
payload: ForgotPasswordVerifyPayload,
|
||||
) => {
|
||||
const response = await api.post<ResetTicket>(
|
||||
"/auth/forgot-password/verify",
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
|
||||
* Carries its own userId/verificationCode and never touches the session.
|
||||
*/
|
||||
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
|
||||
await api.patch("/auth/set-password", payload);
|
||||
};
|
||||
|
||||
@@ -58,6 +58,33 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
/** The channel a password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ForgotPasswordRequestPayload {
|
||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||
identifier: string;
|
||||
channel: ResetChannel;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
// Additional types for Matrix form test
|
||||
export interface User {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||
|
||||
export type OtpChannel = "phone" | "email";
|
||||
|
||||
export const OTP_LENGTH = 6;
|
||||
|
||||
export interface OtpChannelSelectProps {
|
||||
value: OtpChannel;
|
||||
onChange: (channel: OtpChannel) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Phone/email toggle deciding where the verification code is sent. */
|
||||
export function OtpChannelSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label = "Send verification code via",
|
||||
}: OtpChannelSelectProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
{label}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OtpChannelStepProps {
|
||||
channel: OtpChannel;
|
||||
/** Raw email or phone the code went to; masked before display. */
|
||||
target: string;
|
||||
value: string;
|
||||
onChange: (otp: string) => void;
|
||||
onVerify: () => void;
|
||||
onBack: () => void;
|
||||
onResend: () => void;
|
||||
/** Seconds until resend is allowed; 0 enables the button. */
|
||||
resendIn: number;
|
||||
sending: boolean;
|
||||
verifying: boolean;
|
||||
error: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "enter the code we sent you" stage. Shared by signup and the
|
||||
* forgot-password flow — both send through the same `/api/otp/*` service.
|
||||
*/
|
||||
export default function OtpChannelStep({
|
||||
channel,
|
||||
target,
|
||||
value,
|
||||
onChange,
|
||||
onVerify,
|
||||
onBack,
|
||||
onResend,
|
||||
resendIn,
|
||||
sending,
|
||||
verifying,
|
||||
error,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
}: OtpChannelStepProps) {
|
||||
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
||||
const busy = sending || verifying;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
|
||||
{description ?? "Enter it to continue."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={OTP_LENGTH}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={value}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || value.trim().length !== OTP_LENGTH}
|
||||
onClick={onVerify}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || busy}
|
||||
onClick={onResend}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { passwordRequirements } from "@/utils/passwordSchema";
|
||||
|
||||
export interface PasswordChecklistProps {
|
||||
/** The current password value; the checklist hides itself when empty. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Live pass/fail list of the password rules, shown under a password field. */
|
||||
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(value);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Seconds a user must wait before another OTP can be requested. */
|
||||
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
|
||||
* than wall-clock arithmetic, so it needs no Date.now().
|
||||
*/
|
||||
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) return;
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [secondsLeft]);
|
||||
|
||||
return {
|
||||
secondsLeft,
|
||||
start: () => setSecondsLeft(seconds),
|
||||
reset: () => setSecondsLeft(0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
requestPasswordResetRequest,
|
||||
resetPasswordRequest,
|
||||
verifyPasswordResetOtpRequest,
|
||||
} from "@/auth/api";
|
||||
import type { ResetTicket } from "@/auth/types";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type Stage = "identify" | "otp" | "password";
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stage, setStage] = useState<Stage>("identify");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
// The reset ticket lives in memory only — persisting it would leave a
|
||||
// password-change credential sitting in localStorage.
|
||||
const [ticket, setTicket] = useState<ResetTicket | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
/** The identifier as the API will see it — normalised once, reused everywhere. */
|
||||
const normalised = normaliseIdentifier(identifier);
|
||||
|
||||
const sendCode = async () => {
|
||||
await requestPasswordResetRequest({ identifier: normalised, channel });
|
||||
setOtpCode("");
|
||||
resendCooldown.start();
|
||||
};
|
||||
|
||||
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
|
||||
// so we always advance; a non-existent identifier simply never receives a code.
|
||||
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 — trade the code for a single-use ticket.
|
||||
const handleVerify = async () => {
|
||||
setError(null);
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await verifyPasswordResetOtpRequest({
|
||||
identifier: normalised,
|
||||
channel,
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
setTicket(result);
|
||||
setStage("password");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 — spend the ticket on IAM's set-password.
|
||||
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!ticket) {
|
||||
setError("Your reset session expired. Start again.");
|
||||
setStage("identify");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
try {
|
||||
await resetPasswordRequest({
|
||||
userId: ticket.userId,
|
||||
// The API matches this against email / username / phone, so the typed
|
||||
// identifier works regardless of which one it is.
|
||||
email: normalised,
|
||||
verificationCode: ticket.verificationCode,
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/auth", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identifierLabel =
|
||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Recover your account"
|
||||
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email or phone."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{stage === "identify" ? (
|
||||
<form onSubmit={handleIdentify} className="flex w-full flex-col">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<KeyRound size={22} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your email or phone number and we'll send you a code to
|
||||
reset it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={sending}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
label="Send the code to"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
The code goes to {identifierLabel}, which may differ from what you
|
||||
typed above.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
disabled={!identifier.trim()}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
Send code
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remembered it?{" "}
|
||||
<Link to="/auth" className="font-semibold text-primary hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{stage === "otp" ? (
|
||||
<OtpChannelStep
|
||||
channel={channel}
|
||||
target={normalised}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={handleVerify}
|
||||
onBack={() => {
|
||||
setStage("identify");
|
||||
setError(null);
|
||||
}}
|
||||
onResend={handleResend}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={error}
|
||||
title="Enter your reset code"
|
||||
description="Enter it to choose a new password."
|
||||
submitLabel="Verify code"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stage === "password" ? (
|
||||
<form onSubmit={handleReset} className="flex w-full flex-col">
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Choose a new password
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Pick something strong you haven't used before.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={verifying}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<PasswordChecklist value={password} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm new password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={verifying}
|
||||
error={
|
||||
confirmPassword && confirmPassword !== password
|
||||
? "Passwords do not match"
|
||||
: undefined
|
||||
}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={
|
||||
verifying ||
|
||||
!meetsAllRequirements(password) ||
|
||||
password !== confirmPassword
|
||||
}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={verifying}
|
||||
onClick={() => {
|
||||
setStage("otp");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
@@ -14,25 +14,13 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
const normaliseIdentifier = (raw: string): string => {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251")
|
||||
? digits.slice(3)
|
||||
: digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
};
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const LoginPage = () => {
|
||||
@@ -111,14 +99,24 @@ const LoginPage = () => {
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordInput
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -63,22 +64,35 @@ const VIEW_FILTERS: Record<
|
||||
active: { status: "active" },
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||
] as const;
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
"name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...VIEW_FILTERS[view],
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
|
||||
);
|
||||
};
|
||||
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
|
||||
@@ -291,6 +305,20 @@ export default function CustomersPage() {
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="md"
|
||||
w={160}
|
||||
allowDeselect={false}
|
||||
aria-label="Sort customers"
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setSort(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -188,6 +188,8 @@ export interface CompanyListFilter {
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the bookings service shape). */
|
||||
|
||||
22
apps/edr-freight-web/backoffice/src/utils/identifier.ts
Normal file
22
apps/edr-freight-web/backoffice/src/utils/identifier.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
export function normaliseIdentifier(raw: string): string {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
export const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
export const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
38
apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts
Normal file
38
apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
|
||||
export const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
|
||||
* — a password this accepts but the API rejects surfaces as an opaque 400.
|
||||
*/
|
||||
export const passwordField = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
|
||||
|
||||
export const confirmPasswordField = z
|
||||
.string()
|
||||
.min(1, "Please confirm your password");
|
||||
|
||||
export const samePassword = (data: {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}) => data.password === data.confirmPassword;
|
||||
|
||||
/** Every requirement in {@link passwordRequirements} is satisfied. */
|
||||
export const meetsAllRequirements = (value: string) =>
|
||||
passwordRequirements.every((r) => r.test(value));
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -13,12 +14,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-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 { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
@@ -675,12 +677,24 @@ export default function CompanyProfileForm({
|
||||
Address Information
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Region"
|
||||
placeholder="Tigray"
|
||||
required
|
||||
error={errors.region?.message}
|
||||
{...register("region")}
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
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
|
||||
label="Zone"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
|
||||
import { isValidPhone } from "@/components/PhoneField";
|
||||
|
||||
@@ -21,7 +22,7 @@ export const onboardingSchema = z.object({
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
@@ -35,7 +36,18 @@ export const onboardingSchema = z.object({
|
||||
renewedTo: z.string().optional(),
|
||||
// Address fields are user-entered and required (the registration/license
|
||||
// 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.
|
||||
//
|
||||
// Deliberately `.refine` over `z.enum`: the form needs "" as its unselected
|
||||
// sentinel (default value, and legacy rows whose region isn't in the list). A
|
||||
// literal union makes "" untypable, which also splits the resolver's
|
||||
// input/output types and breaks useForm's inference for every other field.
|
||||
region: z
|
||||
.string()
|
||||
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
|
||||
message: "Region is required",
|
||||
}),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
|
||||
@@ -36,7 +36,7 @@ const schema = z.object({
|
||||
|
||||
// TRANSPORT
|
||||
fanNumber: z.string().min(1),
|
||||
tinNumber: z.string().min(1),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
|
||||
truckType: z.enum([
|
||||
"Casoni",
|
||||
@@ -185,7 +185,7 @@ export default function TransporterOnboarding() {
|
||||
|
||||
<Field data-invalid={!!errors.tinNumber}>
|
||||
<FieldLabel>TIN Number</FieldLabel>
|
||||
<Input {...register("tinNumber")} />
|
||||
<Input maxLength={10} inputMode="numeric" {...register("tinNumber")} />
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
|
||||
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 "./contracts";
|
||||
export * from "./clearance-files.catalog";
|
||||
export * from "./ethiopian-regions.catalog";
|
||||
export * from "./notifications";
|
||||
export * from "./booking-window-ws";
|
||||
export * from "./support-chat";
|
||||
|
||||
Reference in New Issue
Block a user