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:
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user