From acf878f99752645595b037c40db869f3c7c4b455 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 18:29:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(deps):=20bump=20zod=203=E2=86=924=20+=20@h?= =?UTF-8?q?ookform/resolvers=203=E2=86=925?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (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, unknown, z.infer>`. Verified: tsc clean (0 errors), vitest 1293/1293 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 +- pnpm-lock.yaml | 82 +++++++++++-------- .../[portSlug]/invoices/new/page.tsx | 3 +- src/app/api/auth/set-password/route.ts | 2 +- src/app/api/portal/auth/activate/route.ts | 2 +- .../api/portal/auth/reset-password/route.ts | 2 +- src/app/api/public/website-inquiries/route.ts | 2 +- .../api/v1/companies/[id]/members/handlers.ts | 4 +- src/components/berths/berth-form.tsx | 5 +- src/components/clients/client-form.tsx | 3 +- .../expenses/expense-form-dialog.tsx | 5 +- src/components/interests/interest-form.tsx | 3 +- src/components/yachts/yacht-form.tsx | 3 +- src/lib/api/list-query.ts | 4 +- src/lib/errors.ts | 2 +- .../services/berth-reservations.service.ts | 24 ++++-- src/lib/services/invoices.ts | 15 +++- src/lib/validators/document-templates.ts | 18 ++-- src/lib/validators/documents.ts | 8 +- src/lib/validators/form-templates.ts | 2 +- src/lib/validators/interests.ts | 10 ++- src/lib/validators/invoices.ts | 8 +- src/lib/validators/saved-views.ts | 4 +- tests/unit/security-error-responses.test.ts | 5 +- 24 files changed, 131 insertions(+), 90 deletions(-) diff --git a/package.json b/package.json index c51ad359..37c8ca26 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@hookform/resolvers": "^3.10.0", + "@hookform/resolvers": "^5.2.2", "@pdfme/common": "^6.1.2", "@pdfme/generator": "^6.1.2", "@pdfme/schemas": "^6.1.2", @@ -101,7 +101,7 @@ "tesseract.js": "^7.0.0", "vaul": "^1.1.2", "web-vitals": "^5.2.0", - "zod": "^3.25.76", + "zod": "^4.4.3", "zustand": "^5.0.13" }, "devDependencies": { @@ -120,6 +120,7 @@ "autoprefixer": "^10.5.0", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", + "drizzle-zod": "^0.8.3", "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-config-next": "15.5.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48a38a46..e1f3d672 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@19.2.6) '@hookform/resolvers': - specifier: ^3.10.0 - version: 3.10.0(react-hook-form@7.75.0(react@19.2.6)) + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.75.0(react@19.2.6)) '@pdfme/common': specifier: ^6.1.2 version: 6.1.2 @@ -171,7 +171,7 @@ importers: version: 8.0.7 openai: specifier: ^6.37.0 - version: 6.37.0(ws@8.18.3)(zod@3.25.76) + version: 6.37.0(ws@8.18.3)(zod@4.4.3) pdf-lib: specifier: ^1.17.1 version: 1.17.1 @@ -233,8 +233,8 @@ importers: specifier: ^5.2.0 version: 5.2.0 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.4.3 zustand: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -284,6 +284,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + drizzle-zod: + specifier: ^0.8.3 + version: 0.8.3(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(zod@4.4.3) esbuild: specifier: '>=0.25.0' version: 0.28.0 @@ -776,10 +779,10 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 - '@hookform/resolvers@3.10.0': - resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: - react-hook-form: ^7.0.0 + react-hook-form: ^7.55.0 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -2986,6 +2989,12 @@ packages: sqlite3: optional: true + drizzle-zod@0.8.3: + resolution: {integrity: sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww==} + peerDependencies: + drizzle-orm: '>=0.36.0' + zod: ^3.25.0 || ^4.0.0 + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5375,9 +5384,6 @@ packages: zlibjs@0.3.1: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -5463,7 +5469,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -5475,40 +5481,40 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 - '@better-auth/drizzle-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))': + '@better-auth/drizzle-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: drizzle-orm: 0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) - '@better-auth/kysely-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.1.0(socks@2.8.8))': + '@better-auth/mongo-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.1.0(socks@2.8.8))': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: mongodb: 7.1.0(socks@2.8.8) - '@better-auth/prisma-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/prisma-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/telemetry@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -5813,8 +5819,9 @@ snapshots: - '@types/react' - supports-color - '@hookform/resolvers@3.10.0(react-hook-form@7.75.0(react@19.2.6))': + '@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.6))': dependencies: + '@standard-schema/utils': 0.3.0 react-hook-form: 7.75.0(react@19.2.6) '@humanfs/core@0.19.2': @@ -7388,13 +7395,13 @@ snapshots: better-auth@1.6.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): dependencies: - '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)) - '@better-auth/kysely-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.1.0(socks@2.8.8)) - '@better-auth/prisma-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)) + '@better-auth/kysely-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.1.0(socks@2.8.8)) + '@better-auth/prisma-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -7814,6 +7821,11 @@ snapshots: kysely: 0.28.17 postgres: 3.4.9 + drizzle-zod@0.8.3(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(zod@4.4.3): + dependencies: + drizzle-orm: 0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) + zod: 4.4.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9174,10 +9186,10 @@ snapshots: dependencies: mimic-function: 5.0.1 - openai@6.37.0(ws@8.18.3)(zod@3.25.76): + openai@6.37.0(ws@8.18.3)(zod@4.4.3): optionalDependencies: ws: 8.18.3 - zod: 3.25.76 + zod: 4.4.3 opencollective-postinstall@2.0.3: {} @@ -10521,8 +10533,6 @@ snapshots: zlibjs@0.3.1: {} - zod@3.25.76: {} - zod@4.4.3: {} zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): diff --git a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx index 2211d18b..5c29b482 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx @@ -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({ + const methods = useForm, unknown, CreateInvoiceInput>({ resolver: zodResolver(createInvoiceSchema), defaultValues: { paymentTerms: 'net30', diff --git a/src/app/api/auth/set-password/route.ts b/src/app/api/auth/set-password/route.ts index cbdafda9..05c7fbad 100644 --- a/src/app/api/auth/set-password/route.ts +++ b/src/app/api/auth/set-password/route.ts @@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise { 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({ diff --git a/src/app/api/portal/auth/activate/route.ts b/src/app/api/portal/auth/activate/route.ts index 33c5a843..c2a202cf 100644 --- a/src/app/api/portal/auth/activate/route.ts +++ b/src/app/api/portal/auth/activate/route.ts @@ -25,7 +25,7 @@ export async function POST(req: NextRequest): Promise { 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); diff --git a/src/app/api/portal/auth/reset-password/route.ts b/src/app/api/portal/auth/reset-password/route.ts index bd555f77..c5a76873 100644 --- a/src/app/api/portal/auth/reset-password/route.ts +++ b/src/app/api/portal/auth/reset-password/route.ts @@ -25,7 +25,7 @@ export async function POST(req: NextRequest): Promise { 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); diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index 83e06e0b..23d9d271 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -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. */ diff --git a/src/app/api/v1/companies/[id]/members/handlers.ts b/src/app/api/v1/companies/[id]/members/handlers.ts index 658b59ae..2b44a03b 100644 --- a/src/app/api/v1/companies/[id]/members/handlers.ts +++ b/src/app/api/v1/companies/[id]/members/handlers.ts @@ -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) => { diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index d9e4ad80..89a750e5 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -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({ + } = useForm, unknown, UpdateBerthInput>({ resolver: zodResolver(updateBerthSchema), defaultValues: { area: berth.area ?? undefined, @@ -403,7 +404,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
setValue('price', v ?? undefined, { shouldDirty: true })} /> diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 09269f57..83a636bd 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -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({ + } = useForm, unknown, CreateClientInput>({ resolver: zodResolver(createClientSchema), defaultValues: { fullName: '', diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index 1fed60c1..cdb99053 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -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({ + } = useForm, unknown, CreateExpenseInput>({ resolver: zodResolver(createExpenseSchema), defaultValues: { currency: 'USD', @@ -211,7 +212,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi setValue('amount', v ?? Number.NaN, { shouldDirty: true, shouldValidate: true }) diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index bdc6dd3a..8916f31d 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -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({ + } = useForm, unknown, CreateInterestInput>({ resolver: zodResolver(createInterestSchema), defaultValues: { clientId: '', diff --git a/src/components/yachts/yacht-form.tsx b/src/components/yachts/yacht-form.tsx index a5429c0e..aaf38ac5 100644 --- a/src/components/yachts/yacht-form.tsx +++ b/src/components/yachts/yacht-form.tsx @@ -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({ + } = useForm, unknown, CreateYachtInput>({ resolver: zodResolver(createYachtSchema), defaultValues: { name: '', diff --git a/src/lib/api/list-query.ts b/src/lib/api/list-query.ts index cd4e92a5..086b85af 100644 --- a/src/lib/api/list-query.ts +++ b/src/lib/api/list-query.ts @@ -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; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index beb44b63..242fd76c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -156,7 +156,7 @@ export function errorResponse(error: unknown): NextResponse { const body: Record = { error: 'Validation failed', code: 'VALIDATION_ERROR', - details: error.errors.map((e) => ({ + details: error.issues.map((e) => ({ field: e.path.join('.'), message: e.message, })), diff --git a/src/lib/services/berth-reservations.service.ts b/src/lib/services/berth-reservations.service.ts index 7855880d..00b9692c 100644 --- a/src/lib/services/berth-reservations.service.ts +++ b/src/lib/services/berth-reservations.service.ts @@ -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; 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(); diff --git a/src/lib/services/invoices.ts b/src/lib/services/invoices.ts index 918bbcc9..a3e61af6 100644 --- a/src/lib/services/invoices.ts +++ b/src/lib/services/invoices.ts @@ -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, diff --git a/src/lib/validators/document-templates.ts b/src/lib/validators/document-templates.ts index c40b395b..94cfec79 100644 --- a/src/lib/validators/document-templates.ts +++ b/src/lib/validators/document-templates.ts @@ -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({ diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts index dc9691f4..36814627 100644 --- a/src/lib/validators/documents.ts +++ b/src/lib/validators/documents.ts @@ -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. */ diff --git a/src/lib/validators/form-templates.ts b/src/lib/validators/form-templates.ts index b391d5cd..c48d72f9 100644 --- a/src/lib/validators/form-templates.ts +++ b/src/lib/validators/form-templates.ts @@ -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), }); diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index 19a32ad2..63750290 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -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(); diff --git a/src/lib/validators/invoices.ts b/src/lib/validators/invoices.ts index b1a774cd..e4785d6a 100644 --- a/src/lib/validators/invoices.ts +++ b/src/lib/validators/invoices.ts @@ -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; export type UpdateInvoiceInput = z.input; +export type CreateInvoiceParsed = z.infer; +export type UpdateInvoiceParsed = z.infer; export type RecordPaymentInput = z.infer; export type ListInvoicesInput = z.infer; diff --git a/src/lib/validators/saved-views.ts b/src/lib/validators/saved-views.ts index ff516a58..1590a035 100644 --- a/src/lib/validators/saved-views.ts +++ b/src/lib/validators/saved-views.ts @@ -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), }); diff --git a/tests/unit/security-error-responses.test.ts b/tests/unit/security-error-responses.test.ts index a3f61b28..0e072545 100644 --- a/tests/unit/security-error-responses.test.ts +++ b/tests/unit/security-error-responses.test.ts @@ -177,7 +177,7 @@ describe('Error response security — ZodError', () => { { code: ZodIssueCode.invalid_type, expected: 'string', - received: 'number', + input: 42, path: ['name'], message: 'Expected string, received number', }, @@ -196,8 +196,9 @@ describe('Error response security — ZodError', () => { { code: ZodIssueCode.too_small, minimum: 1, - type: 'string', + origin: 'string', inclusive: true, + input: '', path: ['fullName'], message: 'String must contain at least 1 character(s)', },