diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 1005fd8ea..57fb570ad 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -45,6 +45,12 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), }, + // The greeting salutation is one of the two strings this route still + // translates server-side; `resolveJobLocale` reads the operator's + // configured default from here when the user's column is NULL. + appSettings: { + findUnique: vi.fn(async () => null), + }, // v1.4.38 W-F — the route now uses `$queryRaw` for the per-type // latest reading, the 7-day rollup-bucket sparkline, and the // 365-day distinct-day streak set. Three raw calls per request, @@ -1089,6 +1095,46 @@ describe("GET /api/dashboard/summary", () => { * source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the * client consumes a finished figure instead of re-deriving one. */ +describe("GET /api/dashboard/summary — greeting locale", () => { + // The salutation and the streak label are translated on the server, and + // the native client is this route's only caller: a Bearer request carries + // no locale cookie, so `User.locale` is the sole per-user signal. When it + // is NULL the operator's configured default is the answer — the same + // fallback order every background writer uses. The coercion this replaces + // stopped at English, so a German instance greeted a user who never opened + // the language picker in English while the rest of their screen was German. + it("falls back to the operator default, not to English", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultLocale: "de", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { greeting: { salutation: string } }; + }; + expect(body.data.greeting.salutation).toBe("Hallo, testuser"); + }); + + it("still prefers the user's own stored locale", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, locale: "en" }, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultLocale: "de", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { greeting: { salutation: string } }; + }; + expect(body.data.greeting.salutation).toBe("Hi, testuser"); + }); +}); + describe("GET /api/dashboard/summary — mood + BMI cards", () => { function cardsOf(body: unknown): Array> { return (body as { data: { metrics: Array> } }).data diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index b596e17fb..a80dbc6de 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -61,7 +61,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { type Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; @@ -343,7 +344,8 @@ export const GET = apiHandler(async () => { Awaited> >, `${user.id}|dashboard-summary`, - () => buildDashboardSummary(user.id, userTz, buildContext(user)), + async () => + buildDashboardSummary(user.id, userTz, await buildContext(user)), annotate, ); @@ -383,23 +385,22 @@ interface SummaryBuilderContext { locale: Locale; } -function buildContext(user: { +async function buildContext(user: { displayName: string | null; username: string; locale: string | null; -}): SummaryBuilderContext { +}): Promise { const greetingName = user.displayName ?? user.username; - // v1.5.x — accept every shipped locale (de / en / es / fr / it / pl) - // so the greeting + streak label resolve against the user's - // configured language. The legacy narrowing dropped fr/es/it/pl back - // to the default; that bug is masked for the title/unit fields by - // the new key-based wire shape but still mattered for the strings - // that stay translated server-side. - const locale: Locale = (locales as readonly string[]).includes( - user.locale ?? "", - ) - ? (user.locale as Locale) - : defaultLocale; + // The greeting salutation and the streak label are the two strings this + // route still translates server-side, and the native client is its only + // caller — a Bearer request carries no locale cookie and no meaningful + // Accept-Language, so the stored column is the only per-user signal. + // That is exactly the shape `resolveJobLocale` exists for: stored user + // locale, then the operator's configured default, then English. The + // hand-rolled coercion this replaces stopped at English, so a German + // instance whose user never touched the language picker greeted them in + // English while every other string on the same screen was German. + const locale: Locale = await resolveJobLocale(user.locale); return { greetingName, locale }; }