From 6f608121326fc6e0059e2a5a90cdff5f7c67d04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:19:56 +0100 Subject: [PATCH] fix(ai): accept every app language on the chat and status requests The Coach chat, the document chat and the fenced chat declared their locale field as de/en, so a body naming French, Korean, Brazilian Portuguese or any other shipped language was refused with 422 before resolveServerLocale, which carries every locale, ever saw it. The same pair sat on the profile update, so an account could not store any other language there either, and the stored language is what the cookie-blind background work (the Coach nudge, notification texts) writes in. All four now take z.enum(locales), and the published contract for the metric, insight and biomarker status queries and the profile update lists every UI locale instead of two. --- docs/api/openapi.yaml | 122 +++++++++++++++--- .../insights/coach-panel/use-coach.ts | 3 +- .../__tests__/chat-request-locale.test.ts | 38 ++++++ src/lib/ai/coach/types.ts | 8 +- src/lib/auth/__tests__/profile-update.test.ts | 25 +++- src/lib/auth/profile-update.ts | 7 +- src/lib/insights/status-shared.ts | 6 +- src/lib/openapi/routes/insights/schemas.ts | 19 ++- src/lib/openapi/routes/profile.ts | 2 +- src/lib/validations/inbound-documents.ts | 9 +- 10 files changed, 197 insertions(+), 42 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/chat-request-locale.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d17701724..ae46ddc4b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13216,12 +13216,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13248,12 +13255,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13280,12 +13294,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13312,12 +13333,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13344,12 +13372,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13377,12 +13412,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Compliance assessment envelope (fresh, cached, or preparing). @@ -13472,12 +13514,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -13517,12 +13566,19 @@ paths: - in: query name: locale schema: - description: Optional UI-locale override; defaults to the session locale. + description: Optional UI-locale override, any language the app ships; defaults to the session locale. type: string enum: - de - en - description: Optional UI-locale override; defaults to the session locale. + - fr + - es + - it + - pl + - ko + - nl + - pt-BR + description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). @@ -28539,6 +28595,13 @@ components: enum: - de - en + - fr + - es + - it + - pl + - ko + - nl + - pt-BR - type: "null" timezone: type: string @@ -31364,8 +31427,15 @@ components: locale: type: string enum: - - en - de + - en + - fr + - es + - it + - pl + - ko + - nl + - pt-BR scope: type: object properties: @@ -31528,8 +31598,15 @@ components: locale: type: string enum: - - en - de + - en + - fr + - es + - it + - pl + - ko + - nl + - pt-BR attachmentIds: minItems: 1 maxItems: 5 @@ -35519,8 +35596,15 @@ components: locale: type: string enum: - - en - de + - en + - fr + - es + - it + - pl + - ko + - nl + - pt-BR required: - message InboundFactEditRequest: diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 71139164b..9d453a7a9 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -1,5 +1,6 @@ "use client"; +import type { Locale } from "@/lib/i18n/config"; import { useCallback, useEffect, @@ -895,7 +896,7 @@ export interface SendCoachMessageParams { conversationId?: string; message: string; prefill?: string; - locale?: "en" | "de"; + locale?: Locale; /** * v1.4.20.1 — optional snapshot scope (per-source toggles + window) * driven by the sources-rail picker. When omitted the server falls diff --git a/src/lib/ai/coach/__tests__/chat-request-locale.test.ts b/src/lib/ai/coach/__tests__/chat-request-locale.test.ts new file mode 100644 index 000000000..d7fd5a613 --- /dev/null +++ b/src/lib/ai/coach/__tests__/chat-request-locale.test.ts @@ -0,0 +1,38 @@ +/** + * The chat request bodies take every language the app ships. + * + * The Coach chat, the document chat and the fenced chat declared `locale` as + * the pair de/en, so a body naming any other app language (French, Korean, + * Brazilian Portuguese, …) was refused with 422 before the reply language was + * ever resolved, although `resolveServerLocale` and the prompts downstream + * carry every locale. + */ +import { describe, expect, it } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { coachChatRequestSchema } from "@/lib/ai/coach/types"; +import { + documentChatRequestSchema, + fencedChatRequestSchema, +} from "@/lib/validations/inbound-documents"; + +const SCHEMAS = { + coach: (locale: string) => + coachChatRequestSchema.safeParse({ message: "Hello", locale }), + document: (locale: string) => + documentChatRequestSchema.safeParse({ message: "Hello", locale }), + fenced: (locale: string) => + fencedChatRequestSchema.safeParse({ message: "Hello", locale }), +}; + +describe.each(Object.entries(SCHEMAS))("%s chat request", (_name, parse) => { + it.each([...locales])("accepts %s", (locale) => { + const parsed = parse(locale); + expect(parsed.success).toBe(true); + expect(parsed.data?.locale).toBe(locale); + }); + + it("refuses a language the app does not ship", () => { + expect(parse("xx").success).toBe(false); + }); +}); diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index f6bf822e2..b09114597 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -8,6 +8,8 @@ */ import { z } from "zod/v4"; +import { locales } from "@/lib/i18n/config"; + import { COACH_SCOPE_SOURCES } from "./scope-sources"; import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; @@ -85,7 +87,11 @@ export const coachChatRequestSchema = z.object({ conversationId: z.string().min(1).max(64).optional(), message: z.string().min(1).max(4000), prefill: z.string().max(2000).optional(), - locale: z.enum(["en", "de"]).optional(), + /** + * The reply language: any UI locale. Overrides the cookie and the account's + * stored language for this turn (`resolveServerLocale`). + */ + locale: z.enum(locales).optional(), scope: coachScopeSchema.optional(), /** * v1.16.6 — guided clarifying-questions flow: the pending question diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index 2c4846a66..005c142ab 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -56,6 +56,21 @@ beforeEach(() => { ] as never); }); +describe("applyProfileUpdate locale", () => { + it.each(["fr", "pt-BR", "ko"])( + "stores %s, a language the app ships", + async (locale) => { + const result = await applyProfileUpdate(USER_ID, { locale }); + expect(result.ok).toBe(true); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ locale }), + }), + ); + }, + ); +}); + describe("applyProfileUpdate timezone validation", () => { it("accepts a valid IANA zone", async () => { const result = await applyProfileUpdate(USER_ID, { @@ -271,7 +286,7 @@ describe("applyProfileUpdate partial-field save", () => { it("writes nothing and reports every rejected field when the whole body is invalid", async () => { const result = await applyProfileUpdate(USER_ID, { gender: "male", - locale: "fr", + locale: "xx", }); expect(result.ok).toBe(false); if (!result.ok) { @@ -290,15 +305,15 @@ describe("applyProfileUpdate top-level error message", () => { // sharpest regression check available for "does the raw Zod default // ever reach the top-level string a person reads". Zod v4's default // enum message is shaped like: - // Invalid option: expected one of "de"|"en" + // Invalid option: expected one of "de"|"en"|"fr"|… const RAW_ZOD_ENUM_PATTERN = /expected one of/i; it("never lets the raw Zod enum message reach the top-level `message`", async () => { - const result = await applyProfileUpdate(USER_ID, { locale: "fr" }); + const result = await applyProfileUpdate(USER_ID, { locale: "xx" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN); - expect(result.message).not.toContain('"de"|"en"'); + expect(result.message).not.toContain('"de"|"en"|"fr"'); // Relocated, not deleted: the same detail is still available for // whoever needs it (the iOS `details.issues` contract), just not // in the string a person reads first. @@ -318,7 +333,7 @@ describe("applyProfileUpdate top-level error message", () => { }); it("carries a stable errorCode so the web client can localize the sentence", async () => { - const result = await applyProfileUpdate(USER_ID, { locale: "fr" }); + const result = await applyProfileUpdate(USER_ID, { locale: "xx" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errorCode).toBe("profile.update.nothingSaved"); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 6d2b43c0a..532fc382e 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -17,6 +17,7 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response"; import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { z } from "zod/v4"; +import { locales } from "@/lib/i18n/config"; import { REPROOF_FAILED_CODE, REPROOF_REQUIRED_CODE, @@ -26,9 +27,11 @@ import { const extendedProfileSchema = profileSchema.extend({ // Trimmed first; "" (or only spaces) clears the name to null below. displayName: optionalDisplayNameSchema.nullable().optional(), + // Every language the app ships: the stored language is what background + // work (the Coach nudge, the notification texts) writes in. locale: z - .enum(["de", "en"], { - error: "Locale must be de or en.", + .enum(locales, { + error: "Locale must be one of the languages the app ships.", }) .nullable() .optional(), diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 8e8b8fc90..e82f19c20 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -8,7 +8,7 @@ * to the rounding precision, the chart-token scrub, or the cache-row * shape lands once rather than seven times. * - * `SupportedLocale` is the full six-locale UI union. The status prompts + * `SupportedLocale` is the full UI locale union. The status prompts * compose one of two reviewed instruction bodies (de / en) but name the * reader's own language in an explicit output directive, so the reader's * locale must survive the whole pipeline rather than being collapsed to a @@ -29,7 +29,7 @@ import { } from "@/lib/ai/safety/outbound-screen"; /** - * The locales the assessment pipeline carries end-to-end — all six the UI + * The locales the assessment pipeline carries end-to-end — every one the UI * ships. Aliased to the i18n `Locale` so adding a UI locale widens the * pipeline in one place rather than needing a second edit here. */ @@ -108,7 +108,7 @@ export function normalizeSummaryText(value: string): string { } /** - * Validate an arbitrary locale string against the six the UI ships. + * Validate an arbitrary locale string against every locale the UI ships. * * A recognised locale passes through UNCHANGED — that is the whole point: * the former `value === "de" ? "de" : "en"` binary erased a French reader's diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index d18bb5dd4..8e2baeb9f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -8,6 +8,7 @@ import { recoveryEmptySchema } from "../recovery-empty"; import { trendSentiment, trendVerdict } from "../trend-verdict"; import { z } from "zod/v4"; +import { locales } from "@/lib/i18n/config"; import { measurementSourceEnum, measurementTypeEnum } from "../shared"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; import { @@ -45,9 +46,11 @@ export const metricStatusQuery = z "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", ), locale: z - .enum(["de", "en"]) + .enum(locales) .optional() - .describe("Optional UI-locale override; defaults to the session locale."), + .describe( + "Optional UI-locale override, any language the app ships; defaults to the session locale.", + ), }) .meta({ id: "MetricStatusQuery" }); @@ -792,9 +795,11 @@ export const glp1PlateauResponse = z export const insightStatusQuery = z .object({ locale: z - .enum(["de", "en"]) + .enum(locales) .optional() - .describe("Optional UI-locale override; defaults to the session locale."), + .describe( + "Optional UI-locale override, any language the app ships; defaults to the session locale.", + ), }) .meta({ id: "InsightStatusQuery" }); @@ -856,9 +861,11 @@ export const biomarkerAssessmentQuery = z "User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).", ), locale: z - .enum(["de", "en"]) + .enum(locales) .optional() - .describe("Optional UI-locale override; defaults to the session locale."), + .describe( + "Optional UI-locale override, any language the app ships; defaults to the session locale.", + ), }) .meta({ id: "BiomarkerAssessmentQuery" }); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index a19f2eb14..204bec3a1 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -554,7 +554,7 @@ export const profileUpdateRequest = z .describe( "Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched.", ), - locale: z.enum(["de", "en"]).nullable().optional(), + locale: z.enum(locales).nullable().optional(), timezone: z.string().min(1).max(64).optional(), timeFormat: z .enum(["AUTO", "H12", "H24"]) diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 83b008c37..435646d73 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -24,6 +24,7 @@ * it is always narrowed from the session. */ import { z } from "zod/v4"; +import { locales } from "@/lib/i18n/config"; import { DOCUMENT_BULK_MAX_IDS, DOCUMENT_CONTENT_INDEX_SOURCES, @@ -944,14 +945,14 @@ export const DOCUMENT_CHAT_MESSAGE_MAX = 4000; * Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no * `documentId` field — the user is narrowed from the session and the document id * comes from the path. `conversationId` continues an existing thread scoped to - * this document; omitting it starts a new one. `locale` is the reply language - * (the document chat, like the Coach, replies in de/en). + * this document; omitting it starts a new one. `locale` is the reply language, + * any UI locale, like the Coach's. */ export const documentChatRequestSchema = z .object({ conversationId: z.string().trim().min(1).max(64).optional(), message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX), - locale: z.enum(["en", "de"]).optional(), + locale: z.enum(locales).optional(), }) .meta({ id: "DocumentChatRequest" }); @@ -990,7 +991,7 @@ export const fencedChatRequestSchema = z .object({ conversationId: z.string().trim().min(1).max(64).optional(), message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX), - locale: z.enum(["en", "de"]).optional(), + locale: z.enum(locales).optional(), attachmentIds: z .array(z.string().trim().min(1).max(64)) .min(1)