Skip to content

i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me #45

Description

@mforce

Rescoped: #45 is the API half of i18n infrastructure. The SPA half is #182 and lands after this — it depends on GET /me (the user's language) and the errorCodes extension. Ships English-only; first language pack is Phase 1.5 (epic #15).

Part of epic #14 (Phase 1.1). Spec: §4.5 "UI language vs farm locale", §5.2, §24, §6 (item 13), UC-012; tech spec §3.2.

Plan hardened after a codex (repo-aware) + pi review. Corrections from that pass are marked ⓡ.

Decisions

  • users.language = nullable BCP-47 primary-language subtag on ApplicationUser, stored lowercase. NOT a full locale — regional variants stay a farms.locale formatting concern (§4.5).
  • Language rides GET /me, not the JWT. Role stays JWT-derived (re-read on refresh, F19: Admin-gate corrective/destructive actions — stepping stone to full RBAC (#73) #78) — unchanged. A change takes effect on the next catalog load.
  • Every authenticated user — Read-only included — may update their own language (self-service, not admin-gated, not farm-scoped).
  • ⓡ Update verb is PUT /api/v1/me/language, not PATCH /me. PATCH on a nullable field has an absent-vs-null ambiguity (a missing nullable JSON property binds as null → silent clear). PUT of one absolute preference is unambiguous.
  • Format validation only: a well-formed subtag is stored; "is there a pack" is the SPA's resolution concern (i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker) #182), where a stored-but-unsupported value is treated as unset, never an error.
  • Error codes are additive and explicit-only (see below).

Technical approach

1. users.language column

  • Nullable varchar on ApplicationUser (ASP.NET Identity). EF migration.
  • ⓡ Validation grammar (FluentValidation): null OR 2–8 ASCII letters (^[A-Za-z]{2,8}$, the RFC 5646 primary-subtag width), case-insensitive input, trimmed and lowercased on store. Edge cases pinned: null clears; absent property is a 400 (PUT requires the field); empty/whitespace is invalid (not another spelling of null); en-US is rejected (regional variant, not a primary subtag).

2. GET /api/v1/me

  • New user-scoped group (MeEndpoints), identity from the token — distinct from farm-scoped /account.
  • Returns { id, email, role, language }. ⓡ Audit what the SPA actually displays about the user (does anything show a display name?) and add name/displayName if so, rather than making the SPA parse the JWT for it. Role echoed for convenience; JWT stays authoritative.

3. PUT /api/v1/me/language

  • Body { language: string | null }, validated as §1. Open to every role incl. Read-only — the write-authorization gate must explicitly permit it.
  • ⓡ Idempotency namespace bug — fix required. IdempotencyMiddleware keys by (AccountId, method+path, key) with no user id (IdempotencyMiddleware.cs:43-56). Two users in the same account sending the same key to /me/language → the second replays the first's stored 2xx and skips their own update. This endpoint's idempotency scope must include the current user id. Ship with a cross-user/same-key integration test. (Writes still require an Idempotency-Key per the app-wide middleware; the SPA mints a fresh UUID per call, so key reuse across different bodies does not arise in practice — the user-scope fix closes the cross-user hole regardless.)

4. Per-field validation errorCodes (additive)

  • 400 ValidationProblem keeps errors: { field: ["English message"] } unchanged; gains a parallel errorCodes: { field: ["Code"] }. Message = English fallback; code = the stable contract. Backward-compatible (old SPA ignores the extra member).
  • ⓡ Emit codes only where an EXPLICIT code was assigned. FluentValidation's default codes (NotEmptyValidator, …) are framework internals and must NOT be exposed as stable contract. A field/rule without an intentional code emits no errorCodes entry (its English message still appears in errors).
  • ⓡ Code convention <Feature>.<Field>.<Rule> (e.g. Me.Language.Format) — a bare Field.Rule collides across endpoints.
  • ⓡ Preserve per-field array-index alignment between errors[field][i] (message) and errorCodes[field][i] (code) so the client can pair a code with its English message.
  • ⓡ The real surface is 31 validator classes / 35 ValidationProblem call sites, 4 hand-built (non-FluentValidation) validation responses, and malformed-JSON/model-binding 400s that bypass all of them — not "~15." Scope decisions:
    • This slice ships: the shared errorCodes response helper; swaps the 35 FV call sites to it (additive, errors unchanged); wires the 4 manual responses through the same shape; and assigns explicit codes to the new /me/language validator as the worked example.
    • Malformed JSON / type-binding 400s are explicitly OUT of the coded contract (they are malformed requests, not field validation) — documented, not silently uncovered.
    • Retrofitting explicit codes onto the other 30 validators is incremental and tracked (checklist below), NOT a big-bang — but until a validator has explicit codes, it emits none (no default-code leakage).
    • ⓡ Interpolation limitation, decided now: codes carry no arguments, so a translated message cannot reconstruct "maximum 256 characters." Phase 1.1 keeps the English message as the fallback for such cases; if Phase 1.5 needs interpolated translations, add a structured errorArgs (or error-item) extension THEN. Recorded so it is a conscious deferral, not a surprise.

Scope

  • users.language column (nullable varchar) + migration
  • GET /me — identity + language (+ name if the SPA displays one)
  • PUT /me/language — self-service (all roles incl. Read-only), user-scoped idempotency
  • Language validation (2–8 letters or null; lowercased; absent/empty/en-US handled)
  • Shared additive errorCodes helper; swap 35 FV call sites; route the 4 manual responses through it; explicit codes on the /me/language validator
  • Fix IdempotencyMiddleware to include user id for user-scoped write endpoints (or a per-endpoint override) + cross-user test
  • Docs: GLOSSARY (UI language vs farm locale) + Help (a "your language" note) + specs §3.2/§4.5
  • Tests: column round-trip, null↔value round-trip, GET /me shape incl. language, PUT /me/language for every role incl. Read-only, validation rejects (absent/empty/en-US/too-long), cross-user same-key idempotency, errorCodes present+aligned on a coded 400, no default-code leakage on an uncoded field

Incremental (tracked, before epic #14 closes) — explicit codes on the remaining 30 validators

Enumerate and assign <Feature>.<Field>.<Rule> codes. Can be split into its own migration issue when scheduled; listed here so the surface is KNOWN, not discovered later.

Out of scope

Sequencing

Lands first; #182 depends on GET /me + the errorCodes extension.

Activity

  1. changed the title [-]i18n infrastructure — externalized UI strings, API error codes, users.language[/-] [+]i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me[/+] on Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions