From f003b6f41312b93a424920ece8da5292546c20bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 10:44:04 +0200 Subject: [PATCH] Resolve job-side locale through the operator default Every background path that resolved a user row's locale collapsed a NULL column to English: the nightly briefing warm, the status crons, the period-narrative warm, reaction lines, the morning refresh, the briefing push, coach nudges, the reminder workers, and the document and workout summaries each carried their own copy of `locales.includes(x) ? x : defaultLocale` or called `normalizeLocale(user.locale)`. None consulted the instance's configured default language, so an account that never touched the language picker got English prose and English pushes on a German-language instance. Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale when valid, else the operator's app_settings.default_locale when valid, else English. The settings read lives beside the other app-settings accessors. Every job and daily path resolves through it once, where it has the row; the template helpers keep coerceLocale for a value that is already resolved, and queue payloads keep normalizeLocale. The briefing push hands its resolved locale down to the digest, and the digest to the snapshot read, so the push copy and the digest lines are in the same language instead of two different fallbacks. A structural test walks src/lib/jobs and src/lib/daily and fails on a hand-rolled coercion, a validator applied to a row column, or a row reader that does not import the helper. It was red against the previous tree on all three counts. --- .../job-locale-resolution-guard.test.ts | 129 ++++++++++++++++++ src/lib/app-settings.ts | 19 +++ .../__tests__/daily-briefing-push.test.ts | 4 +- src/lib/daily/daily-briefing-push.ts | 26 ++-- src/lib/daily/load-digest.ts | 18 +-- src/lib/i18n/__tests__/job-locale.test.ts | 52 +++++++ src/lib/i18n/job-locale.ts | 34 +++++ src/lib/jobs/coach-nudge.ts | 14 +- src/lib/jobs/cycle-reminder.ts | 14 +- src/lib/jobs/document-summary.ts | 13 +- src/lib/jobs/insight-pregenerate.ts | 24 +--- src/lib/jobs/measurement-reminder.ts | 14 +- src/lib/jobs/medication-low-stock.ts | 16 +-- src/lib/jobs/mood-reminder.ts | 16 +-- src/lib/jobs/morning-digest-refresh.ts | 8 +- src/lib/jobs/period-narrative-warm.ts | 21 +-- src/lib/jobs/reaction-line.ts | 11 +- src/lib/jobs/reminder/insights-handlers.ts | 13 +- .../reminder/medication-reminder-check.ts | 6 +- src/lib/jobs/workout-insight-generate.ts | 9 +- 20 files changed, 322 insertions(+), 139 deletions(-) create mode 100644 src/__tests__/job-locale-resolution-guard.test.ts create mode 100644 src/lib/i18n/__tests__/job-locale.test.ts create mode 100644 src/lib/i18n/job-locale.ts diff --git a/src/__tests__/job-locale-resolution-guard.test.ts b/src/__tests__/job-locale-resolution-guard.test.ts new file mode 100644 index 000000000..80167cfa0 --- /dev/null +++ b/src/__tests__/job-locale-resolution-guard.test.ts @@ -0,0 +1,129 @@ +/** + * Structural guard: a job or worker resolves a user row's locale through + * `resolveJobLocale`, never through a bare fallback to English. + * + * Background paths have no request, so nothing but the stored `User.locale` + * tells them which language to write in. When that column is NULL the old + * hand-rolled coercions (`locales.includes(x) ? x : defaultLocale`, + * `normalizeLocale(user.locale)`) all landed on English, and the operator's + * configured default locale was never consulted. The nightly briefing warm + * then wrote English prose into a cache that a German reader was served from. + * + * `resolveJobLocale` is the one place the fallback order lives: stored user + * locale, then the operator default, then English. This guard keeps every + * job path on it. Three tripwires, none of them a proof: + * + * T1 — no file under `src/lib/jobs` or `src/lib/daily` hand-rolls the + * coercion with `locales.includes(`. + * T2 — no such file applies `normalizeLocale` / `coerceLocale` / + * `resolveLocale` straight to a row column (`.locale`). Those + * validators are for a value that is already resolved, such as a job + * payload. + * T3 — every such file that reads a row's `.locale` imports the helper. + * + * What slips past: a row column read into a local variable first and then + * coerced, or handed raw to a template function whose parameter still + * accepts `string | null`. The matchers are grep-shaped on purpose; a + * reviewer still reads the diff. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const ROOTS = ["lib/jobs", "lib/daily"] as const; + +function jobFiles(): string[] { + return ROOTS.flatMap((root) => + walkSourceFiles(join(SRC, root), { floor: 5 }) + .filter((rel) => !rel.includes("__tests__")) + .filter((rel) => !rel.endsWith(".test.ts")) + .map((rel) => `${root}/${rel}`), + ).sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** + * Receivers whose `.locale` is a value already resolved upstream — a queue + * payload, an options bag, a template input — rather than a database row. + */ +const RESOLVED_RECEIVERS = new Set([ + "data", + "payload", + "options", + "opts", + "input", + "params", + "prepared", + "job", +]); + +const ROW_LOCALE_READ = /\b([A-Za-z_$][\w$]*)\??\.locale\b(?!\s*[:=][^=])/g; + +function rowLocaleReceivers(text: string): string[] { + const receivers = new Set(); + for (const match of text.matchAll(ROW_LOCALE_READ)) { + const receiver = match[1]; + if (RESOLVED_RECEIVERS.has(receiver)) continue; + receivers.add(receiver); + } + return [...receivers].sort(); +} + +describe("job-side locale resolution goes through resolveJobLocale", () => { + const files = jobFiles(); + + it("walks a non-trivial set of job and daily files", () => { + expect(files.length).toBeGreaterThan(20); + }); + + it("T1 — no job file hand-rolls the locale coercion with locales.includes(", () => { + const offenders = files.filter((rel) => + /\blocales\.includes\(/.test(read(rel)), + ); + expect(offenders).toEqual([]); + }); + + it("T2 — no job file applies a bare validator to a row's locale column", () => { + const bare = + /\b(?:normalizeLocale|coerceLocale|resolveLocale)\(\s*(?:[\w$]+\.)*([\w$]+)\??\.locale\s*\)/g; + const offenders = files.filter((rel) => + [...read(rel).matchAll(bare)].some( + (match) => !RESOLVED_RECEIVERS.has(match[1]), + ), + ); + expect(offenders).toEqual([]); + }); + + /** + * Files that read a row's `.locale` without resolving it, because they only + * carry the raw column to a consumer that does. Each entry names the + * consumer; adding one here is a review decision, not a default. + */ + const PROJECTION_ONLY = [ + // Projects `User.locale` into the cron candidate row; the status crons + // and the forced-warm path in `reminder/insights-handlers.ts` resolve it. + "lib/jobs/status-cron-candidates.ts", + ]; + + it("T3 — every job file that reads a row's locale imports resolveJobLocale", () => { + const readers = files.filter( + (rel) => rowLocaleReceivers(read(rel)).length > 0, + ); + // The sweep is only meaningful if it actually finds the row readers. + expect(readers.length).toBeGreaterThan(5); + const offenders = readers + .filter((rel) => !PROJECTION_ONLY.includes(rel)) + .filter((rel) => !/from "@\/lib\/i18n\/job-locale"/.test(read(rel))); + expect(offenders).toEqual([]); + // The allowlist must not outlive its entries. + for (const rel of PROJECTION_ONLY) { + expect(readers).toContain(rel); + } + }); +}); diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 1287ccdc0..b210675f8 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -68,3 +68,22 @@ export async function getReminderThresholds(): Promise { return { lateMinutes: 120, missedMinutes: 240 }; } } + +/** + * The operator's configured default locale (`AppSettings.defaultLocale`), or + * `null` when the settings row is absent or unreadable. Validation against + * the shipped locale list is the caller's job (`resolveJobLocale`); this only + * reads the column. + */ +export async function getOperatorDefaultLocale(): Promise { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { defaultLocale: true }, + }); + return settings?.defaultLocale ?? null; + } catch { + getEvent()?.addWarning("Failed to load the operator default locale"); + return null; + } +} diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 321c2136a..5d411ee56 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -196,10 +196,12 @@ describe("maybeDispatchDailyBriefing", () => { ); expect(result).toBe("sent"); + // The push resolves the locale once (stored, then operator default) and + // hands it to the digest so both speak the same language. expect(loadDailyDigest).toHaveBeenCalledWith( expect.objectContaining({ id: "u1" }), IN_WINDOW, - { enabledItemKinds: PRIORITY_ITEM_KINDS }, + { enabledItemKinds: PRIORITY_ITEM_KINDS, locale: "en" }, ); expect(deps.dispatch).toHaveBeenCalledOnce(); }); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index d2042409d..9cef2f919 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -28,7 +28,8 @@ import type { User } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client"; import { annotate, getEvent } from "@/lib/logging/context"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -79,22 +80,21 @@ export type DailyBriefingDispatchResult = export interface DailyBriefingDispatchDeps { dispatch?: typeof dispatchNotification; - loadDigest?: (user: User, now: Date) => Promise; + loadDigest?: (user: User, now: Date, locale: Locale) => Promise; isModuleEnabled?: typeof isModuleEnabled; } -function loadDailyBriefingDigest(user: User, now: Date): Promise { +function loadDailyBriefingDigest( + user: User, + now: Date, + locale: Locale, +): Promise { return loadDailyDigest(user, now, { enabledItemKinds: PRIORITY_ITEM_KINDS, + locale, }); } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * A digest is "substantive" — worth a morning push — when it carries a score, a * briefing lead, a top signal, OR a worth-a-look item. A brand-new / empty @@ -211,13 +211,17 @@ export async function maybeDispatchDailyBriefing( return "suppressed-frequency"; } - const digest = await loadDigest(user, now); + // Resolved once, off the job path (stored locale, then the operator + // default), and handed to the digest so its lines and the push copy + // are in the same language. + const locale = await resolveJobLocale(user.locale); + const digest = await loadDigest(user, now, locale); if (!digestHasSubstance(digest)) { annotate({ action: { name: "daily.briefing_push.no_digest" } }); return "no-digest"; } - const { t } = getServerTranslator(resolveLocale(user.locale)); + const { t } = getServerTranslator(locale); const { title, body } = buildDailyBriefingPush(digest, t); const outcome = await dispatch({ diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 1b83f38a4..a7e14b030 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -19,7 +19,7 @@ import { annotate } from "@/lib/logging/context"; import { resolveModuleMap } from "@/lib/modules/gate"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import type { Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; @@ -293,12 +293,6 @@ function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan { }; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * Today's arrival markers, decrypted fault-isolated. * @@ -348,6 +342,12 @@ export interface DailyDigestLoadOptions { * from which cards the user chose to display in the Today hero. */ enabledItemKinds?: readonly PriorityItemKind[]; + /** + * A locale already resolved by a background caller (the morning briefing + * push). Request-driven readers leave it unset and the snapshot read + * resolves the locale from the request. + */ + locale?: Locale; } export async function loadDailyDigest( @@ -375,7 +375,7 @@ export async function loadDailyDigest( arrivalRows, nextVisitRow, ] = await Promise.all([ - readDashboardSnapshotCached(user), + readDashboardSnapshotCached(user, undefined, { locale: options.locale }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, @@ -597,7 +597,7 @@ export async function loadDailyDigest( : []; const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey)); - const resolvedLocale = resolveLocale(locale); + const resolvedLocale: Locale = locale; const { t } = getServerTranslator(resolvedLocale); // Group the two figures for the reader. `Intl.NumberFormat` is the only diff --git a/src/lib/i18n/__tests__/job-locale.test.ts b/src/lib/i18n/__tests__/job-locale.test.ts new file mode 100644 index 000000000..68e33b26e --- /dev/null +++ b/src/lib/i18n/__tests__/job-locale.test.ts @@ -0,0 +1,52 @@ +/** + * `resolveJobLocale` — the locale a background path writes in. + * + * A job has no request to read a cookie or Accept-Language from, so the + * stored `User.locale` is the only per-user signal. When it is NULL the + * operator's configured default is the next best answer, and English only + * when that is unset or invalid too. The old job-side coercions skipped the + * middle step and wrote English for every account that had never touched + * the language picker. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getOperatorDefaultLocale = vi.hoisted(() => + vi.fn<() => Promise>(), +); + +vi.mock("@/lib/app-settings", () => ({ + getOperatorDefaultLocale, +})); + +import { resolveJobLocale } from "@/lib/i18n/job-locale"; + +beforeEach(() => { + getOperatorDefaultLocale.mockReset(); + getOperatorDefaultLocale.mockResolvedValue(null); +}); + +describe("resolveJobLocale", () => { + it("a valid stored user locale wins without consulting the operator default", async () => { + getOperatorDefaultLocale.mockResolvedValue("de"); + await expect(resolveJobLocale("fr")).resolves.toBe("fr"); + expect(getOperatorDefaultLocale).not.toHaveBeenCalled(); + }); + + it("a NULL user locale falls back to the operator default", async () => { + getOperatorDefaultLocale.mockResolvedValue("de"); + await expect(resolveJobLocale(null)).resolves.toBe("de"); + await expect(resolveJobLocale(undefined)).resolves.toBe("de"); + }); + + it("NULL user locale and no operator default resolve to English", async () => { + getOperatorDefaultLocale.mockResolvedValue(null); + await expect(resolveJobLocale(null)).resolves.toBe("en"); + }); + + it("ignores invalid strings at both levels", async () => { + getOperatorDefaultLocale.mockResolvedValue("xx"); + await expect(resolveJobLocale("zz-ZZ")).resolves.toBe("en"); + getOperatorDefaultLocale.mockResolvedValue("de"); + await expect(resolveJobLocale("")).resolves.toBe("de"); + }); +}); diff --git a/src/lib/i18n/job-locale.ts b/src/lib/i18n/job-locale.ts new file mode 100644 index 000000000..980d50ffd --- /dev/null +++ b/src/lib/i18n/job-locale.ts @@ -0,0 +1,34 @@ +/** + * Locale resolution for background paths. + * + * A job or worker has no request to read a cookie or Accept-Language from, + * so the stored `User.locale` is the only per-user signal. When it is NULL + * (the account never touched the language picker) the operator's configured + * default locale is the answer, and English only when that is unset or + * invalid as well. + * + * Every nightly writer that resolved a user row's locale used to land on + * English for a NULL column, so a German-language instance warmed its + * briefings in English while every request-driven write was German. This + * is the one place the job-side fallback order lives; the structural guard + * `src/__tests__/job-locale-resolution-guard.test.ts` keeps the job tree on + * it. `normalizeLocale` / `coerceLocale` remain for validating a value that + * is already resolved, such as a queue payload. + */ +import { getOperatorDefaultLocale } from "@/lib/app-settings"; +import { defaultLocale, locales, type Locale } from "./config"; + +function isShippedLocale(value: string | null | undefined): value is Locale { + return ( + typeof value === "string" && (locales as readonly string[]).includes(value) + ); +} + +export async function resolveJobLocale( + userLocale: string | null | undefined, +): Promise { + if (isShippedLocale(userLocale)) return userLocale; + const operatorDefault = await getOperatorDefaultLocale(); + if (isShippedLocale(operatorDefault)) return operatorDefault; + return defaultLocale; +} diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 3dd46e799..d8de29c13 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -87,8 +87,8 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs"; import { recordProactiveNudge } from "@/lib/ai/coach/persistence"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { coerceLocale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { composeNudgeWithAI, createNudgeAiTickBudget, @@ -221,12 +221,6 @@ export interface CoachNudgeSummary { failed: number; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * Pure trigger predicates — exported for the unit tests so the * thresholds stay pinned without a DB. @@ -484,7 +478,7 @@ export function buildCoachNudgePayload( locale: string | null | undefined, options: NudgePayloadOptions = {}, ): { title: string; body: string } { - const t = getServerTranslator(resolveLocale(locale)).t; + const t = getServerTranslator(coerceLocale(locale)).t; const name = options.name?.trim() || null; const opener = GREETING_KEYS[(options.openerSeed ?? 0) % GREETING_KEYS.length]; @@ -987,7 +981,7 @@ export async function runCoachNudgeTick( hasCoachFocus = profileRow?.coachFocusEncrypted != null; } - const locale = resolveLocale(user.locale); + const locale = await resolveJobLocale(user.locale); const name = resolveGreetingName(user); // Rotate the opener day to day, stable within a tick. const openerSeed = now.getUTCDate(); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 1ec6ebbb2..e43288439 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -38,8 +38,8 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { coerceLocale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dayDiff } from "@/lib/cycle/day-math"; @@ -109,12 +109,6 @@ function utcDateString(now: Date, offsetDays: number): string { .padStart(2, "0")}-${d.getUTCDate().toString().padStart(2, "0")}`; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - function emptySummary(): CycleReminderSummary { return { candidatesScanned: 0, @@ -221,7 +215,7 @@ export function buildCycleReminderPayload( locale: string | null | undefined, discreet: boolean, ): { title: string; body: string } { - const t = getServerTranslator(resolveLocale(locale)).t; + const t = getServerTranslator(coerceLocale(locale)).t; if (discreet) { return { title: t("cycleReminders.discreetTitle"), @@ -452,7 +446,7 @@ export async function runCycleReminderTick( const { title, body } = buildCycleReminderPayload( event, - user.locale, + await resolveJobLocale(user.locale), discreet, ); diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index ab3bff03b..21cbc9469 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -37,7 +37,8 @@ import { prepareVisionInput, } from "@/lib/documents/ai-route-support"; import { runDocumentSummary } from "@/lib/documents/describe"; -import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { encryptDocumentSummary } from "@/lib/documents/store"; @@ -202,17 +203,13 @@ export async function runDocumentSummaryJob( } // The screen picks its pattern banks by locale; this job has no request, so - // the reader's stored preference is the source. An unset/unknown value falls - // back to English, never to a silent no-locale path. + // the reader's stored preference is the source, then the operator default, + // then English — never a silent no-locale path. const owner = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true }, }); - const locale: Locale = (locales as readonly string[]).includes( - owner?.locale ?? "", - ) - ? (owner?.locale as Locale) - : defaultLocale; + const locale: Locale = await resolveJobLocale(owner?.locale); try { const { summary, blocked } = await runDocumentSummary({ diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index d23291610..34c95e503 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -54,10 +54,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; -import { - normalizeLocale as normalizeStatusLocale, - type SupportedLocale, -} from "@/lib/insights/status-shared"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { generateComprehensiveInsight, type GenerateOutcome, @@ -535,19 +533,6 @@ export async function findPregenerateCandidates( }); } -/** - * The user's stored locale, validated against the six the UI ships. - * - * This was a local `value === "de" ? "de" : "en"` copy, which flattened a - * fr/es/it/pl account to English before the warm pass ever reached a prompt. - * It now defers to the one shared validator so the reader's locale survives - * into the prompt's output-language directive; an unknown value still - * defaults to English, never German. - */ -function normalizeLocale(value: string | null): SupportedLocale { - return normalizeStatusLocale(value); -} - /** * Run one pre-generation pass. Pure of pg-boss so the unit test can * drive it directly. The master assistant kill-switch short-circuits @@ -649,7 +634,10 @@ export async function runInsightPregenerate( PREGENERATE_BUDGET_WINDOW_MS, ); - const locale = normalizeLocale(candidate.locale); + // The stored locale, then the operator default: a NULL column used to + // resolve to English here, so a German-language instance warmed its + // briefings in English every night. + const locale = await resolveJobLocale(candidate.locale); let outcome: GenerateOutcome | null = null; if (!budget.allowed) { result.budgetBlocked++; diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 1151a35ab..8c9444012 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -30,8 +30,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import type { MeasurementType } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { coerceLocale, type Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { @@ -129,12 +129,6 @@ async function cleanupExpiredCoachReminders( return result.count; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * Pure due-predicate: at this instant, is the reminder due AND inside its * local notify-hour window? @@ -171,7 +165,7 @@ export function buildMeasurementReminderPayload( label: string, location: string | null, ): { title: string; body: string } { - const t = getServerTranslator(resolveLocale(locale)).t; + const t = getServerTranslator(coerceLocale(locale)).t; const base = t("measurementReminders.pushBody", { label }); const body = location ? `${base} ${t("measurementReminders.pushLocation", { location })}` @@ -358,7 +352,7 @@ export async function runMeasurementReminderTick( } const { title, body } = buildMeasurementReminderPayload( - reminder.user.locale, + await resolveJobLocale(reminder.user.locale), reminder.label, reminder.location, ); diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 493183833..6b6f462d6 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -75,8 +75,8 @@ import { resolveReorderLeadDays, } from "@/lib/validations/notification-prefs"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { coerceLocale, type Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { annotate, getEvent } from "@/lib/logging/context"; /** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */ @@ -228,12 +228,6 @@ export function decideLowStockAction(input: { return "notify"; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * v1.17.0 — localised push copy. When the supply data is rich enough to * date (`runwayDays >= 1` with a derivable cadence) the body names the @@ -255,7 +249,7 @@ export function buildLowStockPayload(input: { schedules: RunwaySchedule[]; today: Date; }): { title: string; body: string } { - const t = getServerTranslator(resolveLocale(input.locale)).t; + const t = getServerTranslator(coerceLocale(input.locale)).t; const title = t("lowStockReminders.title", { medName: input.medName }); if (input.runwayDays < 1) { @@ -294,7 +288,7 @@ export function buildLowStockPayload(input: { // Day-only, UTC: the runway dates are UTC-midnight calendar days, so a // UTC formatter renders the intended day in every locale. A medium // style stays compact and unambiguous for a push body. - const dateFmt = new Intl.DateTimeFormat(resolveLocale(input.locale), { + const dateFmt = new Intl.DateTimeFormat(coerceLocale(input.locale), { timeZone: "UTC", day: "numeric", month: "short", @@ -505,7 +499,7 @@ export async function runMedicationLowStockTick( break; case "notify": { const { title, body } = buildLowStockPayload({ - locale: user.locale, + locale: await resolveJobLocale(user.locale), medName: med.name, runwayDays: evaluation.runwayDays ?? 0, unitsRemaining: evaluation.unitsRemaining, diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index cc3a0fe3f..24e170f07 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -22,8 +22,8 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { coerceLocale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; @@ -59,12 +59,6 @@ export interface MoodReminderSummary { failed: number; } -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * Pure decision predicate: should the worker fire a mood reminder for * this user at this instant? @@ -115,7 +109,7 @@ export function buildMoodReminderPayload(locale: string | null | undefined): { title: string; body: string; } { - const t = getServerTranslator(resolveLocale(locale)).t; + const t = getServerTranslator(coerceLocale(locale)).t; return { title: t("moodReminders.dailyTitle"), body: t("moodReminders.dailyBody"), @@ -222,7 +216,9 @@ export async function runMoodReminderTick( continue; } - const { title, body } = buildMoodReminderPayload(user.locale); + const { title, body } = buildMoodReminderPayload( + await resolveJobLocale(user.locale), + ); const outcome = await dispatchImpl({ eventType: "MOOD_REMINDER", diff --git a/src/lib/jobs/morning-digest-refresh.ts b/src/lib/jobs/morning-digest-refresh.ts index 28f1063da..0674aa330 100644 --- a/src/lib/jobs/morning-digest-refresh.ts +++ b/src/lib/jobs/morning-digest-refresh.ts @@ -35,10 +35,8 @@ import { type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared"; -import { - normalizeLocale, - type SupportedLocale, -} from "@/lib/insights/status-shared"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { MORNING_DIGEST_REFRESH_QUEUE, type MorningDigestRefreshPayload, @@ -102,7 +100,7 @@ export async function runMorningDigestRefresh( return { status: "already-final" }; } - const locale = normalizeLocale(user.locale); + const locale = await resolveJobLocale(user.locale); const outcome = await generate(userId, { locale, force: true }); // A hard provider failure leaves the day provisional and hands recovery to diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index f33c9b1a6..3c5c97600 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -32,7 +32,8 @@ import { type NarrativeGenerateOutcome, } from "@/lib/insights/narrative/period-narrative-generate"; import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; -import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; +import { defaultLocale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, @@ -132,19 +133,6 @@ export async function findNarrativeCandidates( .slice(0, cap); } -/** - * The user's stored locale, validated against the shipped UI locale union. - * - * The narrative row is keyed `(user, period, locale)`, so the nightly warm has - * to write the SAME key the read route reads. Collapsing fr/es/it/pl to `en` - * here would leave those users' rows permanently cold on the nightly path. - * An unset or unknown value falls back to the app default (`en`), never to - * German. - */ -function normalizeLocale(value: string | null): Locale { - return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; -} - /** * Run one nightly warm pass. Pure of pg-boss so the unit test can drive it * directly. Short-circuits to a no-op when (a) the master assistant briefing @@ -197,7 +185,10 @@ export async function runPeriodNarrativeWarm( continue; } - const locale = normalizeLocale(candidate.locale); + // The narrative row is keyed `(user, period, locale)`, so the nightly + // warm has to write the SAME key the read route reads: the stored + // locale, then the operator default for an account that never set one. + const locale = await resolveJobLocale(candidate.locale); for (const period of periods) { const outcome = await generate(candidate.id, { period, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index e1f73f5b5..cd2601c4b 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -53,7 +53,8 @@ import { extractAssessmentSummary } from "@/lib/insights/status-shared"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { singleUserTurn, type CompletionResult } from "@/lib/ai/types"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import type { DailyDigest } from "@/lib/daily/digest"; @@ -101,12 +102,6 @@ export const REACTION_LINE_CLAIM_LEASE_MS = 2 * 60_000; export type ReactionLineOutcome = { status: "skipped"; reason: string } | { status: "generated" }; -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - /** * Normalise the model's output into a shippable sentence, or null. * @@ -458,7 +453,7 @@ export async function runReactionLine( const user = await prisma.user.findUnique({ where: { id: job.userId } }); if (!user) return { status: "skipped", reason: "no_user" }; - const locale = resolveLocale(user.locale); + const locale = await resolveJobLocale(user.locale); const chain = await resolveProviderChain(job.userId); if (chain.length === 0) return { status: "skipped", reason: "no_provider" }; diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index fd275f43c..32a0da584 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -7,6 +7,7 @@ import { type Job } from "pg-boss"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { normalizeLocale } from "@/lib/insights/status-shared"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status"; import { INSIGHT_PREGENERATE_QUEUE, @@ -96,7 +97,10 @@ export async function runStatusCronGenerate( for (const user of users) { try { - await generate(user.id, { locale: user.locale, force: false }); + await generate(user.id, { + locale: await resolveJobLocale(user.locale), + force: false, + }); generated++; } catch (error) { failed++; @@ -153,10 +157,9 @@ async function runStatusBatchCron(taskName: string): Promise { for (const user of users) { try { const result = await generateStatusBatchForUser(user.id, { - // Validate against the six UI locales rather than collapsing to a - // binary — the per-metric generators normalise identically, and the - // prompt names the reader's own language from this value. - locale: normalizeLocale(user.locale), + // The stored locale, then the operator default — the prompt names + // the reader's own language from this value. + locale: await resolveJobLocale(user.locale), force: false, }); generated += result.batched + result.fellBack; diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 2decef35b..b91e50dbc 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -6,6 +6,7 @@ */ import { type Job } from "pg-boss"; import type { Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { decrypt } from "@/lib/crypto"; @@ -623,13 +624,14 @@ export async function handleReminderCheck( // — so the gate lives in the dispatcher's APNs branch, which is // the only place that knows whether an APNs send was on the table. if (med.notificationsEnabled) { + const recipientLocale = await resolveJobLocale(med.user.locale); const { title, message } = getPhaseMessage( currentPhase, med.name, doseInfo, timeWindow, Math.ceil(minutesToEnd), - med.user.locale, + recipientLocale, ); const renderForRecipient = (locale: Locale) => getPhaseMessage( @@ -644,7 +646,7 @@ export async function handleReminderCheck( const keyboard = getPhaseKeyboard( currentPhase, med.id, - med.user.locale, + recipientLocale, ); // v0.5.4 — surface the slot time as an ISO 8601 string so diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index a5854f285..67966b14b 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -45,7 +45,8 @@ import { } from "@/lib/ai/prompts/workout-insight"; import { prisma } from "@/lib/db"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { finalizeStatusSummary } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -380,11 +381,7 @@ export async function runWorkoutInsightGenerate( return { status: "unchanged" as const }; } - const locale: Locale = (locales as readonly string[]).includes( - profile?.locale ?? "", - ) - ? (profile?.locale as Locale) - : defaultLocale; + const locale: Locale = await resolveJobLocale(profile?.locale); return { status: "ready" as const, evidence, inputHash, locale }; })().catch(async (err) => { // Nothing could have reached a provider. Delete the reservation so a