feat(api): zod schema error messages → i18n catalog keys (#212) #246

Merged
james merged 1 commit from 212-zod-i18n into main 2026-06-23 13:22:40 +00:00
Owner

Summary

Resolves ADR-0025 §6 by moving every zod validator off inline English messages onto a small, finite ZodIssueCode → catalog key map. The server's ProblemFieldError now carries the catalog key + args alongside the legacy message; the universal client renders through a new formatApiError helper and flips with the active locale.

Mapping

ZodIssueCode Discriminator Catalog key Args
invalid_type errors.zod.invalidType { expected }
too_small minimum <= 1 errors.zod.required
too_small minimum > 1 errors.zod.tooShort { min }
too_big errors.zod.tooLong { max }
invalid_format format: email errors.zod.invalidEmail
invalid_format format: url errors.zod.invalidUrl
invalid_format format: datetime errors.zod.invalidDateTime
invalid_format other errors.zod.invalidFormat
invalid_value errors.zod.invalidValue
unrecognized_keys errors.zod.unrecognizedKeys
custom params.key passes through other primitive params
anything else errors.zod.invalid

The helper is wired up globally via z.config({ customError }) so every safeParse(...) across the codebase resolves to a key without per-route opt-in. .refine() cases surface their key via { params: { key: "errors.foo.bar" } }.

Files swept

  • New: apps/api/lib/api/zodErrorMap.ts, apps/api/tests/lib/api/zodErrorMap.test.ts, apps/client/lib/api/formatApiError.ts, apps/client/tests/formatApiError.test.ts.
  • DTOs: lib/dto/{auth,auth-token,education,job,note,pat,profile,settings,skill,user}.ts — every inline .min/.max/.regex/.email/.refine message removed; .refine() annotated with params: { key }.
  • API: lib/api/errors.ts builds the new field-error shape; lib/api/openapi.ts extends ProblemFieldError with code / key / args.
  • Client: login.tsx, register.tsx, (app)/notes.tsx render via formatApiError.
  • Catalog: errors.zod.* namespace added to packages/i18n/messages/{en,es}.json.
  • Docs: CLAUDE.md i18n bullet refreshed; ADR-0025 §6 marked resolved by #212; docs/api-conventions.md example + field doc updated.
  • Regenerated openapi.json + packages/api-client/src/generated/schema.d.ts.

Test plan

  • pnpm -F @carol/api test — 568 passing (zodErrorMap unit tests + extended notes / auth integration tests)
  • pnpm -F @carol/api typecheck / lint
  • pnpm -F @carol/api openapi:check / openapi:coverage
  • pnpm -F @carol/api-client test / typecheck / lint / check
  • pnpm -F @carol/client test / typecheck / lint / export:web
  • apps/client/tests/formatApiError.test.ts — asserts the visible string flips when the locale changes (en → es)

Links: #212, ADR-0025 §6.

🤖 Generated with Claude Code

## Summary Resolves ADR-0025 §6 by moving every zod validator off inline English messages onto a small, finite `ZodIssueCode → catalog key` map. The server's `ProblemFieldError` now carries the catalog key + args alongside the legacy message; the universal client renders through a new `formatApiError` helper and flips with the active locale. ## Mapping | `ZodIssueCode` | Discriminator | Catalog key | Args | |---|---|---|---| | `invalid_type` | — | `errors.zod.invalidType` | `{ expected }` | | `too_small` | `minimum <= 1` | `errors.zod.required` | — | | `too_small` | `minimum > 1` | `errors.zod.tooShort` | `{ min }` | | `too_big` | — | `errors.zod.tooLong` | `{ max }` | | `invalid_format` | `format: email` | `errors.zod.invalidEmail` | — | | `invalid_format` | `format: url` | `errors.zod.invalidUrl` | — | | `invalid_format` | `format: datetime` | `errors.zod.invalidDateTime` | — | | `invalid_format` | other | `errors.zod.invalidFormat` | — | | `invalid_value` | — | `errors.zod.invalidValue` | — | | `unrecognized_keys` | — | `errors.zod.unrecognizedKeys` | — | | `custom` | `params.key` | passes through | other primitive `params` | | anything else | — | `errors.zod.invalid` | — | The helper is wired up globally via `z.config({ customError })` so every `safeParse(...)` across the codebase resolves to a key without per-route opt-in. `.refine()` cases surface their key via `{ params: { key: "errors.foo.bar" } }`. ## Files swept - New: `apps/api/lib/api/zodErrorMap.ts`, `apps/api/tests/lib/api/zodErrorMap.test.ts`, `apps/client/lib/api/formatApiError.ts`, `apps/client/tests/formatApiError.test.ts`. - DTOs: `lib/dto/{auth,auth-token,education,job,note,pat,profile,settings,skill,user}.ts` — every inline `.min/.max/.regex/.email/.refine` message removed; `.refine()` annotated with `params: { key }`. - API: `lib/api/errors.ts` builds the new field-error shape; `lib/api/openapi.ts` extends `ProblemFieldError` with `code` / `key` / `args`. - Client: `login.tsx`, `register.tsx`, `(app)/notes.tsx` render via `formatApiError`. - Catalog: `errors.zod.*` namespace added to `packages/i18n/messages/{en,es}.json`. - Docs: `CLAUDE.md` i18n bullet refreshed; ADR-0025 §6 marked resolved by #212; `docs/api-conventions.md` example + field doc updated. - Regenerated `openapi.json` + `packages/api-client/src/generated/schema.d.ts`. ## Test plan - [x] `pnpm -F @carol/api test` — 568 passing (zodErrorMap unit tests + extended notes / auth integration tests) - [x] `pnpm -F @carol/api typecheck` / `lint` - [x] `pnpm -F @carol/api openapi:check` / `openapi:coverage` - [x] `pnpm -F @carol/api-client test` / `typecheck` / `lint` / `check` - [x] `pnpm -F @carol/client test` / `typecheck` / `lint` / `export:web` - [x] `apps/client/tests/formatApiError.test.ts` — asserts the visible string flips when the locale changes (en → es) Links: #212, ADR-0025 §6. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api): zod schema error messages → i18n catalog keys (#212)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 1m30s
PR / pnpm audit (pull_request) Successful in 2m0s
PR / Lint (pull_request) Successful in 2m20s
PR / OpenAPI (pull_request) Successful in 2m28s
PR / Static analysis (pull_request) Successful in 2m28s
PR / Client (web export smoke) (pull_request) Successful in 2m37s
PR / Typecheck (pull_request) Successful in 2m52s
PR / Test (postgres) (pull_request) Successful in 3m4s
PR / Test (sqlite) (pull_request) Successful in 3m4s
PR / Build (pull_request) Successful in 3m13s
PR / Package age policy (soft) (pull_request) Successful in 53s
Secrets / gitleaks (pull_request) Successful in 50s
PR / Trivy (image) (pull_request) Failing after 1m55s
PR / Coverage (soft) (pull_request) Successful in 1m40s
5fc6bcb60d
Migrate every zod validator off inline English strings onto the new
`apps/api/lib/api/zodErrorMap.ts` helper. The map translates each
`ZodIssueCode` to a catalog key under `errors.zod.*`, surfaces the
key + args on `ProblemFieldError`, and lets the client render
through `formatApiError` for full i18n + locale switching.

Resolves ADR-0025 §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 83.0% ≥ 50%
Branches 75.6% ≥ 75%
Functions 91.4% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 83.0% ✅ | ≥ 50% | | Branches | 75.6% ✅ | ≥ 75% | | Functions | 91.4% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 38e5459d7b into main 2026-06-23 13:22:40 +00:00
james deleted branch 212-zod-i18n 2026-06-23 13:22:40 +00:00
Sign in to join this conversation.
No description provided.