feat(deps): bump zod 3→4 + @hookform/resolvers 3→5

Resolved 65 type errors across the codebase via these v4 migration
patterns:

- `ZodError.errors` renamed to `ZodError.issues` (4 call sites in auth
  routes + central error handler).
- `z.record(value)` now requires explicit key type: `z.record(z.string(),
  value)`. Updated 7 sites across templates / forms / saved-views /
  website-inquiries.
- `.refine(check, msgFn)` second-arg shape changed — now requires an
  `{ error: (issue) => ... }` object form. Updated
  `mergeFieldsSchema` in document-templates validator.
- `.transform(...).default(...)` chains: v4 enforces default value type
  matches transform OUTPUT. Reordered to `.default(...).transform(...)`
  in list-query / company-memberships handlers.
- `z.coerce.*()` INPUT type widened to `unknown` in v4. Service signatures
  using `z.input<typeof schema>` (kept for caller flexibility around
  defaults) now re-parse via `schema.parse(data)` to recover the
  post-coercion shape Drizzle needs. Done in berth-reservations service.
  Invoice service narrows `lineItems` locally with a typed cast since
  re-parsing would double-validate.
- `.optional().transform(...)` no longer propagates the optional marker
  through v4's new ZodPipe. Moved `.optional()` to the END of chain in
  `optionalDesiredDimSchema` (interests) and documents list query
  (folderId, signatureOnly).
- ZodIssue subtype shapes simplified: `received` removed from
  invalid_type, `type` renamed to `origin` on too_small. Test fixtures
  updated.
- @hookform/resolvers v5 splits Resolver into 3-generic form (Input,
  Context, Output). useForm calls in 6 forms (client, yacht, berth,
  interest, expense, invoices-new-page) now pass explicit generics:
  `useForm<z.input<typeof schema>, unknown, z.infer<typeof schema>>`.

Verified: tsc clean (0 errors), vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 18:29:03 +02:00
parent d3960af340
commit acf878f997
24 changed files with 131 additions and 90 deletions

View File

@@ -26,6 +26,7 @@ import { CurrencySelect } from '@/components/shared/currency-select';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils/currency';
import type { z } from 'zod';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
const PAYMENT_TERMS = [
@@ -76,7 +77,7 @@ export default function NewInvoicePage() {
enabled: !!prefilledInterestId,
});
const methods = useForm<CreateInvoiceInput>({
const methods = useForm<z.input<typeof createInvoiceSchema>, unknown, CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema),
defaultValues: {
paymentTerms: 'net30',

View File

@@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
const result = await consumeCrmInvite({

View File

@@ -25,7 +25,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
await activateAccount(parsed.data.token, parsed.data.password);

View File

@@ -25,7 +25,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
await resetPassword(parsed.data.token, parsed.data.password);

View File

@@ -38,7 +38,7 @@ import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
const SubmissionSchema = z.object({
submission_id: z.string().uuid(),
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
payload: z.record(z.unknown()),
payload: z.record(z.string(), z.unknown()),
legacy_nocodb_id: z.string().optional(),
/** Defaults to port-nimara since that's currently the only port with a
* public marketing site. Future ports can override per-submission. */

View File

@@ -10,8 +10,8 @@ import { addMembershipSchema } from '@/lib/validators/company-memberships';
const listQuerySchema = z.object({
activeOnly: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('true'),
.default('true')
.transform((v) => v === 'true'),
});
export const listHandler: RouteHandler = async (req, ctx, params) => {

View File

@@ -24,6 +24,7 @@ import { CurrencyInput } from '@/components/shared/currency-input';
import { CurrencySelect } from '@/components/shared/currency-select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { z } from 'zod';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import {
BERTH_AREAS,
@@ -120,7 +121,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
setValue,
watch,
formState: { isSubmitting },
} = useForm<UpdateBerthInput>({
} = useForm<z.input<typeof updateBerthSchema>, unknown, UpdateBerthInput>({
resolver: zodResolver(updateBerthSchema),
defaultValues: {
area: berth.area ?? undefined,
@@ -403,7 +404,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<div className="space-y-2">
<Label>Price</Label>
<CurrencyInput
value={watch('price') ?? ''}
value={(watch('price') as number | null | undefined) ?? ''}
currency={watch('priceCurrency') ?? 'USD'}
onChange={(v) => setValue('price', v ?? undefined, { shouldDirty: true })}
/>

View File

@@ -25,6 +25,7 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input';
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
import { apiFetch } from '@/lib/api/client';
import type { z } from 'zod';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
import { SOURCES } from '@/lib/constants';
import type { CountryCode } from '@/lib/i18n/countries';
@@ -74,7 +75,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateClientInput>({
} = useForm<z.input<typeof createClientSchema>, unknown, CreateClientInput>({
resolver: zodResolver(createClientSchema),
defaultValues: {
fullName: '',

View File

@@ -23,6 +23,7 @@ import { CurrencyInput } from '@/components/shared/currency-input';
import { CurrencySelect } from '@/components/shared/currency-select';
import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox';
import { apiFetch } from '@/lib/api/client';
import type { z } from 'zod';
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
import type { ExpenseRow } from './expense-columns';
@@ -55,7 +56,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<CreateExpenseInput>({
} = useForm<z.input<typeof createExpenseSchema>, unknown, CreateExpenseInput>({
resolver: zodResolver(createExpenseSchema),
defaultValues: {
currency: 'USD',
@@ -211,7 +212,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<Label htmlFor="amount">Amount *</Label>
<CurrencyInput
id="amount"
value={watch('amount') ?? ''}
value={(watch('amount') as number | null | undefined) ?? ''}
currency={watch('currency') ?? 'USD'}
onChange={(v) =>
setValue('amount', v ?? Number.NaN, { shouldDirty: true, shouldValidate: true })

View File

@@ -45,6 +45,7 @@ import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import type { z } from 'zod';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -96,7 +97,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<CreateInterestInput>({
} = useForm<z.input<typeof createInterestSchema>, unknown, CreateInterestInput>({
resolver: zodResolver(createInterestSchema),
defaultValues: {
clientId: '',

View File

@@ -23,6 +23,7 @@ import { CountryCombobox } from '@/components/shared/country-combobox';
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import type { z } from 'zod';
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
interface YachtFormProps {
@@ -73,7 +74,7 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateYachtInput>({
} = useForm<z.input<typeof createYachtSchema>, unknown, CreateYachtInput>({
resolver: zodResolver(createYachtSchema),
defaultValues: {
name: '',

View File

@@ -12,8 +12,8 @@ export const baseListQuerySchema = z.object({
search: z.string().optional(),
includeArchived: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('false'),
.default('false')
.transform((v) => v === 'true'),
});
export type BaseListQuery = z.infer<typeof baseListQuerySchema>;

View File

@@ -156,7 +156,7 @@ export function errorResponse(error: unknown): NextResponse {
const body: Record<string, unknown> = {
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: error.errors.map((e) => ({
details: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),

View File

@@ -11,14 +11,17 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { z } from 'zod';
import { createPendingSchema } from '@/lib/validators/reservations';
import type {
createPendingSchema,
ActivateInput,
EndReservationInput,
CancelInput,
ListReservationsInput,
} from '@/lib/validators/reservations';
// Use z.input so callers (including tests) can omit fields with
// `.default()` like `tenureType`. The service re-parses below to get
// the post-coercion shape Drizzle expects (Date, defaulted tenureType).
type CreatePendingInput = z.input<typeof createPendingSchema>;
export type { BerthReservation };
@@ -110,18 +113,23 @@ export async function createPending(
data.clientId,
);
// Re-parse to apply coercions/defaults locally — Drizzle's .values()
// wants the post-coercion shape (Date, defaulted enum), and v4's
// z.input is too loose to satisfy that.
const parsed = createPendingSchema.parse(data);
const [reservation] = await db
.insert(berthReservations)
.values({
portId,
berthId: data.berthId,
clientId: data.clientId,
yachtId: data.yachtId,
interestId: data.interestId ?? null,
berthId: parsed.berthId,
clientId: parsed.clientId,
yachtId: parsed.yachtId,
interestId: parsed.interestId ?? null,
status: 'pending',
startDate: data.startDate,
tenureType: data.tenureType ?? 'permanent',
notes: data.notes ?? null,
startDate: parsed.startDate,
tenureType: parsed.tenureType,
notes: parsed.notes ?? null,
createdBy: meta.userId,
})
.returning();

View File

@@ -248,8 +248,12 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
const invoiceNumber = await generateInvoiceNumber(portId, tx);
// Calculate subtotal from line items
const lineItemsData = data.lineItems ?? [];
// Calculate subtotal from line items. The `z.coerce.number()` in
// the schema makes the parsed value a number at runtime — narrow
// the post-parse shape locally so v4's stricter input typing
// (unknown for coerced fields) doesn't leak into arithmetic.
type ParsedLineItem = { quantity: number; unitPrice: number; description: string };
const lineItemsData = (data.lineItems ?? []) as ParsedLineItem[];
const subtotal = lineItemsData.reduce((sum, li) => sum + (li.quantity ?? 1) * li.unitPrice, 0);
// BR-042: net10 discount - read from systemSettings
@@ -429,9 +433,12 @@ export async function updateInvoice(
}
if (data.kind !== undefined) updateData.kind = data.kind;
// Recalculate totals if line items changed
// Recalculate totals if line items changed (see createInvoice for
// the ParsedLineItem narrowing rationale — same coerced-number
// story applies on the update path).
if (data.lineItems !== undefined) {
const lineItemsData = data.lineItems;
type ParsedLineItem = { quantity: number; unitPrice: number; description: string };
const lineItemsData = data.lineItems as ParsedLineItem[];
const subtotal = lineItemsData.reduce(
(sum, li) => sum + (li.quantity ?? 1) * li.unitPrice,
0,

View File

@@ -15,13 +15,13 @@ const mergeFieldsSchema = z
.array(z.string())
.optional()
.default([])
.refine(
(tokens) => tokens.every(isAcceptableMergeToken),
(tokens) => {
.refine((tokens) => tokens.every(isAcceptableMergeToken), {
error: (issue) => {
const tokens = issue.input as string[] | undefined;
const unknown = tokens?.filter((t) => !isAcceptableMergeToken(t)) ?? [];
return { message: `Unknown merge tokens: ${unknown.join(', ')}` };
return `Unknown merge tokens: ${unknown.join(', ')}`;
},
);
});
export const templateFormats = ['html', 'pdf_form', 'pdf_overlay', 'documenso_render'] as const;
@@ -104,18 +104,18 @@ export const tiptapDocumentTypes = [
export const createAdminTemplateSchema = z.object({
name: z.string().min(1).max(200),
type: z.enum(tiptapDocumentTypes),
content: z.record(z.unknown()), // TipTap JSON document
content: z.record(z.string(), z.unknown()), // TipTap JSON document
});
export const updateAdminTemplateSchema = z.object({
name: z.string().min(1).max(200).optional(),
content: z.record(z.unknown()).optional(),
content: z.record(z.string(), z.unknown()).optional(),
isActive: z.boolean().optional(),
});
export const previewAdminTemplateSchema = z.object({
content: z.record(z.unknown()),
sampleData: z.record(z.string()).optional(),
content: z.record(z.string(), z.unknown()),
sampleData: z.record(z.string(), z.string()).optional(),
});
export const rollbackAdminTemplateSchema = z.object({

View File

@@ -89,8 +89,8 @@ export const listDocumentsSchema = baseListQuerySchema
folderId: z
.string()
.nullable()
.optional()
.transform((v) => (v === '' ? null : v)),
.transform((v) => (v === '' ? null : v))
.optional(),
includeDescendants: z.coerce.boolean().optional(),
status: z.string().optional(),
/** Hub tab filter - applies tab-specific status / signer-membership constraints. */
@@ -100,8 +100,8 @@ export const listDocumentsSchema = baseListQuerySchema
/** When true, only docs intended for signing (default true on hub). */
signatureOnly: z
.enum(['true', 'false'])
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true')),
.transform((v) => v === 'true')
.optional(),
sentSince: z.string().datetime().optional(),
sentUntil: z.string().datetime().optional(),
/** Entity-aggregated projection params — mutually exclusive with folderId. */

View File

@@ -13,7 +13,7 @@ export const createFormTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().optional(),
fields: z.array(formFieldSchema).min(1, 'At least one field is required'),
branding: z.record(z.unknown()).optional(),
branding: z.record(z.string(), z.unknown()).optional(),
isActive: z.boolean().optional().default(true),
});

View File

@@ -16,15 +16,19 @@ import {
* empty strings collapse to `undefined` so a blank form field doesn't
* round-trip "" → numeric error on the API.
*/
// In Zod 4, the optional() marker must live at the *outside* of the
// chain to propagate the field's optional-ness into the parent z.object.
// In v3 the same pattern worked with optional() in the middle, but v4's
// new ZodPipe (transform) doesn't forward optional through the pipe.
const optionalDesiredDimSchema = z
.union([z.string(), z.number()])
.optional()
.transform((v) => {
if (v === undefined || v === null || v === '') return undefined;
if (v === '') return undefined;
const n = typeof v === 'number' ? v : parseFloat(v);
if (!Number.isFinite(n) || n <= 0) return undefined;
return String(Math.round(n * 100) / 100);
});
})
.optional();
const desiredUnitSchema = z.enum(['ft', 'm']).optional();

View File

@@ -81,9 +81,13 @@ export const listInvoicesSchema = baseListQuerySchema.extend({
});
// `z.input` keeps fields with `.default()` (paymentTerms, currency, kind)
// optional from the caller's perspective. The schema parser still fills in
// the defaults, so the service body can rely on them being present at runtime.
// optional from the caller's perspective. The route layer runs the
// schema through `parseBody`, so the service body can rely on those
// defaults being present at runtime — narrow with a local cast where
// the post-parse shape matters (e.g. coerced `unitPrice` is `number`).
export type CreateInvoiceInput = z.input<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.input<typeof updateInvoiceSchema>;
export type CreateInvoiceParsed = z.infer<typeof createInvoiceSchema>;
export type UpdateInvoiceParsed = z.infer<typeof updateInvoiceSchema>;
export type RecordPaymentInput = z.infer<typeof recordPaymentSchema>;
export type ListInvoicesInput = z.infer<typeof listInvoicesSchema>;

View File

@@ -3,14 +3,14 @@ import { z } from 'zod';
export const createSavedViewSchema = z.object({
entityType: z.string().min(1),
name: z.string().min(1),
filters: z.record(z.unknown()).default({}),
filters: z.record(z.string(), z.unknown()).default({}),
sortConfig: z
.object({
field: z.string(),
direction: z.enum(['asc', 'desc']),
})
.optional(),
columnConfig: z.record(z.unknown()).optional(),
columnConfig: z.record(z.string(), z.unknown()).optional(),
isShared: z.boolean().optional().default(false),
isDefault: z.boolean().optional().default(false),
});