proposal-system/web/src/domain/customers/schemas.ts
Adam Moussa 1f55a59445
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup)
Correctness:
- State-transition mutations now invalidate every cached view via
  invalidateProposalViews (detail + line items + lists + stats + admin
  dashboard) — approving no longer leaves a stale queue for the
  5-minute staleTime
- Presigned S3 PUT moved to proposals/api.ts with res.ok check — a
  rejected upload is no longer confirmed as uploaded
- toCustomerRequest always sends contactEmail ('' clears); API create
  path normalizes empty->null to match the update path — customer
  emails can now be cleared from the UI
- Shared Number-based numeric form fields (domain/shared/formFields):
  '12abc' no longer silently coerces to 12 in the pricing library
- Customer create/update invalidate customersKeys.all so cached search
  autocompletes see new customers
- AdminWorkspace clears dirty right after a successful implicit save,
  before approve — no false unsaved-changes prompt when approve fails
- ProposalFormPage submit gate and missing-fields caption derive from
  ONE checks list (missing customer is now listed)
- Empty states gated on !err in ProposalListPage/AdminDashboard — no
  contradictory error + 'no proposals' UI
- VendorDataPanel migrated to useVendorProposals (kills the divergent
  ['vendorProposals', id] cache key and the inline apiClient query)
- useCustomerList/usePricingLibraryList get keepPreviousData — no
  TablePagination out-of-range flash on page change

Cleanup:
- Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem,
  useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema,
  toUpdateLineItemEntry); tests moved to the live save path
  (useSaveProposalWorkspace)
- Shared useDebouncedValue hook replaces 4 drifted inline debounce
  copies (one leaked its timer on unmount, two hardcoded 300ms);
  DEBOUNCE_AUTOCOMPLETE=300 named
- Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated
  onError handlers (CLAUDE.md traceability)
- shared/api-contracts gains an exports map; /schemas resolver alias
  deduplicated from 3 copies to the tsconfig paths mapping

Verify: tsc clean, vitest 51/51 (tests updated to pin the new
invalidation/mapper behavior + new '12abc' rejection test),
vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00

50 lines
1.9 KiB
TypeScript

// Customers domain — contract schema re-exports + customer form schema.
import { z } from 'zod';
import { createCustomerRequestSchema } from '@proposal-system/api-contracts/schemas';
import type { CreateCustomerRequest, UpdateCustomerRequest } from './types';
export {
customerSchema,
createCustomerRequestSchema,
updateCustomerRequestSchema,
} from '@proposal-system/api-contracts/schemas';
/** Simple email format check (client-side only) — mirrors CustomerManagementPage. */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Customer create/edit form. Derived from createCustomerRequestSchema — the
* form edits a single primary address string (the wire type takes
* addresses: string[]); toCustomerRequest() maps to the request shape.
*/
export const customerFormSchema = createCustomerRequestSchema
.pick({ name: true })
.extend({
name: z.string().trim().min(1, 'Name is required'),
address: z.string().trim().min(1, 'Address is required'),
contactEmail: z
.string()
.refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
message: 'Enter a valid email address',
}),
});
export type CustomerFormValues = z.infer<typeof customerFormSchema>;
/**
* Map validated form values to the create/update request. The form edits the
* primary address; any additional existing addresses are preserved.
* contactEmail is always sent: the API clears the stored email on empty
* string (CustomerService normalizes '' → null), so omitting the key when the
* admin blanks the field would make the email impossible to clear.
*/
export function toCustomerRequest(
values: CustomerFormValues,
existingAddresses: string[] = []
): CreateCustomerRequest & UpdateCustomerRequest {
return {
name: values.name.trim(),
addresses: [values.address.trim(), ...existingAddresses.slice(1)],
contactEmail: values.contactEmail.trim(),
};
}