shoc-frontend-new/src/domain/vendors/schemas/vendor-roster-schema.ts
Codex Review Integration 9a066092b3 fix(vendors): correct additive add reload, phone and contact (SH-250, SH-246)
Three review findings on the additive add path:

- Create-mode reload refreshed selectedRoster (including rowVersion) but left
  the form company fields on their pre-conflict values, so a retry diffed stale
  fields against the reloaded roster and could overwrite the concurrent company
  update that caused the 409. Reload now resets the form from the fresh roster,
  matching the select path.
- mapVendorRosterAdditivePatchToBackend copied companyFields verbatim while the
  reconcile write mapper canonicalizes companyPhone, so PATCH and PUT could send
  different phone shapes for the same input.
- emptyRosterTechnician seeded preferredContact "Phone" even though that
  control was removed from the form, so filling the first blank row submitted a
  fabricated value. append already omitted it; both paths now agree.
2026-08-18 17:28:48 -03:00

109 lines
3.1 KiB
TypeScript

import { z } from "zod";
import {
VENDOR_PREFERRED_CONTACT_VALUES,
type VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
const preferredContactEnum = z.enum(
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
);
export function isAbsoluteHttpsUrl(value: string): boolean {
if (value.trim() === "") return true;
try {
const parsed = new URL(value);
return parsed.protocol === "https:";
} catch {
return false;
}
}
const httpsUrl = z
.string()
.trim()
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
export function isValidNorthAmericanPhone(value: string): boolean {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
}
const northAmericanPhone = z
.string()
.refine(isValidNorthAmericanPhone, "Enter a 10-digit phone number");
const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]);
export const rosterTechnicianSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
contactName: z.string(),
phone: northAmericanPhone,
email: optionalEmail,
preferredContact: preferredContactEnum.optional(),
tradeSpecialties: z.string(),
isActive: z.boolean(),
});
export type RosterTechnicianValues = z.infer<typeof rosterTechnicianSchema>;
const baseCompanyFields = {
name: z.string().trim().min(1, "Company is required"),
companyPhone: northAmericanPhone,
email: optionalEmail,
address: z.string(),
city: z.string(),
state: z.string(),
zip: z.string(),
googleMapsUrl: httpsUrl,
notes: z.string(),
technicians: z.array(rosterTechnicianSchema),
};
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
const hasEmail = Boolean(data.email && data.email.trim());
if (!hasPhone && !hasEmail) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["companyPhone"],
message: "Company phone or email is required",
});
}
});
export type VendorCompanyRosterFormValues = z.infer<typeof vendorCompanyRosterSchema>;
export const vendorCompanyRosterUpdateSchema = vendorCompanyRosterSchema.and(
z.object({
companyId: z.union([z.string(), z.number()]).nullable(),
rowVersion: z.string().min(1, "Row version is required"),
}),
);
export type VendorCompanyRosterUpdateValues = z.infer<typeof vendorCompanyRosterUpdateSchema>;
// SH-250: the preferred-contact control was removed from the form, so seeding a
// value here fabricated one on submit. `append` already omits it; the schema
// keeps it optional, so both paths now agree and the backend receives it only
// when a real value exists.
export const emptyRosterTechnician: RosterTechnicianValues = {
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
};
export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
name: "",
companyPhone: "",
email: "",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [],
};