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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 09:19:56 +01:00
1 parent 65fe5c34e3
commit 6f60812132
10 files changed
+197 -42

No files matched your search

+103 -19
View File
@@ -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:
@@ -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
@@ -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);
});
});
+7 -1
View File
@@ -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
+20 -5
View File
@@ -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");
+5 -2
View File
@@ -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(),
+3 -3
View File
@@ -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
+13 -6
View File
@@ -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" });
+1 -1
View File
@@ -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"])
+5 -4
View File
@@ -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)