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)