From dd2b624aa66701ae6c4bf9c79d5bbb3c5664f327 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 6 Aug 2026 18:54:54 +0000 Subject: [PATCH] fix: onboarding validation --- .../modules/companies/companies.service.ts | 35 +- .../companies/dto/update-profile.dto.ts | 16 +- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../src/components/onboarding/ETradeInfo.tsx | 36 +- .../src/pages/accounts/CompanyProfileForm.tsx | 354 ++++++++++++++---- .../companyProfileForm/ETradeCompanyCard.tsx | 14 +- .../accounts/companyProfileForm/helpers.ts | 106 +++++- .../companyProfileForm/schema.test.ts | 180 +++++++++ .../accounts/companyProfileForm/schema.ts | 54 ++- .../src/pages/settings/TabCompanyProfile.tsx | 153 +++++--- .../portal/src/utils/result.ts | 19 +- 11 files changed, 804 insertions(+), 165 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index b5c953cac..eb6b28b44 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -2057,6 +2057,11 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; + // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. + const poaProven = identity.faydaRequired + ? identity.poa.verified + : identity.poa.verified || Boolean(identity.poa.name?.trim()); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -2074,8 +2079,19 @@ export class CompaniesService { ...(identity.faydaRequired && !identity.owner.verified ? ["Verify the company owner's identity with Fayda"] : []), - ...((poaRequired || poaProvided) && !identity.poa.verified - ? ["Verify your Power of Attorney's identity with Fayda"] + // Nationality-aware, exactly like `poaProven` in + // buildCompanyIdentityState and the check in `assertIdentityVerified`: + // Fayda is an Ethiopian national ID, so a foreign company's typed + // representative has to count. Demanding a verification here regardless + // made this list disagree with the rule actually enforced, and left a + // foreign freight forwarder unable to submit — asked for a Fayda + // verification its representative may have no way to obtain. + ...((poaRequired || poaProvided) && !poaProven + ? [ + identity.faydaRequired + ? "Verify your Power of Attorney's identity with Fayda" + : "Name your Power of Attorney, or verify them with Fayda", + ] : []), ...(identity.passportRequired && !identity.owner.passportNumber ? ["Add the company owner's passport number"] @@ -2089,7 +2105,10 @@ export class CompaniesService { const poaItemCount = delegationDue ? 1 : 0; // One item per identity credential the company has to prove: the owner // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — that one is Fayda whatever the nationality. + // there is one — Fayda for an Ethiopian company, a named representative + // for a foreign one, same rule as `poaProven` above. Counting a foreign + // company's typed PoA as unproven here left the progress bar permanently + // short of 100% on an item it had already satisfied. const ownerCredentialDue = identity.faydaRequired || identity.passportRequired; const ownerCredentialProven = identity.faydaRequired @@ -2099,7 +2118,7 @@ export class CompaniesService { (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); const missingIdentityCount = (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (delegationDue && !identity.poa.verified ? 1 : 0); + (delegationDue && !poaProven ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2767,7 +2786,13 @@ export class CompaniesService { // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), ...(result.email ? { [`${prefix}Email`]: result.email } : {}), - ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + // Fayda returns whatever the national registry holds, which is routinely a + // local number ("0911223344"). Every typed phone in this service is stored + // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here + // becomes a value the portal reads back and cannot resubmit. + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; 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 596644fab..dc7729479 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,12 @@ -import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { + IsString, + IsOptional, + IsEmail, + MaxLength, + IsEnum, + IsIn, + Matches, +} 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'; @@ -39,9 +47,13 @@ export class UpdateProfileDto { @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; + // Ethiopian VAT registration numbers are 10 digits, the same shape as the + // TIN. Both portal forms enforce that; without it here the API happily stored + // whatever a stale client sent, and the two layers disagreed about what the + // column may hold. @IsOptional() @IsString() - @MaxLength(50) + @Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462414480..bda88d912 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -647,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, + // The ET hub's rows open the shipment clearance detail at this URL. + /^\/dashboard\/clearance\/[^/]+(\/|$)/, ]; const isEtClearanceItem = (item: SidebarItem): boolean => diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index e92b907e2..74e267a30 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -26,9 +26,22 @@ interface ETradeInfoProps { onStatusChange?: (status: ETradeStatus) => void; /** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */ onReset?: () => void; + /** + * This TIN already passed eTrade in an earlier session (the saved profile + * carries its registration details), so adopt it on arrival instead of + * re-querying. Rehydration lands the TIN after the first render, which used + * to look exactly like the customer typing a new one: every reopen fired a + * live lookup that could fail on an outage, and re-marked eTrade's fields as + * freshly verified so they were resubmitted on the next save. "Get Data" + * stays available for a deliberate re-verify. + */ + alreadyVerified?: boolean; } -const isValidTin = (tin: string) => tin.length === 10; +// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup +// that could only fail, and the failure was then reported as "this TIN isn't +// registered with eTrade" instead of "that isn't a TIN". +const isValidTin = (tin: string) => /^\d{10}$/.test(tin); export default function ETradeInfo({ tin, @@ -37,6 +50,7 @@ export default function ETradeInfo({ onDataLoaded, onStatusChange, onReset, + alreadyVerified, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -63,6 +77,13 @@ export default function ETradeInfo({ // doesn't refire the lookup the moment this mounts. const lastFetchedTin = useRef(tin || null); useEffect(() => { + // A rehydrated TIN that eTrade already accepted: adopt it silently. Doing + // this before the change-detection below also keeps `onReset` from firing, + // which would wipe the very registration details that prove it passed. + if (alreadyVerified && lastFetchedTin.current === null && isValidTin(tin)) { + lastFetchedTin.current = tin; + return; + } if (tin !== lastFetchedTin.current) { // TIN moved away from whatever we last fetched — that result (verified // data, "taken", or an error) no longer describes this TIN. Drop it so @@ -82,12 +103,17 @@ export default function ETradeInfo({ const apiError = mutation.isError && mutation.error ? extractApiError(mutation.error) : null; - // A 400 here means eTrade simply has no record for this TIN. - const notFound = apiError?.statusCode === 400; + // A 400 here usually means eTrade has no record for this TIN — but the API + // also wraps its own transport failures as a 400 ("Failed to fetch company + // info from eTrade: …"), and reporting an outage as "this TIN isn't + // registered" sends the customer off to re-check a number that was fine. + const unreachable = /failed to fetch/i.test(apiError?.message ?? ""); + const notFound = apiError?.statusCode === 400 && !unreachable; const errorMessage = apiError && !notFound - ? apiError.message || - "We couldn't reach eTrade to fetch your company information. Please try again." + ? unreachable || !apiError.message + ? "We couldn't reach eTrade to fetch your company information. Please try again in a moment." + : apiError.message : null; const status: ETradeStatus = isLoading 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 17c88d792..15354cfd3 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -38,6 +38,10 @@ import { } from "./companyProfileForm/schema"; import { buildPayload, + firstPresent, + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; @@ -45,6 +49,7 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import { verifaydaService } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; +import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard"; import StepSection from "./companyProfileForm/StepSection"; @@ -67,7 +72,7 @@ export default function CompanyProfileForm({ submitError, uploadedDocumentKeys, onUploadDocuments, - identity, + identity: rawIdentity, onIdentityChange, }: { documentSettingCode: string; @@ -115,6 +120,15 @@ export default function CompanyProfileForm({ */ onIdentityChange?: () => void; }) { + // A Fayda claim carries the phone as the national registry holds it, which is + // often a local number the form's E.164 validation (and the API's + // `@IsValidPhone()`) would reject — for a value the customer never typed and + // has no field to correct. Normalize once, here, so every read below is safe. + const identity = useMemo( + () => normalizeIdentityPhones(rawIdentity), + [rawIdentity], + ); + const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -188,15 +202,20 @@ export default function CompanyProfileForm({ const { register, control, - handleSubmit, trigger, watch, setValue, - formState: { errors }, + getValues, + formState: { errors, dirtyFields }, } = useForm({ resolver: zodResolver( buildOnboardingSchema(identity?.passportRequired === true), ), + // `values` below re-seeds the form whenever the profile is refetched — and + // an in-page identity action (ticking "same as owner") refetches it. Without + // this, that reset silently throws away whatever the customer was part-way + // through typing on the current step. + resetOptions: { keepDirtyValues: true, keepErrors: true }, defaultValues: { companyName: "", companyEmail: "", @@ -266,21 +285,28 @@ export default function CompanyProfileForm({ phone: string; } | null>(null); + // `shouldDirty` is what marks the eTrade bundle as "re-verified this session"; + // `stepPayload` sends those keys only when dirty, so an unchanged record is + // never echoed back to the API (which would make it re-query eTrade). const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + const dirty = { shouldDirty: true } as const; if (data.companyName) { - setValue("companyName", data.companyName, { shouldValidate: true }); + setValue("companyName", data.companyName, { + shouldValidate: true, + ...dirty, + }); } - setValue("licenceNumber", data.licenceNumber); - setValue("statusDescription", data.statusDescription); - setValue("dateRegistered", data.dateRegistered); - setValue("renewedFrom", data.renewedFrom); - setValue("renewalDate", data.renewalDate); - setValue("renewedTo", data.renewedTo); - setValue("region", data.region); - setValue("zone", data.zone); - setValue("woreda", data.woreda); - setValue("kebele", data.kebele); - setValue("houseNo", data.houseNo); + setValue("licenceNumber", data.licenceNumber, dirty); + setValue("statusDescription", data.statusDescription, dirty); + setValue("dateRegistered", data.dateRegistered, dirty); + setValue("renewedFrom", data.renewedFrom, dirty); + setValue("renewalDate", data.renewalDate, dirty); + setValue("renewedTo", data.renewedTo, dirty); + setValue("region", data.region, dirty); + setValue("zone", data.zone, dirty); + setValue("woreda", data.woreda, dirty); + setValue("kebele", data.kebele, dirty); + setValue("houseNo", data.houseNo, dirty); // companyAddress is composed reactively from the address fields below, so // setting region/zone/woreda/kebele/houseNo above is enough — no need to // compose it here. companyPhone is derived below (identity → eTrade → @@ -292,6 +318,7 @@ export default function CompanyProfileForm({ setValue( "etradePhone", data.managerPhone || data.regularPhone || data.mobilePhone, + dirty, ); setEtradeOwner({ @@ -321,28 +348,37 @@ export default function CompanyProfileForm({ setEtradeOwner(null); }; - // companyEmail/companyPhone are no longer typed — the Fayda-verified owner + // companyEmail/companyPhone are derived, not typed — the Fayda-verified owner // is the highest-trust source (that's the whole point of verifying), eTrade's // registered number and the account email/phone are the fallbacks used // before verification happens. + // + // `firstValid*`, not `??`: these sources are optional AND unreliable. Fayda's + // email/phone claims can come back empty, and eTrade's registered phone is + // free text that arrives as things like "09 " (→ "+2519"). `??` stops + // at the first non-null, so a junk value became a field with no input and a + // 400 from the API on a value the customer never typed. Skip anything that + // isn't usable and fall through. + // + // When every source really is unusable the fields become editable below + // rather than blocking — the API requires a company email and phone at + // submit (REQUIRED_COMPANY_INFO), so leaving no way to supply them is a dead + // end. + const derivedEmail = firstValidEmail(identity?.owner.email, user.email); + const derivedPhone = firstValidPhone( + identity?.owner.phone, + etradeOwner?.phone, + user.phoneNumber, + ); useEffect(() => { - setValue("companyEmail", identity?.owner.email ?? user.email ?? "", { - shouldValidate: true, - }); + if (derivedEmail) setValue("companyEmail", derivedEmail); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user.email, rehydrate]); + }, [derivedEmail, rehydrate]); useEffect(() => { - setValue( - "companyPhone", - identity?.owner.phone ?? - etradeOwner?.phone ?? - toEthiopianE164(user.phoneNumber) ?? - "", - { shouldValidate: true }, - ); + if (derivedPhone) setValue("companyPhone", derivedPhone); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]); + }, [derivedPhone, rehydrate]); // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears @@ -352,6 +388,16 @@ export default function CompanyProfileForm({ const [gmSameAsOwner, setGmSameAsOwner] = useState( identity?.gmSameAsOwner ?? false, ); + // `identity` is undefined on the first render (the requirements query is still + // in flight), so the initial state above freezes at `false` — adopt the + // server's declaration the moment it lands, or a resumed draft shows an + // unticked box over a GM that is linked server-side. + const identityLoaded = useRef(false); + useEffect(() => { + if (!identity || identityLoaded.current) return; + identityLoaded.current = true; + setGmSameAsOwner(identity.gmSameAsOwner); + }, [identity]); const [contactSameAsGm, setContactSameAsGm] = useState(false); // General Manager source. The company step's email/phone are seeded from @@ -365,16 +411,26 @@ export default function CompanyProfileForm({ // A Fayda-verified owner outranks eTrade's registered owner — it's the // higher-trust source, and the whole point of proving identity is to stop // trusting typed/looked-up data for this. - const gmSourceName = - identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? ""; - const gmSourceEmail = - identity?.owner.email ?? (companyEmail || user.email || ""); - const gmSourcePhone = - identity?.owner.phone ?? - companyPhone ?? - etradeOwner?.phone ?? - toEthiopianE164(user.phoneNumber) ?? - ""; + const gmSourceName = firstPresent( + identity?.owner.name, + etradeOwner?.name, + user.name?.en, + ); + + const gmSourceEmail = firstValidEmail( + identity?.owner.email, + companyEmail, + user.email, + ); + // Same reason as `derivedPhone`: this value is written into + // `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an + // unusable eTrade number here 400s the personnel step instead. + const gmSourcePhone = firstValidPhone( + identity?.owner.phone, + companyPhone, + etradeOwner?.phone, + user.phoneNumber, + ); useEffect(() => { if (!gmSameAsOwner) return; @@ -458,10 +514,24 @@ export default function CompanyProfileForm({ const gmEstablished = gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); - /** Same rule for the representative: verified, or typed where Fayda is optional. */ + /** + * Same rule for the representative: verified, or entered where Fayda is + * optional. + * + * Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative + * counts once they have a name, an email and a phone. The step now renders + * inputs for all three, so this is something the customer can actually + * satisfy — previously it gated on `poaName`, for which no input existed + * anywhere, leaving a foreign freight forwarder permanently stuck. + */ + const poaTyped = Boolean( + watch("poaName")?.trim() && + watch("poaEmail")?.trim() && + watch("poaPhone")?.trim(), + ); const poaEstablished = (identity?.poa.verified ?? false) || - (identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false); + (identity ? !identity.faydaRequired && poaTyped : false); // While linked, mirror the source values into the (disabled) target fields so // the copy stays current even if the user goes back and edits the source. @@ -616,8 +686,12 @@ export default function CompanyProfileForm({ // Until then the upload is hidden: there is no representative for the paper // to authorise, and a freight forwarder is held on the verification gate // below rather than on a file field it cannot yet fill. - const poaProvided = identity?.poa.verified ?? false; - const delegationRequired = poaProvided; + const poaProvided = (identity?.poa.verified ?? false) || poaTyped; + // A freight forwarder owes the paper whether or not its representative could + // verify with Fayda — the API demands it at completion either way. Keying + // this on the verification alone hid the upload from a foreign forwarder and + // then failed them on submit for a file they were never shown. + const delegationRequired = poaProvided || requirePoa; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -625,15 +699,63 @@ export default function CompanyProfileForm({ return Array.isArray(v) ? v.length > 0 : v != null; })(); + /** + * Collect the messages for a set of fields into one sentence. + * + * A failed `trigger()` used to return silently, so Continue simply did + * nothing — and every field whose input is conditionally rendered (or derived + * and never rendered at all) turned into an invisible dead end. Naming the + * failures is the whole point: the ones worth reporting are exactly the ones + * with no error text on screen to read. + */ + const describeErrors = (fields: (keyof FormData)[]): string => { + // Re-parse rather than read `errors`: that's the render-time snapshot, and + // this runs immediately after an `await trigger()` that has not re-rendered + // yet, so the closure would still be holding the previous attempt's state. + const parsed = buildOnboardingSchema( + identity?.passportRequired === true, + ).safeParse(getValues()); + const wanted = new Set(fields as string[]); + const messages = parsed.success + ? [] + : parsed.error.issues + .filter((i) => wanted.has(String(i.path[0]))) + .map((i) => i.message); + return messages.length > 0 + ? `Please fix: ${[...new Set(messages)].join(", ")}.` + : "Some details on this step are incomplete. Please review the fields above."; + }; + + /** + * The fields this step actually validates. `stepFields` covers what the step + * always renders; the company step additionally exposes company email/phone + * as inputs when nothing could be derived for them, and a field is validated + * exactly when the customer can see and fix it. + */ + const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => { + if (s !== "company" || !identity) return stepFields[s]; + return [ + ...stepFields.company, + ...(derivedEmail ? [] : (["companyEmail"] as const)), + ...(derivedPhone ? [] : (["companyPhone"] as const)), + ]; + }; + /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); - const isValid = await trigger(stepFields[step]); - if (!isValid) return false; + const fields = fieldsForStep(step); + const isValid = await trigger(fields); + if (!isValid) { + setSaveError(describeErrors(fields)); + return false; + } if (!onSaveStep) return true; setSaving(true); try { - const res = await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep( + stepPayload(step, getValues(), dirtyFields), + ); if (!res.ok) { setSaveError(res.error); return false; @@ -676,7 +798,14 @@ export default function CompanyProfileForm({ } setSaveError(null); - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + // Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields + // — including every field belonging to a step that isn't on screen — and + // on failure did nothing at all, no alert and no navigation, which is the + // "Submit for review" button that appears dead. Each step has already + // validated and saved its own fields, and the API's `markOnboardingComplete` + // is the authority on what is still outstanding; its message reaches the + // customer through `submitError`. + onSubmit(buildPayload(getValues(), user)); return; } // The TIN must resolve to a real eTrade record before anything else on @@ -728,15 +857,40 @@ export default function CompanyProfileForm({ setDocumentFieldErrors({ [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); + // Validate the text fields too, so every problem shows at once. + const fieldsOk = await trigger(stepFields.poa); setSaveError( - requirePoa - ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." - : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", + [ + requirePoa + ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." + : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", + fieldsOk ? null : describeErrors(stepFields.poa), + ] + .filter(Boolean) + .join(" "), ); - // Fall through to validate the text fields too, so every problem shows at once. - await trigger(stepFields.poa); return; } + // The API will not accept the PoA's details until the paper evidencing the + // delegation is actually on file, so the selection made on this step has to + // be uploaded before the save — not held back until the documents step, + // which is unreachable while this save keeps failing. + if (step === "poa" && delegationRequired && onUploadDocuments) { + const pending = documentFiles[POA_DELEGATION_FILE_KEY]; + const hasPending = Array.isArray(pending) ? pending.length > 0 : pending != null; + if (hasPending) { + setSaving(true); + try { + const res = await onUploadDocuments(); + if (!res.ok) { + setSaveError(res.error); + return; + } + } finally { + setSaving(false); + } + } + } // Field steps validate + save before advancing. const ok = await saveCurrentStep(); if (!ok) return; @@ -812,6 +966,42 @@ export default function CompanyProfileForm({ {...register("ownerPassportNumber")} /> )} + {/* Normally derived from the verified owner (falling back + to eTrade and the account), and shown read-only. Fayda's + email and phone claims are optional though, so when + every source comes up empty these become typeable — + the API requires both at submit, and having no input + for them is otherwise an unrecoverable dead end. */} + + {derivedEmail ? ( + + ) : ( + + )} + {derivedPhone ? ( + + ) : ( + + )} + )} @@ -835,6 +1025,7 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} onReset={handleETradeReset} + alreadyVerified={hasRegistrationDetails} /> {tinVerified && ( Contact Person - {watch("generalManagerName") && ( + {/* `gmName`, not the raw form field: a Fayda-verified GM never + fills `generalManagerName`, so gating on it hid this card from + every Ethiopian company — the majority case. */} + {gmName && ( )} - {/* The address comes from the Fayda claim along with the name, - so it is shown on the panel rather than typed. Only a company - whose representative may hold no Fayda ID still types it. */} + {/* A verified representative's details come from the Fayda claim + and are shown on the panel above. Where Fayda cannot be + required — a foreign company whose representative may hold no + Fayda ID — they are typed here instead. They have to be: the + API refuses to save a freight forwarder's PoA without a name, + email and phone (`REQUIRED_POA_FIELDS`), and before this the + step rendered no input for any of them, so the customer was + told to "add the poa name, poa email, poa phone" with nowhere + to add them. */} {!identity?.poa.verified && !identity?.faydaRequired && ( - + <> + + + + + + + )} - {/* The paper authorises the representative the verification - named, so it only has meaning once one exists. */} - {poaProvided && poaDocumentSetting && ( + {/* The paper authorises the representative, so it shows once one + exists — or straight away for a freight forwarder, who owes it + either way and must not be failed on submit for a file the + step never offered. */} + {delegationRequired && poaDocumentSetting && ( <> ; }) { const value = watch(name) as string | undefined; - if (value && value.trim()) { + if (value && value.trim() && !errors[name]) { return ; } return ( @@ -96,7 +101,12 @@ export default function ETradeCompanyCard({ - {region && region.trim() ? ( + {/* Membership of the catalog, not mere presence: eTrade's normalizer + returns null for a region it doesn't recognise, and older rows can + hold a spelling that isn't in the list. Showing such a value + read-only left the customer with a required field they could not + correct. */} + {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( ) : ( + values.find((v) => v && v.trim())?.trim() ?? ""; + +/** + * First candidate that is actually a usable phone number, normalized to E.164. + * + * Presence is not enough here. eTrade's registered phone is free text and comes + * back as things like `"09 "`, which normalizes to `+2519` — non-empty, + * so a "first present" pick would take it, hand it to a field with no input, + * and have the API reject the whole save with + * "companyPhone must be a valid international phone number" for something the + * customer never typed. Skip a source that cannot produce a valid number and + * fall through to the next one. + */ +export const firstValidPhone = ( + ...values: (string | null | undefined)[] +): string => { + for (const raw of values) { + if (!raw || !raw.trim()) continue; + const e164 = toEthiopianE164(raw); + if (e164 && isValidPhone(e164)) return e164; + } + return ""; +}; + +/** Same idea for email: a malformed claim must not become an unfixable field. */ +export const firstValidEmail = ( + ...values: (string | null | undefined)[] +): string => { + const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return values.find((v) => v && ok.test(v.trim()))?.trim() ?? ""; +}; + +/** + * Fayda reports a person's phone as the national registry holds it, which is + * routinely a local number ("0911223344"). Every phone the forms validate and + * submit is E.164, so normalize on the way in — the API now stores new + * verifications normalized, but rows verified before that still hold raw claims. + */ +export function normalizeIdentityPhones( + identity?: CompanyIdentityState, +): CompanyIdentityState | undefined { + if (!identity) return identity; + const fix = (person: T): T => ({ + ...person, + phone: person.phone ? toEthiopianE164(person.phone) : person.phone, + }); + return { + ...identity, + owner: fix(identity.owner), + poa: fix(identity.poa), + gm: fix(identity.gm), + }; +} /** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ export const phoneDigits = (p?: string | null) => @@ -44,34 +108,35 @@ export function buildPayload( }; } -/** Map one wizard step's form values to the profile-update payload it saves. */ +/** + * Map one wizard step's form values to the profile-update payload it saves. + * + * `dirty` is react-hook-form's `dirtyFields`. The eTrade-owned keys (and the + * TIN) ride along only when the customer actually changed them this session — + * see `ETRADE_BUNDLE_FIELDS`. Everything else is unconditional: the API treats + * an absent key as "untouched", so omitting a field never clears it. + */ export function stepPayload( step: CompanyStep, d: FormData, + dirty: Partial> = {}, ): Partial { switch (step) { - case "company": + case "company": { + const etrade: Partial = {}; + for (const key of ETRADE_BUNDLE_FIELDS) { + if (dirty[key]) (etrade as Record)[key] = d[key]; + } + if (dirty.tinNumber) etrade.tin = d.tinNumber; return { - companyName: d.companyName, companyEmail: d.companyEmail, companyPhone: d.companyPhone, companyAddress: d.companyAddress, - tin: d.tinNumber, vatNumber: d.vatNumber, ownerPassportNumber: d.ownerPassportNumber || undefined, - licenceNumber: d.licenceNumber, - statusDescription: d.statusDescription, - dateRegistered: d.dateRegistered, - renewedFrom: d.renewedFrom, - renewalDate: d.renewalDate, - renewedTo: d.renewedTo, - region: d.region, - zone: d.zone, - woreda: d.woreda, - kebele: d.kebele, - houseNo: d.houseNo, - etradePhone: d.etradePhone, + ...etrade, }; + } case "personnel": return { generalManagerName: d.generalManagerName, @@ -86,7 +151,12 @@ export function stepPayload( contactPersonPhone: d.contactPersonPhone, }; case "poa": - return { poaLocation: d.poaLocation || undefined }; + return { + poaName: d.poaName || undefined, + poaEmail: d.poaEmail || undefined, + poaPhone: d.poaPhone || undefined, + poaLocation: d.poaLocation || undefined, + }; default: return {}; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts new file mode 100644 index 000000000..a6f9862c8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { onboardingSchema, stepFields } from "./schema"; +import { + firstPresent, + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, + stepPayload, +} from "./helpers"; +import type { FormData } from "./schema"; +import type { CompanyIdentityState } from "@/services/verifayda.service"; + +/** A minimally-valid form, so each case can vary one field at a time. */ +const values = (over: Partial = {}): FormData => + ({ + companyName: "Acme PLC", + companyEmail: "acme@example.com", + companyPhone: "+251911223344", + companyAddress: "1, Bole, Bole, Addis Ababa", + etradePhone: "+251911223344", + tinNumber: "0012345678", + vatNumber: "0012345678", + ownerPassportNumber: "", + licenceNumber: "LIC-1", + statusDescription: "Active", + dateRegistered: "2020-01-01", + renewedFrom: "", + renewalDate: "", + renewedTo: "", + region: "Addis Ababa", + zone: "Bole", + woreda: "03", + kebele: "07", + houseNo: "1", + contactPersonName: "Jane Smith", + contactPersonPosition: "", + contactPersonEmail: "", + contactPersonPhone: "+251911223344", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + poaName: "", + poaPhone: "", + poaAddress: "", + poaEmail: "", + poaLocation: "", + ...over, + }) as FormData; + +const errorFor = (data: FormData, field: keyof FormData) => { + const parsed = onboardingSchema.safeParse(data); + if (parsed.success) return undefined; + return parsed.error.issues.find((i) => i.path[0] === field)?.message; +}; + +describe("VAT number", () => { + it("accepts exactly ten digits", () => { + expect(errorFor(values({ vatNumber: "0012345678" }), "vatNumber")).toBeUndefined(); + }); + + // `.length(10)` used to pass this, so a ten-letter string reached the API. + it("rejects ten non-digits", () => { + expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( + "VAT number must be exactly 10 digits", + ); + }); + + it("rejects blank", () => { + expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( + "VAT number is required", + ); + }); +}); + +describe("region", () => { + it("rejects a spelling outside the catalog", () => { + expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe( + "Region is required", + ); + }); +}); + +describe("stepFields", () => { + // The regression this whole change exists to prevent: a step must not gate on + // a field it renders no input for, or Continue fails with the error attached + // to nothing on screen. + it("never gates the company step on a derived or read-only field", () => { + const unreachable = [ + "companyEmail", + "companyPhone", + "companyAddress", + "etradePhone", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + ]; + expect( + stepFields.company.filter((f) => unreachable.includes(f)), + ).toEqual([]); + }); +}); + +describe("stepPayload (company)", () => { + it("omits the eTrade bundle when nothing was re-verified", () => { + const payload = stepPayload("company", values(), {}); + expect(payload.tin).toBeUndefined(); + expect(payload.region).toBeUndefined(); + expect(payload.licenceNumber).toBeUndefined(); + // The customer's own fields still save. + expect(payload.vatNumber).toBe("0012345678"); + }); + + it("includes the bundle and the TIN once they are dirty", () => { + const payload = stepPayload("company", values(), { + tinNumber: true, + region: true, + }); + expect(payload.tin).toBe("0012345678"); + expect(payload.region).toBe("Addis Ababa"); + // Still only the dirty ones. + expect(payload.licenceNumber).toBeUndefined(); + }); +}); + +describe("firstPresent", () => { + it("skips empty strings rather than stopping at them", () => { + expect(firstPresent("", " ", "second@example.com")).toBe( + "second@example.com", + ); + expect(firstPresent(null, undefined, "")).toBe(""); + }); +}); + +describe("firstValidPhone", () => { + // Observed live: eTrade returned "09 " for a real TIN. It normalizes to + // "+2519", which is non-empty — so a presence check took it, put it in a field + // with no input, and the API rejected the whole step. + it("skips an eTrade number that cannot make a valid E.164", () => { + expect(firstValidPhone("09 ", "+251911223344")).toBe("+251911223344"); + }); + + it("normalizes a local number it can use", () => { + expect(firstValidPhone("0911223344")).toBe("+251911223344"); + }); + + it("returns empty when no source is usable, so the field falls back to an input", () => { + expect(firstValidPhone("09 ", "", null)).toBe(""); + }); +}); + +describe("firstValidEmail", () => { + it("skips a malformed claim", () => { + expect(firstValidEmail("not-an-email", "real@example.com")).toBe( + "real@example.com", + ); + }); +}); + +describe("normalizeIdentityPhones", () => { + it("converts a local Fayda phone claim to E.164", () => { + const identity = { + faydaRequired: true, + passportRequired: false, + owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null }, + poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null }, + gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null }, + gmSameAsOwner: false, + complete: false, + } as CompanyIdentityState; + + const fixed = normalizeIdentityPhones(identity)!; + expect(fixed.owner.phone).toBe("+251911223344"); + expect(fixed.gm.phone).toBe("+251911223344"); + expect(fixed.poa.phone).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index ff404ebe3..3133ddef0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -26,10 +26,11 @@ export const onboardingSchema = z.object({ // can diverge without the backend's eTrade-authenticity check misfiring. etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), + // `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits. vatNumber: z .string() .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), + .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), // The owner's passport number — the foreign-company identity credential // (Fayda is an Ethiopian national ID). Required only for a foreign company; // enforced in buildOnboardingSchema since that depends on `nationality`. @@ -134,22 +135,47 @@ export function buildOnboardingSchema( }); } +/** + * The keys eTrade owns. They are only resent when the customer actually + * re-verified the TIN this session: the API reacts to *any* of them by issuing + * a live eTrade lookup (`applyEtradeSourcedFields`) whose transport failures + * come back as a 400, so echoing unchanged values back would let an eTrade + * outage block a save the customer never made. + */ +export const ETRADE_BUNDLE_FIELDS = [ + "companyName", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", +] as const satisfies readonly (keyof FormData)[]; + +/** + * What each step validates before it may advance. + * + * Hard rule: a key belongs here only if that step renders an input the customer + * can actually correct it in. `companyEmail`/`companyPhone` are derived from the + * Fayda identity / eTrade / the account and have no input of their own, and the + * read-only eTrade fields cannot be edited at all — listing them meant a value + * the customer never typed could fail zod with its error message attached to + * nothing on screen, which reads as a Continue button that silently does + * nothing. The server still enforces its own required-field list at submit + * (`REQUIRED_COMPANY_INFO`), and reports it with a message. + */ export const stepFields: Record = { company: [ "companyName", - "companyEmail", - "companyPhone", - "companyAddress", - "etradePhone", "tinNumber", "vatNumber", "ownerPassportNumber", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", "region", "zone", "woreda", @@ -167,7 +193,11 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: ["poaLocation"], + // The API requires poaName/poaEmail/poaPhone from a freight forwarder + // (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the + // representative isn't proven by Fayda — otherwise the save is rejected + // naming fields the form never rendered. + poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"], documents: [], additional: [], }; 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 e42283674..2ff16f445 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,4 +1,4 @@ -import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import type { CompanyProfileInput, @@ -30,6 +30,12 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; +import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; +import { + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, +} from "@/pages/accounts/companyProfileForm/helpers"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -43,12 +49,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ // no standalone input. companyAddress: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), + // Same rule as onboarding — the two forms write the same column, so they must + // not disagree about what is acceptable in it. vatNumber: z .string() - .trim() - .max(20, "VAT number is too long") - .optional() - .or(z.literal("")), + .min(1, "VAT number is required") + .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), ownerPassportNumber: z.string().optional(), // Registration/address fields are eTrade-sourced — locked once eTrade // supplies a value, editable only as an escape hatch when it doesn't @@ -72,21 +78,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */ -const ETRADE_BUNDLE_FIELDS = [ - "companyName", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", -] as const satisfies readonly (keyof CompanyProfileFormData)[]; +/** + * `etradePhone` is not on this form, so the shared list is filtered down to the + * keys it actually holds. Source of truth: `companyProfileForm/schema.ts`. + */ +const ETRADE_FIELDS = SHARED_ETRADE_FIELDS.filter( + (k): k is Exclude => k !== "etradePhone", +); interface TabCompanyProfileProps { profile?: ProfileResponse; @@ -166,32 +164,39 @@ export default function TabCompanyProfile({ values: defaultValues, }); - const identity = profile?.identity; + // Fayda stores the phone as the national registry holds it (often a local + // number), which neither this form's E.164 validation nor the API's + // `@IsValidPhone()` accepts. Normalize on read — same as the wizard. + const identity = useMemo( + () => normalizeIdentityPhones(profile?.identity), + [profile?.identity], + ); const verifiedIdentity = identity?.faydaRequired === true; // companyEmail/companyPhone are the owner's verified contact details, never // typed — same derivation as the onboarding wizard, just fed from the saved - // profile instead of an in-progress form. - useEffect(() => { - if (!user) return; - setValue("companyEmail", identity?.owner.email ?? user.email ?? "", { - shouldValidate: true, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user?.email]); + // profile instead of an in-progress form. `firstValid*` rather than `??`: + // these claims are optional AND unreliable — eTrade's registered phone is + // free text that arrives as things like "09 " — and `??` stops at the + // first non-null, so junk became a read-only field the customer could not + // fix and a 400 on save. When nothing usable can be derived the fields below + // become editable instead of blocking. + const derivedEmail = firstValidEmail(identity?.owner.email, user?.email); + const derivedPhone = firstValidPhone( + identity?.owner.phone, + profile?.etradePhone, + user?.phoneNumber, + ); useEffect(() => { - if (!user) return; - setValue( - "companyPhone", - identity?.owner.phone ?? - profile?.etradePhone ?? - toEthiopianE164(user.phoneNumber) ?? - "", - { shouldValidate: true }, - ); + if (derivedEmail) setValue("companyEmail", derivedEmail); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]); + }, [derivedEmail]); + + useEffect(() => { + if (derivedPhone) setValue("companyPhone", derivedPhone); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [derivedPhone]); // companyAddress is composed from the (locked) eTrade address parts, not // typed directly. @@ -249,7 +254,7 @@ export default function TabCompanyProfile({ // on every save would otherwise trigger the server's eTrade // authenticity re-check for no reason. const etradeBundle: Record = {}; - for (const key of ETRADE_BUNDLE_FIELDS) { + for (const key of ETRADE_FIELDS) { if (dirtyFields[key]) etradeBundle[key] = data[key]; } if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber; @@ -296,13 +301,32 @@ export default function TabCompanyProfile({ }); const onSubmit = (data: CompanyProfileFormData) => { + setValidationError(null); if (isCreate && selectedRoles.length === 0) return; mutation.mutate(data); }; - const saveErrorMessage = mutation.isError - ? extractApiError(mutation.error).message - : null; + /** + * Without this, a failed validation made "Save Changes" a no-op: the fields + * the schema requires are largely eTrade-sourced and rendered read-only, so + * their error messages had nowhere to appear and the button simply did + * nothing. Name them instead. + */ + const [validationError, setValidationError] = useState(null); + const onInvalid = (formErrors: typeof errors) => { + const messages = Object.values(formErrors) + .map((e) => e?.message) + .filter((m): m is string => Boolean(m)); + setValidationError( + messages.length > 0 + ? `Please fix: ${[...new Set(messages)].join(", ")}.` + : "Some details are incomplete. Please review the fields above.", + ); + }; + + const saveErrorMessage = + validationError ?? + (mutation.isError ? extractApiError(mutation.error).message : null); const pendingOwnerReview = Boolean( (profile?.pendingChanges as { faydaIdentity?: Record } | null) @@ -333,7 +357,7 @@ export default function TabCompanyProfile({ : "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."} -
+ @@ -389,9 +413,33 @@ export default function TabCompanyProfile({ {...register("ownerPassportNumber")} /> )} + {/* Read-only while the verified owner (or eTrade, or the account) + supplies them. Fayda's email/phone claims are optional, so + when nothing can be derived these become typeable — the API + requires both, and showing an empty read-only field is a save + that can never succeed. */} - - + {derivedEmail ? ( + + ) : ( + + )} + {derivedPhone ? ( + + ) : ( + + )} )} @@ -410,6 +458,7 @@ export default function TabCompanyProfile({ error={errors.tinNumber?.message} onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} + alreadyVerified={hasRegistrationDetails} /> {tinVerified && ( - {region?.trim() ? ( + {/* Membership of the catalog, not mere presence — a stored spelling + outside the list is otherwise uncorrectable. */} + {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( ) : ( @@ -548,7 +599,9 @@ function LockedField({ errors: ReturnType>["formState"]["errors"]; }) { const value = watch(name) as string | undefined; - if (value?.trim()) { + // A value that fails validation unlocks too — rendering a rejected value + // read-only is a save that can never succeed and never says why. + if (value?.trim() && !errors[name]) { return ; } return ( diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 54104442c..3c0bddf34 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -34,6 +34,16 @@ function humanizeApiMessage(raw: string): string { return raw; } +/** + * NestJS's ValidationPipe reports every failed constraint at once, so `message` + * arrives as a string[] rather than a string. Flatten it — passing the array + * through left the UI rendering its entries run together with no separator. + */ +function asMessage(value: unknown): string { + if (Array.isArray(value)) return value.filter(Boolean).join(". "); + return typeof value === "string" ? value : ""; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -41,13 +51,10 @@ export function extractApiError(err: unknown): ApiError { if (response) { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; + const raw = asMessage(data?.message) || asMessage(data?.error); return { - code: (data?.message as string) || (data?.error as string) || "api_error", - message: humanizeApiMessage( - (data?.message as string) || - (data?.error as string) || - "An unexpected error occurred", - ), + code: raw || "api_error", + message: humanizeApiMessage(raw || "An unexpected error occurred"), statusCode, }; }