diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 9b22296..8fa1cf6 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -51,14 +51,14 @@ const PersonaSchema = z for (const loc of p.locales) { if (!p.content.names[loc]) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["content", "names", loc], message: `missing name pool for declared locale '${loc}'`, }); } if (!p.content.descriptions[loc]) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["content", "descriptions", loc], message: `missing description pool for declared locale '${loc}'`, }); diff --git a/apps/journal/app/routes/api.v1.activities._index.ts b/apps/journal/app/routes/api.v1.activities._index.ts index d9545b1..a323e88 100644 --- a/apps/journal/app/routes/api.v1.activities._index.ts +++ b/apps/journal/app/routes/api.v1.activities._index.ts @@ -5,6 +5,7 @@ import { PaginationQuerySchema, CreateActivityRequestSchema, ERROR_CODES, + zodIssuesToFieldErrors, } from "@trails-cool/api"; /** GET /api/v1/activities — paginated activity list */ @@ -59,7 +60,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = CreateActivityRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const id = await createActivity(user.id, { diff --git a/apps/journal/app/routes/api.v1.routes.$id.ts b/apps/journal/app/routes/api.v1.routes.$id.ts index 8458312..86e3690 100644 --- a/apps/journal/app/routes/api.v1.routes.$id.ts +++ b/apps/journal/app/routes/api.v1.routes.$id.ts @@ -1,7 +1,7 @@ import type { Route } from "./+types/api.v1.routes.$id"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.server"; -import { UpdateRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { UpdateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; /** GET /api/v1/routes/:id — full route detail */ export async function loader({ request, params }: Route.LoaderArgs) { @@ -45,7 +45,7 @@ export async function action({ request, params }: Route.ActionArgs) { const parsed = UpdateRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } await updateRoute(params.id, user.id, parsed.data); diff --git a/apps/journal/app/routes/api.v1.routes._index.ts b/apps/journal/app/routes/api.v1.routes._index.ts index 8ce9821..5f72767 100644 --- a/apps/journal/app/routes/api.v1.routes._index.ts +++ b/apps/journal/app/routes/api.v1.routes._index.ts @@ -5,6 +5,7 @@ import { PaginationQuerySchema, CreateRouteRequestSchema, ERROR_CODES, + zodIssuesToFieldErrors, } from "@trails-cool/api"; /** GET /api/v1/routes — paginated route list */ @@ -58,7 +59,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = CreateRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const id = await createRoute(user.id, parsed.data); diff --git a/apps/journal/app/routes/api.v1.routes.compute.ts b/apps/journal/app/routes/api.v1.routes.compute.ts index 077d733..cd111ff 100644 --- a/apps/journal/app/routes/api.v1.routes.compute.ts +++ b/apps/journal/app/routes/api.v1.routes.compute.ts @@ -1,6 +1,6 @@ import type { Route } from "./+types/api.v1.routes.compute"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; -import { ComputeRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { ComputeRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001"; @@ -13,7 +13,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = ComputeRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } try { diff --git a/apps/journal/app/routes/api.v1.uploads.ts b/apps/journal/app/routes/api.v1.uploads.ts index 83a18da..4998bdc 100644 --- a/apps/journal/app/routes/api.v1.uploads.ts +++ b/apps/journal/app/routes/api.v1.uploads.ts @@ -1,6 +1,6 @@ import type { Route } from "./+types/api.v1.uploads"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; -import { PresignedUploadRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { PresignedUploadRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; import { randomUUID } from "node:crypto"; const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902"; @@ -16,7 +16,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = PresignedUploadRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const { filename, resourceType, resourceId } = parsed.data; diff --git a/apps/journal/package.json b/apps/journal/package.json index 62c0ca7..b036f5b 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -36,7 +36,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", - "zod": "^3.25.0" + "zod": "^4.0.0" }, "devDependencies": { "@react-router/dev": "catalog:", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a3b13dc..4bd2597 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -66,7 +66,7 @@ "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "use-latest-callback": "^0.3.3", - "zod": "^3.25.76" + "zod": "^4.0.0" }, "devDependencies": { "@testing-library/react-native": "^13.3.3", diff --git a/packages/api/package.json b/packages/api/package.json index 2e8b628..1c5432e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,6 @@ "typecheck": "tsc" }, "dependencies": { - "zod": "^3.25.0" + "zod": "^4.0.0" } } diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts index 479fbc6..e4f2420 100644 --- a/packages/api/src/activities.ts +++ b/packages/api/src/activities.ts @@ -2,24 +2,24 @@ import { z } from "zod"; /** Activity summary for list views */ export const ActivitySummarySchema = z.object({ - id: z.string().uuid(), + id: z.uuid(), name: z.string(), description: z.string(), - routeId: z.string().uuid().nullable(), + routeId: z.uuid().nullable(), routeName: z.string().nullable(), distance: z.number().nullable(), duration: z.number().nullable(), elevationGain: z.number().nullable(), elevationLoss: z.number().nullable(), - startedAt: z.string().datetime().nullable(), + startedAt: z.iso.datetime().nullable(), geojson: z.string().nullable(), - createdAt: z.string().datetime(), + createdAt: z.iso.datetime(), }); /** Full activity detail */ export const ActivityDetailSchema = ActivitySummarySchema.extend({ gpx: z.string().nullable(), - photos: z.array(z.string().url()), + photos: z.array(z.url()), }); /** Paginated activity list response */ @@ -33,8 +33,8 @@ export const CreateActivityRequestSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(5000).default(""), gpx: z.string().optional(), - routeId: z.string().uuid().optional(), - startedAt: z.string().datetime().optional(), + routeId: z.uuid().optional(), + startedAt: z.iso.datetime().optional(), duration: z.number().optional(), distance: z.number().optional(), }); diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 8440738..3f556f1 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -20,8 +20,8 @@ export const TokenResponseSchema = z.object({ export const DeviceSchema = z.object({ id: z.string(), deviceName: z.string().nullable(), - lastActiveAt: z.string().datetime(), - createdAt: z.string().datetime(), + lastActiveAt: z.iso.datetime(), + createdAt: z.iso.datetime(), isCurrent: z.boolean(), }); diff --git a/packages/api/src/discovery.ts b/packages/api/src/discovery.ts index 9680a9a..2f1ff2a 100644 --- a/packages/api/src/discovery.ts +++ b/packages/api/src/discovery.ts @@ -3,8 +3,8 @@ import { z } from "zod"; export const DiscoveryResponseSchema = z.object({ apiVersion: z.string(), instanceName: z.string(), - apiBaseUrl: z.string().url(), - tileUrl: z.string().url().optional(), + apiBaseUrl: z.url(), + tileUrl: z.url().optional(), }); export type DiscoveryResponse = z.infer; diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts index 7d7fb88..381dd95 100644 --- a/packages/api/src/errors.ts +++ b/packages/api/src/errors.ts @@ -14,6 +14,18 @@ export const ApiErrorResponseSchema = z.object({ export type FieldError = z.infer; export type ApiErrorResponse = z.infer; +/** + * Map a ZodError's issues to the FieldError[] shape used in API + * validation responses. Keeps the path-flattening rule in one place so + * every route returns validation errors in the same shape. + */ +export function zodIssuesToFieldErrors(error: z.ZodError): FieldError[] { + return error.issues.map((i) => ({ + field: i.path.join("."), + message: i.message, + })); +} + /** Standard error codes */ export const ERROR_CODES = { VALIDATION_ERROR: "VALIDATION_ERROR", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 8f7e100..a6e14a6 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -4,6 +4,7 @@ export { ENDPOINTS } from "./endpoints.ts"; // Error schemas export { ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES, + zodIssuesToFieldErrors, type ApiErrorResponse, type FieldError, } from "./errors.ts"; diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts index b9e64e5..dc83071 100644 --- a/packages/api/src/routes.ts +++ b/packages/api/src/routes.ts @@ -2,7 +2,7 @@ import { z } from "zod"; /** Route summary for list views */ export const RouteSummarySchema = z.object({ - id: z.string().uuid(), + id: z.uuid(), name: z.string(), description: z.string(), distance: z.number().nullable(), @@ -11,15 +11,15 @@ export const RouteSummarySchema = z.object({ routingProfile: z.string().nullable(), dayBreaks: z.array(z.number()), geojson: z.string().nullable(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), }); /** Route version info */ export const RouteVersionSchema = z.object({ version: z.number(), changeDescription: z.string().nullable(), - createdAt: z.string().datetime(), + createdAt: z.iso.datetime(), }); /** Full route detail */ diff --git a/packages/api/src/uploads.ts b/packages/api/src/uploads.ts index 98f8927..37da2f1 100644 --- a/packages/api/src/uploads.ts +++ b/packages/api/src/uploads.ts @@ -4,13 +4,13 @@ export const PresignedUploadRequestSchema = z.object({ filename: z.string(), contentType: z.string(), resourceType: z.enum(["route", "activity"]), - resourceId: z.string().uuid(), + resourceId: z.uuid(), }); export const PresignedUploadResponseSchema = z.object({ - uploadUrl: z.string().url(), + uploadUrl: z.url(), storageKey: z.string(), - expiresAt: z.string().datetime(), + expiresAt: z.iso.datetime(), }); export type PresignedUploadRequest = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c61e98a..3cd855d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,8 +267,8 @@ importers: specifier: 'catalog:' version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) zod: - specifier: ^3.25.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@react-router/dev': specifier: 'catalog:' @@ -418,8 +418,8 @@ importers: specifier: ^0.3.3 version: 0.3.3(react@19.2.5) zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@testing-library/react-native': specifier: ^13.3.3 @@ -576,8 +576,8 @@ importers: packages/api: dependencies: zod: - specifier: ^3.25.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 packages/db: dependencies: @@ -7671,6 +7671,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -15661,3 +15664,5 @@ snapshots: yocto-queue@0.1.0: {} zod@3.25.76: {} + + zod@4.3.6: {}