Merge pull request #833 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-20 11:26:30 +03:00
committed by GitHub
39 changed files with 1708 additions and 102 deletions

View File

@@ -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,

View File

@@ -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('');
});
});

View File

@@ -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);
}

View File

@@ -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.
}
}

View File

@@ -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();

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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()

View File

@@ -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 || "",

View File

@@ -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);
}
}
});
});

View 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(),
};
}
}

View 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 {}

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 };
}
// ---------------------------------------------------------------------------

View File

@@ -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 });
});
});

View File

@@ -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 };
}

View File

@@ -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 />} />

View File

@@ -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);
};

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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),
};
}

View File

@@ -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&apos;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&apos;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;

View File

@@ -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} />}>

View File

@@ -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 (AZ)" },
{ value: "name:DESC", label: "Name (ZA)" },
] 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>

View File

@@ -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). */

View 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}`;
};

View 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));

View File

@@ -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"

View File

@@ -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"),

View File

@@ -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>

View File

@@ -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()

View 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;
}

View File

@@ -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";