From ce2a61ced95c73857091e95c0e232b4cc7d088ee Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 11:40:48 -0500 Subject: [PATCH] Fix empty string validation on all optional form fields across all schemas Add opt() preprocessor that coerces empty strings to undefined before Zod validation. Applied to every optional string field in account, member, identifier, supplier, product, inventory unit, tax exemption, payment method, and lookup schemas. Fixes forms rejecting blank optional fields. --- packages/shared/src/schemas/account.schema.ts | 61 ++++++++++--------- .../shared/src/schemas/inventory.schema.ts | 45 ++++++++------ 2 files changed, 58 insertions(+), 48 deletions(-) diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts index 0f991ae..8eee5ea 100644 --- a/packages/shared/src/schemas/account.schema.ts +++ b/packages/shared/src/schemas/account.schema.ts @@ -1,5 +1,10 @@ import { z } from 'zod' +/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ +function opt(schema: T) { + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) as z.ZodEffects> +} + export const BillingMode = z.enum(['consolidated', 'split']) export type BillingMode = z.infer @@ -11,8 +16,8 @@ export type TaxExemptStatus = z.infer export const AccountCreateSchema = z.object({ name: z.string().min(1).max(255), - email: z.string().email().optional(), - phone: z.string().max(50).optional(), + email: opt(z.string().email()), + phone: opt(z.string().max(50)), address: z .object({ street: z.string().optional(), @@ -22,7 +27,7 @@ export const AccountCreateSchema = z.object({ }) .optional(), billingMode: BillingMode.default('consolidated'), - notes: z.string().optional(), + notes: opt(z.string()), }) export type AccountCreateInput = z.infer @@ -33,11 +38,11 @@ export const MemberCreateSchema = z.object({ accountId: z.string().uuid(), firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), - dateOfBirth: z.string().date().optional(), + dateOfBirth: opt(z.string().date()), isMinor: z.boolean().optional(), - email: z.string().email().optional(), - phone: z.string().max(50).optional(), - notes: z.string().optional(), + email: opt(z.string().email()), + phone: opt(z.string().max(50)), + notes: opt(z.string()), }) export type MemberCreateInput = z.infer @@ -56,14 +61,14 @@ export type IdentifierType = z.infer export const MemberIdentifierCreateSchema = z.object({ memberId: z.string().uuid(), type: IdentifierType, - label: z.string().max(100).optional(), + label: opt(z.string().max(100)), value: z.string().min(1).max(255), - issuingAuthority: z.string().max(255).optional(), - issuedDate: z.string().date().optional(), - expiresAt: z.string().date().optional(), - imageFrontUrl: z.string().max(500).optional(), - imageBackUrl: z.string().max(500).optional(), - notes: z.string().optional(), + issuingAuthority: opt(z.string().max(255)), + issuedDate: opt(z.string().date()), + expiresAt: opt(z.string().date()), + imageFront: opt(z.string()), + imageBack: opt(z.string()), + notes: opt(z.string()), isPrimary: z.boolean().default(false), }) export type MemberIdentifierCreateInput = z.infer @@ -91,8 +96,8 @@ export const PaymentMethodCreateSchema = z.object({ accountId: z.string().uuid(), processor: PaymentProcessor, processorPaymentMethodId: z.string().min(1).max(255), - cardBrand: z.string().max(50).optional(), - lastFour: z.string().length(4).optional(), + cardBrand: opt(z.string().max(50)), + lastFour: opt(z.string().length(4)), expMonth: z.number().int().min(1).max(12).optional(), expYear: z.number().int().min(2000).max(2100).optional(), isDefault: z.boolean().default(false), @@ -110,19 +115,19 @@ export type PaymentMethodUpdateInput = z.infer export const TaxExemptionCreateSchema = z.object({ accountId: z.string().uuid(), certificateNumber: z.string().min(1).max(255), - certificateType: z.string().max(100).optional(), - issuingState: z.string().length(2).optional(), - expiresAt: z.string().date().optional(), - notes: z.string().optional(), + certificateType: opt(z.string().max(100)), + issuingState: opt(z.string().length(2)), + expiresAt: opt(z.string().date()), + notes: opt(z.string()), }) export type TaxExemptionCreateInput = z.infer export const TaxExemptionUpdateSchema = z.object({ - certificateNumber: z.string().min(1).max(255).optional(), - certificateType: z.string().max(100).optional(), - issuingState: z.string().length(2).optional(), - expiresAt: z.string().date().optional(), - notes: z.string().optional(), + certificateNumber: opt(z.string().min(1).max(255)), + certificateType: opt(z.string().max(100)), + issuingState: opt(z.string().length(2)), + expiresAt: opt(z.string().date()), + notes: opt(z.string()), }) export type TaxExemptionUpdateInput = z.infer @@ -131,14 +136,14 @@ export type TaxExemptionUpdateInput = z.infer export const LookupCreateSchema = z.object({ name: z.string().min(1).max(100), slug: z.string().min(1).max(100).regex(/^[a-z0-9_]+$/, 'Slug must be lowercase alphanumeric with underscores'), - description: z.string().optional(), + description: opt(z.string()), sortOrder: z.number().int().default(0), }) export type LookupCreateInput = z.infer export const LookupUpdateSchema = z.object({ - name: z.string().min(1).max(100).optional(), - description: z.string().optional(), + name: opt(z.string().min(1).max(100)), + description: opt(z.string()), sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), }) diff --git a/packages/shared/src/schemas/inventory.schema.ts b/packages/shared/src/schemas/inventory.schema.ts index 168ed36..96d882b 100644 --- a/packages/shared/src/schemas/inventory.schema.ts +++ b/packages/shared/src/schemas/inventory.schema.ts @@ -1,9 +1,14 @@ import { z } from 'zod' +/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ +function opt(schema: T) { + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) as z.ZodEffects> +} + export const CategoryCreateSchema = z.object({ name: z.string().min(1).max(255), - description: z.string().optional(), - parentId: z.string().uuid().optional(), + description: opt(z.string()), + parentId: opt(z.string().uuid()), sortOrder: z.number().int().default(0), }) export type CategoryCreateInput = z.infer @@ -13,13 +18,13 @@ export type CategoryUpdateInput = z.infer export const SupplierCreateSchema = z.object({ name: z.string().min(1).max(255), - contactName: z.string().max(255).optional(), - email: z.string().email().optional(), - phone: z.string().max(50).optional(), - website: z.string().max(255).optional(), - accountNumber: z.string().max(100).optional(), - paymentTerms: z.string().max(100).optional(), - notes: z.string().optional(), + contactName: opt(z.string().max(255)), + email: opt(z.string().email()), + phone: opt(z.string().max(50)), + website: opt(z.string().max(255)), + accountNumber: opt(z.string().max(100)), + paymentTerms: opt(z.string().max(100)), + notes: opt(z.string()), }) export type SupplierCreateInput = z.infer @@ -45,14 +50,14 @@ export const ItemCondition = z.string().min(1).max(100) export const UnitStatus = z.string().min(1).max(100) export const ProductCreateSchema = z.object({ - sku: z.string().max(100).optional(), - upc: z.string().max(100).optional(), + sku: opt(z.string().max(100)), + upc: opt(z.string().max(100)), name: z.string().min(1).max(255), - description: z.string().optional(), - brand: z.string().max(255).optional(), - model: z.string().max(255).optional(), - categoryId: z.string().uuid().optional(), - locationId: z.string().uuid().optional(), + description: opt(z.string()), + brand: opt(z.string().max(255)), + model: opt(z.string().max(255)), + categoryId: opt(z.string().uuid()), + locationId: opt(z.string().uuid()), isSerialized: z.boolean().default(false), isRental: z.boolean().default(false), isDualUseRepair: z.boolean().default(false), @@ -73,13 +78,13 @@ export const ProductSearchSchema = z.object({ export const InventoryUnitCreateSchema = z.object({ productId: z.string().uuid(), - locationId: z.string().uuid().optional(), - serialNumber: z.string().max(255).optional(), + locationId: opt(z.string().uuid()), + serialNumber: opt(z.string().max(255)), condition: ItemCondition.default('new'), status: UnitStatus.default('available'), - purchaseDate: z.string().date().optional(), + purchaseDate: opt(z.string().date()), purchaseCost: z.number().min(0).optional(), - notes: z.string().optional(), + notes: opt(z.string()), }) export type InventoryUnitCreateInput = z.infer