mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
65fe5c34e3
commit
6f60812132
10 files changed
+197
-42
No files matched your search
+103
-19
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user