diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index a923336c..29df8921 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -289,7 +289,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByLabel("Technician Name").fill("New Technician"); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("(314) 555-0100"); + await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test"); await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx index 06fe52b3..a680d383 100644 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -98,7 +98,7 @@ export function VendorFormFields({ label="Technician Phone" placeholder="(XXX) XXX-XXXX" inputMode="tel" - value={formatPhoneMask(field.value ?? "")} + value={field.value ?? ""} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} error={Boolean(errors.phone)} helperText={errors.phone?.message} @@ -208,7 +208,7 @@ export function VendorFormFields({ label="Company Phone" placeholder="(XXX) XXX-XXXX" inputMode="tel" - value={formatPhoneMask(field.value ?? "")} + value={field.value ?? ""} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} error={Boolean(errors.companyPhone)} helperText={errors.companyPhone?.message} diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 5cfec245..9273ef8b 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -51,6 +51,14 @@ function readCompanyId(record: Record): string | number | null return null; } +function readNumericCompanyId(record: Record): number | null { + const value = readCompanyId(record); + if (typeof value === "number") return value; + if (typeof value !== "string" || value.trim() === "") return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + function readPreferredContact( record: Record, ...keys: string[] @@ -72,10 +80,12 @@ function readPreferredContact( } export function toCanonicalPhone(value: unknown): string { - const digits = String(value ?? "") - .replace(/\D+/g, "") - .slice(0, 10); - if (digits.length !== 10) return ""; + const original = String(value ?? ""); + let digits = original.replace(/\D+/g, ""); + if (digits.length === 11 && digits.startsWith("1")) { + digits = digits.slice(1); + } + if (digits.length !== 10) return original; return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } @@ -95,8 +105,8 @@ export function mapVendorListItem(raw: unknown): VendorListItem { companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: toCanonicalPhone(item.phone ?? item.Phone), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -120,8 +130,8 @@ export function mapVendor(raw: unknown): Vendor { companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: toCanonicalPhone(item.phone ?? item.Phone), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -139,7 +149,7 @@ export function mapVendorToBackend(vendor: unknown): Record { const item = asRecord(vendor); const companyName = readString(item, "companyName", "CompanyName"); const payload: Record = { - companyId: readCompanyId(item), + companyId: readNumericCompanyId(item), companyName, contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), @@ -167,7 +177,7 @@ function mapFacetCompany(raw: unknown): VendorFacetCompany { return { companyId: readCompanyId(item), name: readString(item, "name", "Name"), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), email: readString(item, "email", "Email"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 52d8edf3..7ce3be5e 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -12,19 +12,18 @@ const httpsUrl = z "Google Maps URL must use HTTPS", ); -const canonicalPhone = z - .string() - .refine( - (value) => value === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(value), - "Enter a 10-digit phone number", - ); +const northAmericanPhone = z.string().refine((value) => { + if (value === "") return true; + const digits = value.replace(/\D+/g, ""); + return digits.length === 10 || (digits.length === 11 && digits.startsWith("1")); +}, "Enter a 10-digit phone number"); export const vendorSchema = z.object({ companyName: z.string().min(1, "Company is required"), contactName: z.string().min(1, "Technician name is required"), email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(), - phone: canonicalPhone.optional(), - companyPhone: canonicalPhone.optional(), + phone: northAmericanPhone.optional(), + companyPhone: northAmericanPhone.optional(), googleMapsUrl: httpsUrl.optional(), notes: z.string().optional(), totalJobs: z.number().optional(), diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index e368c3dc..b66ce1bf 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -101,7 +101,7 @@ describe("vendorsApi.getList", () => { expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); expect(facets.companies[0]).toMatchObject({ name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", + companyPhone: "314-555-0100", googleMapsUrl: "https://maps.google.com/gateway", }); expect(facets.locations[0]?.label).toBe("St. Louis, MO"); @@ -126,10 +126,10 @@ describe("vendorsApi.getList", () => { it("sends all editable prototype fields when creating and updating", async () => { const values = { - companyId: "co-1", + companyId: "42", companyName: "Gateway Plumbing", contactName: "Adam Whyte", - phone: "314-555-0198", + phone: "+1 314-555-0198", companyPhone: "314-555-0100", email: "dispatch@gateway.test", address: "1 Market St", @@ -154,7 +154,7 @@ describe("vendorsApi.getList", () => { expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes, - companyId: "co-1", + companyId: 42, preferredContact: "Text", phone: "(314) 555-0198", companyPhone: "(314) 555-0100", @@ -182,6 +182,42 @@ describe("vendorsApi.getList", () => { ); }); + it("does not send a non-numeric company id to the integer backend contract", async () => { + apiPost.mockResolvedValueOnce({ data: {} }); + + await vendorsApi.create({ + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyId: null }), + ); + }); + + it("preserves a legacy phone on read and unrelated write", async () => { + apiGet.mockResolvedValueOnce({ + data: { + id: 7, + companyName: "Legacy Vendor", + contactName: "Taylor", + phone: "555-1234 ext 9", + }, + }); + apiPut.mockResolvedValueOnce({ data: {} }); + + const vendor = await vendorsApi.getById(7); + expect(vendor.phone).toBe("555-1234 ext 9"); + + await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" }); + expect(apiPut).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7`, + expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }), + ); + }); + it("maps the deactivation-impact payload including linked work orders", async () => { apiGet.mockResolvedValueOnce({ data: { diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index aee36aaa..244c2b8e 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -35,14 +35,15 @@ describe("vendorSchema", () => { ).toBe(false); }); - it("accepts empty phones and canonical (XXX) XXX-XXXX phones, rejecting partials", () => { + it("accepts canonicalizable North American phones and rejects partials", () => { expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe( true, ); expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe( true, ); - expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(false); + expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true); + expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true); expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false); });