From 3d4996e4dfd6560685c68aefd3a3e52a38fe5fd4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 20 Jul 2026 06:19:12 +0000 Subject: [PATCH 1/4] feat: setup forget password to the backoffice --- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../backoffice/src/auth/api.ts | 38 ++- .../backoffice/src/auth/types.ts | 27 ++ .../src/components/auth/OtpChannelStep.tsx | 179 ++++++++++ .../src/components/auth/PasswordChecklist.tsx | 41 +++ .../backoffice/src/hooks/useResendCooldown.ts | 24 ++ .../src/pages/auth/ForgotPasswordPage.tsx | 318 ++++++++++++++++++ .../backoffice/src/pages/auth/LoginPage.tsx | 42 ++- .../backoffice/src/utils/identifier.ts | 22 ++ .../backoffice/src/utils/passwordSchema.ts | 38 +++ 10 files changed, 708 insertions(+), 23 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/utils/identifier.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 16cbd8507..c82f64ed2 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 ( } /> + } /> {/* } /> */} } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts index f72e72949..240c55001 100644 --- a/apps/edr-freight-web/backoffice/src/auth/api.ts +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -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("/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( + "/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); +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 6279c8491..1c79819d5 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -58,6 +58,33 @@ export interface LoginResponse extends Partial { 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; diff --git a/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx @@ -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 ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +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 ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx @@ -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 ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts @@ -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), + }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx new file mode 100644 index 000000000..7a21c0d8b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx @@ -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("identify"); + const [identifier, setIdentifier] = useState(""); + const [channel, setChannel] = useState("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(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(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) => { + 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) => { + 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 ( + +
+ {stage === "identify" ? ( +
+
+ + + +
+ +
+

+ Forgot your password? +

+

+ Enter your email or phone number and we'll send you a code to + reset it. +

+
+ + + setIdentifier(event.target.value)} + /> + + + +

+ The code goes to {identifierLabel}, which may differ from what you + typed above. +

+ + {error ? ( + }> + {error} + + ) : null} + + + +

+ Remembered it?{" "} + + Back to sign in + +

+
+
+ ) : null} + + {stage === "otp" ? ( + { + 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" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+
+ ) : null} +
+
+ ); +}; + +export default ForgotPasswordPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 849049bc2..811d7e34b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -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)} /> - setPassword(event.target.value)} - /> +
+
+ Password + + Forgot password? + +
+ setPassword(event.target.value)} + /> +
{error ? ( }> diff --git a/apps/edr-freight-web/backoffice/src/utils/identifier.ts b/apps/edr-freight-web/backoffice/src/utils/identifier.ts new file mode 100644 index 000000000..72677f73c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/utils/identifier.ts @@ -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}`; +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts b/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts new file mode 100644 index 000000000..207d9dc2b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts @@ -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)); From c7195f077a1a8d83487a37d8f4763fd1e5adde0a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 20 Jul 2026 07:41:25 +0000 Subject: [PATCH 2/4] fix: 194 and 183 plane takss --- .../validators/is-tin.validator.spec.ts | 54 +++++++++++++++++++ .../src/common/validators/is-tin.validator.ts | 54 +++++++++++++++++++ .../modules/companies/companies.repository.ts | 8 ++- .../dto/create-company-with-profile.dto.ts | 3 +- .../companies/dto/create-company.dto.ts | 5 +- .../modules/companies/dto/fetch-etrade.dto.ts | 5 +- .../companies/dto/list-companies-query.dto.ts | 15 ++++++ .../companies/dto/update-profile.dto.ts | 5 +- .../src/pages/customers/CustomersPage.tsx | 38 +++++++++++-- .../backoffice/src/types/customer.ts | 2 + .../accounts/companyProfileForm/schema.ts | 2 +- .../on_boarding/TransportrOnBoarding.tsx | 4 +- .../src/pages/settings/TabCompanyProfile.tsx | 2 +- 13 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts create mode 100644 apps/edr-freight-api/src/common/validators/is-tin.validator.ts diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts new file mode 100644 index 000000000..cf6ca7a6e --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts @@ -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(''); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts new file mode 100644 index 000000000..9396a884d --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts @@ -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); +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 6a0365854..b02f38380 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + sortBy = 'name', + sortOrder = 'ASC', } = query; const qb = this.repository @@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository { ); } + // 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(); diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index eb32f72ae..7816572ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index a56ea5ad8..be911b3ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 2eb37c92d..466c03ed6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index adaa12479..8b5083e5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -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"; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9fd8f28ae..a1b1ac9df 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator'; 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 +35,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() diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 89325ae79..c1312a6ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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("all"); + const [sort, setSort] = useState("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" }, ]} /> + + diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 27011a9d7..597dfa509 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -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() From a45e1008fa308d4ec91c243cd2c90ba14726dbfb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 20 Jul 2026 08:19:59 +0000 Subject: [PATCH 3/4] fix: normalized the region and logged the otp properly --- apps/edr-freight-api/src/app.module.ts | 2 + .../2400000000000-NormalizeCompanyRegions.ts | 77 +++++++++ .../companies/dto/update-profile.dto.ts | 13 +- .../companies/services/etrade.service.ts | 7 +- .../services/region-normalization.spec.ts | 54 +++++++ .../src/modules/health/health.controller.ts | 108 +++++++++++++ .../src/modules/health/health.module.ts | 14 ++ .../modules/notifications/broker.util.spec.ts | 90 +++++++++++ .../src/modules/notifications/broker.util.ts | 92 +++++++++++ .../notifications/email-client.service.ts | 36 +++-- .../notifications/sms-client.service.ts | 64 +++++--- .../src/modules/otp/otp.controller.ts | 8 +- .../src/modules/otp/otp.service.spec.ts | 29 +++- .../src/modules/otp/otp.service.ts | 150 ++++++++++++++++-- .../src/pages/accounts/CompanyProfileForm.tsx | 28 +++- .../accounts/companyProfileForm/schema.ts | 5 +- .../src/freight/ethiopian-regions.catalog.ts | 97 +++++++++++ packages/types/src/freight/index.ts | 1 + 18 files changed, 812 insertions(+), 63 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts create mode 100644 apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts create mode 100644 apps/edr-freight-api/src/modules/health/health.controller.ts create mode 100644 apps/edr-freight-api/src/modules/health/health.module.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/broker.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/broker.util.ts create mode 100644 packages/types/src/freight/ethiopian-regions.catalog.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 5a9ae9ef9..e435267a1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts new file mode 100644 index 000000000..b3369b82d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts @@ -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 { + // 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 { + // 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. + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index a1b1ac9df..ba3e27aeb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsTin } from '../../../common/validators/is-tin.validator'; @@ -138,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() diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index b588c241f..51bdb2df6 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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 || "", diff --git a/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts new file mode 100644 index 000000000..910b3199a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts @@ -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); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.ts b/apps/edr-freight-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..6b559f2e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/health/health.controller.ts @@ -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(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/health/health.module.ts b/apps/edr-freight-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..572e5eb86 --- /dev/null +++ b/apps/edr-freight-api/src/modules/health/health.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/notifications/broker.util.spec.ts b/apps/edr-freight-api/src/modules/notifications/broker.util.spec.ts new file mode 100644 index 000000000..a6f73ac0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/broker.util.spec.ts @@ -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): 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((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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/notifications/broker.util.ts b/apps/edr-freight-api/src/modules/notifications/broker.util.ts new file mode 100644 index 000000000..aa09d362e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/broker.util.ts @@ -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 { + 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(); + } 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; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts index 161b2486a..20a43d061 100644 --- a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts index f94c0c20e..43e47f350 100644 --- a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5d12f91f9..6b0078429 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -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 }; } // --------------------------------------------------------------------------- diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 5f4afcfbd..c9d5c5714 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -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 }); + }); }); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 06d837065..5e91251c9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -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 }; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 27e51b403..47c00bef2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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 - ( +