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