From 182787e143c2d1d723e59da3e16235d1f9817129 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 9 Aug 2026 07:57:45 +0000 Subject: [PATCH] fix: gm email --- .../companies.fayda-identity.spec.ts | 87 +++++++++++++++++++ .../accounts/companyProfileForm/helpers.ts | 12 ++- .../companyProfileForm/schema.test.ts | 34 ++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index dd198b1c6..599187b22 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -354,6 +354,93 @@ describe("Fayda identity verification binds a person to the company", () => { ).resolves.toBeDefined(); }); + // Fayda's email and phone claims are optional and routinely absent. The owner + // is who the company is reached through and the step renders no input for + // their contact details, so the onboarding account — already OTP-proven — + // stands in rather than leaving the company unreachable. + describe("account contact details stand in for absent Fayda claims", () => { + const noContactClaims = { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + address: "Addis Ababa", + }; + const account = { + email: "account@example.com", + phoneNumber: "+251911777777", + }; + + it("falls back to the account for an owner Fayda gave no email or phone", async () => { + const { service, ctx } = makeService({ verification: noContactClaims }); + + await service.completeIdentityVerification( + "user-1", + { subject: "owner", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.ownerEmail).toBe("account@example.com"); + expect(ctx.attributes.ownerPhone).toBe("+251911777777"); + }); + + it("prefers the Fayda claim over the account when there is one", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification( + "user-1", + { subject: "owner", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.ownerEmail).toBe("haile@example.com"); + expect(ctx.attributes.ownerPhone).toBe("+251922000000"); + }); + + it("leaves the PoA alone — the account is not that person", async () => { + const { service, ctx } = makeService({ verification: noContactClaims }); + + await service.completeIdentityVerification( + "user-1", + { subject: "poa", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.poaEmail).toBeUndefined(); + expect(ctx.attributes.poaPhone).toBeUndefined(); + }); + + // Owners verified before the fallback existed hold blank contacts. Copying + // those blanks onto the GM makes generalManagerEmail required by onboarding + // with no field anywhere to satisfy it. + it("fills the GM copy from the account when the stored owner has no contacts", async () => { + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await service.setGmSameAsOwner("user-1", account); + + expect(ctx.attributes.gmEmail).toBe("account@example.com"); + expect(ctx.attributes.generalManagerEmail).toBe("account@example.com"); + expect(ctx.attributes.generalManagerPhone).toBe("+251911777777"); + }); + + it("keeps the stored owner contacts when the GM copy has them", async () => { + const { service, ctx } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911000111", + }, + }); + + await service.setGmSameAsOwner("user-1", account); + + expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); + expect(ctx.attributes.generalManagerPhone).toBe("+251911000111"); + }); + }); + it("never locks or gates the general manager — it is not the verified subject", async () => { // GM is a plain typed role; the portal offers a "same as owner" copy, but // the backend must not treat it as identity-owned or require it verified. diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 572627292..36c0072fb 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -139,10 +139,16 @@ export function stepPayload( }; } case "personnel": + // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and + // undefined, so an empty string is validated and 400s with + // "generalManagerEmail must be an email". An Ethiopian company never types + // these — the GM comes from the Fayda verification (or the "same as owner" + // declaration), so the form fields are legitimately blank and would fail a + // step that has no input to fix. return { - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, + generalManagerName: d.generalManagerName || undefined, + generalManagerEmail: d.generalManagerEmail || undefined, + generalManagerPhone: d.generalManagerPhone || undefined, }; case "contact": 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 index eb5471861..6e459a6fb 100644 --- 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 @@ -123,6 +123,40 @@ describe("stepPayload (company)", () => { }); }); +describe("stepPayload (personnel)", () => { + // An Ethiopian company never types the GM — Fayda (or "same as owner") owns + // those fields — so the form holds "". `@IsOptional()` on the DTO skips only + // null/undefined, so an empty string is validated and comes back as + // "generalManagerEmail must be an email", on a step that renders no input. + it("omits blank GM fields instead of sending empty strings", () => { + const payload = stepPayload( + "personnel", + values({ + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + }), + ); + expect(payload.generalManagerName).toBeUndefined(); + expect(payload.generalManagerEmail).toBeUndefined(); + expect(payload.generalManagerPhone).toBeUndefined(); + }); + + it("still sends typed GM details (foreign company)", () => { + const payload = stepPayload( + "personnel", + values({ + generalManagerName: "Abebe Bikila", + generalManagerEmail: "gm@example.com", + generalManagerPhone: "+251911223344", + }), + ); + expect(payload.generalManagerName).toBe("Abebe Bikila"); + expect(payload.generalManagerEmail).toBe("gm@example.com"); + expect(payload.generalManagerPhone).toBe("+251911223344"); + }); +}); + describe("firstPresent", () => { it("skips empty strings rather than stopping at them", () => { expect(firstPresent("", " ", "second@example.com")).toBe(