diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5511ba8..4c2f3e4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ cannot read images even though it can. Any Claude tier from the 4 family upward, and every Fable model, now counts as vision-capable. The model preset list in Settings also offers `claude-sonnet-5` and `claude-opus-5`. +- Dashboard, Today: a self-hoster using the app in German could see the + dashboard lead and the Today briefing in English, and they stayed English + no matter how often the page was opened. The nightly briefing writer + resolved an account that had never picked a language to English, ignoring + the instance's configured default language, and the briefing cache is a + single slot per account that carried no record of the language it was + written in, so every reader was served whatever the last writer left + there. The request-driven refresh only replaced it once the cache was a + day old and the provider answered, which with a provider at its usage + limit was never. The cache now records the language it was generated in, + and a reader in another language gets the honest "preparing" state and a + re-warm in their own language instead of foreign prose; rows written + before this change are served as before until the next write tags them. + Every background path (nightly briefing warm, status crons, period + narratives, reaction lines, morning refresh, briefing push, coach nudges, + reminders, document and workout summaries) now resolves a missing user + language to the instance default, then to English, through one shared + helper, and a structural test keeps the job tree on it. ## [1.38.1] — 2026-09-01 diff --git a/prisma/migrations/0335_insights_cached_locale/migration.sql b/prisma/migrations/0335_insights_cached_locale/migration.sql new file mode 100644 index 000000000..4fab2f2eb --- /dev/null +++ b/prisma/migrations/0335_insights_cached_locale/migration.sql @@ -0,0 +1,4 @@ +-- Tag the comprehensive-briefing cache with the language it was written in. +-- One slot per user; readers in another language treat a differing tag as an +-- empty cache. NULL on existing rows: treated as matching until the next write. +ALTER TABLE "users" ADD COLUMN "insights_cached_locale" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 185f7ff28..311b0c061 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,6 +119,13 @@ model User { insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") insightsCachedText String? @map("insights_cached_text") // cached JSON output + // Language the cached text was generated in. The cache is one slot per + // user, so a reader in another language must not be served it: the + // snapshot briefing lift and the advisor read treat a tag that differs + // from the reader's locale as an empty cache and re-warm in the reader's. + // NULL on rows written before the tag existed (treated as matching; the + // next write tags it). + insightsCachedLocale String? @map("insights_cached_locale") // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the // cached comprehensive text was generated from. Nightly / forced / lazy // regenerations compare the fresh snapshot's hash against this and skip 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/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 4100c070d..224e1148d 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -756,9 +756,12 @@ describe("backup round trip — export, wire schema, restore", () => { userUpdate, "a scope-narrowing restore must clear the cached briefing", ).toBeDefined(); + // The language tag is cleared with the text: an empty slot carries + // no language, and the next write tags it afresh. expect(userUpdate!.data).toEqual({ insightsCachedText: null, insightsCachedAt: null, + insightsCachedLocale: null, }); }); diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index 667993bad..060b2c08c 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -82,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => { codexConnectionStatus: "connected", insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); diff --git a/src/app/api/auth/codex/disconnect/route.ts b/src/app/api/auth/codex/disconnect/route.ts index 6b62cd7c0..39b3535c7 100644 --- a/src/app/api/auth/codex/disconnect/route.ts +++ b/src/app/api/auth/codex/disconnect/route.ts @@ -22,6 +22,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { codexConnectionStatus: "disconnected", insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index b99cff3f8..da8647672 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -216,6 +216,7 @@ describe("about-me module gate", () => { data: { insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); expect(invalidateInsights).toHaveBeenCalledWith("u1"); diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 79f468e73..2e05ab307 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -271,6 +271,7 @@ export const PUT = apiHandler(async (req: Request) => { data: { insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); } diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 7bf2d2a94..55516a66d 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -447,6 +447,9 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { expect(args.data.insightsCachedText).toEqual(expect.any(String)); // SHA-256 hex digest of the compacted feature snapshot. expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/); + // The cache row records the language it was written in, so a reader + // in another language is never served this text. + expect(args.data.insightsCachedLocale).toBe("en"); }); it("refuses a stale cache write when AI profile inclusion changes during generation", async () => { @@ -797,6 +800,55 @@ describe("GET /api/insights/generate — read-only advisor read", () => { expect(body.data.revalidating).toBe(true); }); + // `resolveServerLocale` is mocked to "en" in this suite, so the reader is + // an English session throughout; the cache tag is what varies. + it("does NOT serve a fresh cache tagged with another language; warms in the reader's", async () => { + const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } }; + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(cached), + insightsCachedLocale: "de", + locale: "en", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { insights: unknown; cached: boolean; revalidating: boolean }; + }; + expect(body.data.cached).toBe(false); + expect(body.data.insights).toBeNull(); + expect(body.data.revalidating).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledWith({ + userId: "u-1", + locale: "en", + }); + expect(resolveProvider).not.toHaveBeenCalled(); + }); + + it("serves a fresh cache whose tag matches the reader's language", async () => { + const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } }; + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(cached), + insightsCachedLocale: "en", + locale: "en", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { + data: { insights: unknown; cached: boolean; revalidating: boolean }; + }; + expect(body.data.cached).toBe(true); + expect(body.data.insights).toEqual(cached); + expect(body.data.revalidating).toBe(false); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + it("returns an empty payload (no warm) on a cold cache without a provider", async () => { vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 8c13b2c62..8de86d480 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -154,13 +154,38 @@ export const GET = apiHandler(async (request: NextRequest) => { select: { insightsCachedAt: true, insightsCachedText: true, + insightsCachedLocale: true, locale: true, }, }); + // Resolve the locale the caller is actually reading (cookie / + // Accept-Language fall-back when `User.locale` is unset) and validate it + // against the shipped list — the same convention the nightly's + // `normalizeLocale` and the status routes follow. The previous + // `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale` + // to German even for a client reading the app in English, so the + // visit-triggered warm filled the wrong cache family. + const readerLocale = normalizeLocale( + await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? user.locale ?? null, + }), + ); + + // The cache is one slot per user. Text written in another language is + // never the reader's briefing, however fresh: treat the slot as empty for + // prose, and re-warm in the reader's language below. An untagged row + // predates the tag and is served as before. + const languageMismatch = + dbUser?.insightsCachedLocale != null && + dbUser.insightsCachedLocale !== readerLocale; + const cachedAt = dbUser?.insightsCachedAt ?? null; const isFresh = - cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + !languageMismatch && + cachedAt !== null && + Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; // v1.18.9 (#4) — resolve provider availability once, up front. The // read path never blocks on or generates through the provider, but it @@ -204,23 +229,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // empty / connect-AI state instead of a wasted enqueue). let revalidating = false; if (!isFresh && hasProvider) { - // Resolve the locale the caller is actually reading (cookie / - // Accept-Language fall-back when `User.locale` is unset) and narrow - // non-German to English — the same convention the nightly's - // `normalizeLocale` and the status routes follow. The previous - // `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale` - // to German even for a client reading the app in English, so the - // visit-triggered warm filled the wrong cache family. - const resolved = await resolveServerLocale({ - request, - userLocale: dbUser?.locale ?? user.locale ?? null, - }); - const locale = normalizeLocale(resolved); - void enqueueForceWarm({ userId, locale }); + void enqueueForceWarm({ userId, locale: readerLocale }); revalidating = true; } - if (dbUser?.insightsCachedText) { + if (dbUser?.insightsCachedText && !languageMismatch) { try { const cached = JSON.parse(dbUser.insightsCachedText); const legacyPayload = isLegacyInsightPayload(cached); @@ -260,7 +273,13 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.generate.read" }, - meta: { cached: false, revalidating, hasProvider }, + meta: { + cached: false, + revalidating, + hasProvider, + languageMismatch, + readerLocale, + }, }); return apiSuccess({ insights: null, @@ -298,6 +317,7 @@ export const POST = apiHandler((request: NextRequest) => insightsPrivacyMode: true, insightsCachedAt: true, insightsCachedText: true, + insightsCachedLocale: true, // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach // settings. Filtered off `features` before serialisation so the // LLM never sees the excluded blocks. @@ -366,10 +386,16 @@ export const POST = apiHandler((request: NextRequest) => legacyPayload: boolean; cachedAt: Date; } | null = null; + // A cache tagged with another language is not this reader's cache and + // never short-circuits; the generation below writes the reader's own. + const cachedLocaleMatches = + dbUser?.insightsCachedLocale == null || + dbUser.insightsCachedLocale === locale; if ( !forceRefresh && dbUser?.insightsCachedAt && dbUser.insightsCachedText && + cachedLocaleMatches && Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 ) { try { @@ -999,6 +1025,7 @@ export const POST = apiHandler((request: NextRequest) => data: { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, aboutMe: aboutMe ?? null, diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index b7c8eb9cd..a1f475121 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -77,6 +77,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { data.insightsPrivacyMode = mode; data.insightsCachedAt = null; data.insightsCachedText = null; + data.insightsCachedLocale = null; } if (Object.keys(data).length === 0) { diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 06495539e..67640b226 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -287,6 +287,7 @@ function user(healthScoreConfigJson: unknown): SnapshotUserInput { disableCoach: false, insightsCachedText: null, insightsCachedAt: null, + insightsCachedLocale: null, dashboardWidgetsJson: null, thresholdsJson: null, healthScoreConfigJson, 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/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index ce4d4f8a3..257da8fdd 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -194,6 +194,7 @@ function baseUser( disableCoach: false, insightsCachedText: null, insightsCachedAt: null, + insightsCachedLocale: null, dashboardWidgetsJson: null, thresholdsJson: null, healthScoreConfigJson: null, @@ -694,6 +695,78 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { }); }); +describe("buildDashboardSnapshot — briefing locale tag", () => { + beforeEach(() => { + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + }); + + const briefing = { + greeting: "Hello", + paragraph: "Everything is in the green.", + keyFindings: [], + }; + + it("a cache tagged 'en' read by a 'de' reader serves NO prose and reports preparing", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ dailyBriefing: briefing }), + insightsCachedLocale: "en", + }), + { locale: "de" }, + ); + expect(snap.briefingState).toBe("preparing"); + expect(snap.briefing).toBeNull(); + expect(snap.briefingStale).toBe(false); + expect(snap.briefingUpdatedAt).toBeNull(); + }); + + it("a mismatched tag with no provider anywhere reports no-provider, still without prose", async () => { + hasAnyConfiguredProvider.mockResolvedValue(false); + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ dailyBriefing: briefing }), + insightsCachedLocale: "en", + }), + { locale: "de" }, + ); + expect(snap.briefingState).toBe("no-provider"); + expect(snap.briefing).toBeNull(); + }); + + it("an untagged cache (written before the tag existed) is served as before", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ dailyBriefing: briefing }), + insightsCachedLocale: null, + }), + { locale: "de" }, + ); + expect(snap.briefingState).toBe("ready"); + expect(snap.briefing?.paragraph).toBe(briefing.paragraph); + }); + + it("a matching tag is served", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ dailyBriefing: briefing }), + insightsCachedLocale: "de", + }), + { locale: "de" }, + ); + expect(snap.briefingState).toBe("ready"); + expect(snap.briefing?.paragraph).toBe(briefing.paragraph); + }); +}); + describe("buildDashboardSnapshot — no LLM in the path", () => { it("the builder module never imports the provider chain", async () => { // Structural guard — the builder must assemble from rollup / mood / diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index acfcc4b25..bcedf987d 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -34,6 +34,7 @@ import { type SnapshotUserInput, } from "@/lib/dashboard/snapshot"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import type { Locale } from "@/lib/i18n/config"; /** * Per-key TTL for the snapshot cache entry. Strictly greater than the @@ -50,7 +51,16 @@ export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; export interface SnapshotReadResult { body: DashboardSnapshot; /** The locale the snapshot was resolved (and cache-keyed) under. */ - locale: string; + locale: Locale; +} + +export interface SnapshotReadOptions { + /** + * A locale already resolved by the caller. Background paths (the morning + * briefing push) have no request to resolve one from and pass the job-side + * resolution here so the digest and the push copy agree on a language. + */ + locale?: Locale; } /** @@ -61,6 +71,7 @@ export interface SnapshotReadResult { export async function readDashboardSnapshotCached( user: User, time?: (label: string, builder: () => Promise) => Promise, + options: SnapshotReadOptions = {}, ): Promise { // `User` row is already in hand at both call sites — no extra round-trip. const snapshotUser: SnapshotUserInput = { @@ -76,6 +87,7 @@ export async function readDashboardSnapshotCached( disableCoach: user.disableCoach, insightsCachedText: user.insightsCachedText, insightsCachedAt: user.insightsCachedAt, + insightsCachedLocale: user.insightsCachedLocale, dashboardWidgetsJson: user.dashboardWidgetsJson, sourcePriorityJson: user.sourcePriorityJson, thresholdsJson: user.thresholdsJson, @@ -87,7 +99,10 @@ export async function readDashboardSnapshotCached( // a DE session never share a snapshot cell (and never see each other's // memory wording). The `${user.id}|` prefix still covers the key under // the measurement-write stale-sweep. - const locale = await resolveServerLocale({ userLocale: user.locale }); + const locale = await resolveServerLocale({ + userLocale: user.locale, + override: options.locale ?? null, + }); // Stale-while-revalidate: a measurement write marks the analytics // bucket stale rather than hard-evicting the snapshot, so a busy iOS diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index e41732df5..dc22484f6 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -508,6 +508,12 @@ export interface SnapshotUserInput { disableCoach: boolean; insightsCachedText: string | null; insightsCachedAt: Date | null; + /** + * Language the cached text was generated in. `null` on rows written before + * the tag existed (served as-is). A tag that differs from the reader's + * locale makes the cache count as absent for prose purposes. + */ + insightsCachedLocale: string | null; dashboardWidgetsJson: unknown; /** Raw source-priority settings already loaded with the authenticated user. */ sourcePriorityJson?: unknown; @@ -908,6 +914,7 @@ async function liftBriefing( user: SnapshotUserInput, coachEnabled: boolean, hasProvider: () => Promise, + readerLocale: Locale, ): Promise<{ briefing: DailyBriefing | null; briefingState: BriefingState; @@ -923,10 +930,21 @@ async function liftBriefing( }; } - const cachedAt = user.insightsCachedAt; + // The cache is one slot per user with no per-language copy. Prose written + // in another language is never the reader's briefing, stale or not, so a + // differing tag makes the slot count as never generated: no text, no + // timestamp. The advisor read (`GET /api/insights/generate`) enqueues the + // re-warm in the reader's language on the same condition. An untagged row + // predates the tag and is served as before. + const cachedLocale = user.insightsCachedLocale; + const languageMismatch = + cachedLocale !== null && cachedLocale !== readerLocale; + const cachedAt = languageMismatch ? null : user.insightsCachedAt; const updatedAt = cachedAt?.toISOString() ?? null; const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS; - const cachedBriefing = parseCachedBriefing(user.insightsCachedText); + const cachedBriefing = languageMismatch + ? null + : parseCachedBriefing(user.insightsCachedText); if (!stale && cachedBriefing) { return { @@ -1375,10 +1393,15 @@ export async function buildDashboardSnapshot( // `briefingState: "disabled"` empty surface the existing flags produce // (the raw weight/BP/pulse data stays untouched — `insights` is the // narrative layer, not the data layer). + // The reader's locale: the route resolves it from the request; the builder + // defaults to English. It decides which cached briefing counts as the + // reader's and which narrative row the briefing memory reads. + const locale = options.locale ?? "en"; const briefing = await liftBriefing( user, flags.briefing && modules.insights !== false, options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), + locale, ); // v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict + @@ -1386,8 +1409,7 @@ export async function buildDashboardSnapshot( // briefing on the `insights` module so a narrative-off account carries none. // Both are fail-soft — a transient derived-engine read resolves to null and // never sinks the snapshot. The narrative block rides the warm phase's - // healthScore; memory rides the briefing card. The locale defaults to English. - const locale = options.locale ?? "en"; + // healthScore; memory rides the briefing card. const narrativeSex = binaryReferenceSex(user.gender); const narrativeProfile = { ageYears: getAgeFromDateOfBirth(user.dateOfBirth), diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 311b68487..43fab3294 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -316,6 +316,7 @@ export const USER_RESET = { insightsPrivacyMode: "aggregated", insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, insightsSnapshotHash: null, insightsWarmFailedAt: null, insightsBriefingRerollDate: null, diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index b0110f9a2..2de06afe6 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -913,7 +913,11 @@ export async function restoreProfileData( // pre-restore briefing against a narrower post-restore scope. await tx.user.update({ where: { id: ownerId }, - data: { insightsCachedText: null, insightsCachedAt: null }, + data: { + insightsCachedText: null, + insightsCachedAt: null, + insightsCachedLocale: null, + }, }); } 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/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 049a1863c..612d1d265 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -226,10 +226,31 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(write).toBeTruthy(); const data = (write![0] as { data: Record }).data; expect(data.insightsSnapshotHash).toBe(FEATURES_HASH); + // The cache row records the language it was generated in. + expect(data.insightsCachedLocale).toBe("de"); // v1.16.8 — the blanket per-status eviction is gone. expect(auditDeleteMany).not.toHaveBeenCalled(); }); + it("a fresh cache tagged with another language does not short-circuit an unforced run", async () => { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 1 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "english" } }), + insightsCachedLocale: "en", + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + }); + + // Past the short-circuit → provider chain resolved → no provider. + expect(resolveProviderChain).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ status: "skipped", reason: "no-provider" }); + }); + it("generates when no fingerprint is stored yet (first run after the gate shipped)", async () => { makeByokChain(); findUnique.mockResolvedValue({ diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 50ef35bd9..2891697ab 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -612,6 +612,7 @@ export async function generateComprehensiveInsight( insightsPrivacyMode: true, insightsCachedAt: true, insightsCachedText: true, + insightsCachedLocale: true, insightsExcludeMetrics: true, insightsSnapshotHash: true, insightsBriefingRerollDate: true, @@ -639,10 +640,18 @@ export async function generateComprehensiveInsight( AI_BUDGETS.comprehensive.timeoutMs, ); + // A cache written in another language is not this locale's cache: it + // must not satisfy an unforced run, or a reader in the requested language + // keeps being told the slot is warm while it holds someone else's prose. + // An untagged row predates the tag and counts as matching. + const cachedLocaleMatches = + dbUser?.insightsCachedLocale == null || + dbUser.insightsCachedLocale === locale; if ( !force && dbUser?.insightsCachedAt && dbUser.insightsCachedText && + cachedLocaleMatches && Date.now() - dbUser.insightsCachedAt.getTime() < CACHE_TTL_MS ) { return { status: "cached" }; @@ -896,6 +905,7 @@ export async function generateComprehensiveInsight( { insightsCachedAt: new Date(), insightsCachedText: rerolled.text, + insightsCachedLocale: locale, insightsBriefingRerollDate: todayKey, }, ); @@ -920,6 +930,10 @@ export async function generateComprehensiveInsight( locale, { insightsCachedAt: new Date(), + // The fingerprint covers the generation locale, so a matching hash + // means the stored text is in `locale`; tag it (a pre-tag row gets + // its label on its first unchanged night). + insightsCachedLocale: locale, insightsBriefingRerollDate: todayKey, }, ); @@ -939,6 +953,7 @@ export async function generateComprehensiveInsight( locale, { insightsCachedAt: new Date(), + insightsCachedLocale: locale, }, ); const skipped = scopeChangedSkip(stamped, locale); @@ -1282,6 +1297,7 @@ export async function generateComprehensiveInsight( { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + insightsCachedLocale: locale, insightsSnapshotHash: snapshotHash, }, ); 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 diff --git a/src/lib/profile/__tests__/health-facts.test.ts b/src/lib/profile/__tests__/health-facts.test.ts index 172df618b..3cca03757 100644 --- a/src/lib/profile/__tests__/health-facts.test.ts +++ b/src/lib/profile/__tests__/health-facts.test.ts @@ -93,6 +93,7 @@ describe("encrypted health profile fact revisions", () => { data: { insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); expect(mocks.invalidateInsights).toHaveBeenCalledWith("user-1"); @@ -128,7 +129,11 @@ describe("encrypted health profile fact revisions", () => { expect(mocks.transaction).toHaveBeenCalledOnce(); expect(mocks.userUpdate).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { insightsCachedAt: null, insightsCachedText: null }, + data: { + insightsCachedAt: null, + insightsCachedText: null, + insightsCachedLocale: null, + }, }); expect(JSON.stringify(mocks.create.mock.calls[0][0].data)).not.toContain( '"value":"FORMER"', @@ -189,7 +194,11 @@ describe("encrypted health profile fact revisions", () => { expect(mocks.transaction).toHaveBeenCalledOnce(); expect(mocks.userUpdate).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { insightsCachedAt: null, insightsCachedText: null }, + data: { + insightsCachedAt: null, + insightsCachedText: null, + insightsCachedLocale: null, + }, }); }); @@ -232,7 +241,11 @@ describe("encrypted health profile fact revisions", () => { expect(mocks.transaction).toHaveBeenCalledOnce(); expect(mocks.userUpdate).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { insightsCachedAt: null, insightsCachedText: null }, + data: { + insightsCachedAt: null, + insightsCachedText: null, + insightsCachedLocale: null, + }, }); }); diff --git a/src/lib/profile/health-facts.ts b/src/lib/profile/health-facts.ts index cdecfbd93..c2201a961 100644 --- a/src/lib/profile/health-facts.ts +++ b/src/lib/profile/health-facts.ts @@ -60,6 +60,7 @@ export async function invalidateHealthProfileFactConsumers( data: { insightsCachedAt: null, insightsCachedText: null, + insightsCachedLocale: null, }, }); invalidateUserInsights(userId); diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts new file mode 100644 index 000000000..56a930549 --- /dev/null +++ b/tests/integration/briefing-cache-locale.test.ts @@ -0,0 +1,113 @@ +/** + * The comprehensive-briefing cache is one slot per user. When it holds prose + * in a language other than the reader's, the dashboard snapshot serves no + * briefing text at all rather than the wrong language. + * + * This runs the real Prisma path end to end: the cache row is written with + * its locale tag through the client, and the snapshot is read through the + * same `readDashboardSnapshotCached` the dashboard route and the Today + * digest use, with the reader's locale resolved from the cookie the client + * sets when the user switches language. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const BRIEFING = { + greeting: "Good morning", + paragraph: "Your readings this week stayed inside their usual range.", + keyFindings: [], +}; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUserWithCachedBriefing(cachedLocale: string | null) { + const prisma = getPrismaClient(); + return prisma.user.create({ + data: { + username: "briefing-user", + email: "briefing@example.test", + role: "USER", + // A German-language account: the stored preference is what the + // snapshot read resolves the reader's locale from. + locale: "de", + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }), + insightsCachedLocale: cachedLocale, + }, + }); +} + +describe("dashboard snapshot — briefing cache written in another language", () => { + it("serves NO briefing prose to a 'de' reader from a cache tagged 'en'", async () => { + const user = await seedUserWithCachedBriefing("en"); + const { readDashboardSnapshotCached } = + await import("@/lib/dashboard/snapshot-read"); + + const { body, locale } = await readDashboardSnapshotCached(user); + + expect(locale).toBe("de"); + expect(body.briefing).toBeNull(); + expect(body.briefingStale).toBe(false); + expect(body.briefingUpdatedAt).toBeNull(); + // No provider is configured on this account, so the honest empty state + // is "nothing will fill this"; with one it would be "preparing". + expect(["preparing", "no-provider"]).toContain(body.briefingState); + }); + + it("serves the briefing when the tag matches the reader", async () => { + const user = await seedUserWithCachedBriefing("de"); + const { readDashboardSnapshotCached } = + await import("@/lib/dashboard/snapshot-read"); + + const { body } = await readDashboardSnapshotCached(user); + + expect(body.briefingState).toBe("ready"); + expect(body.briefing?.paragraph).toBe(BRIEFING.paragraph); + }); + + it("serves an untagged row written before the tag existed", async () => { + const user = await seedUserWithCachedBriefing(null); + const { readDashboardSnapshotCached } = + await import("@/lib/dashboard/snapshot-read"); + + const { body } = await readDashboardSnapshotCached(user); + + expect(body.briefingState).toBe("ready"); + expect(body.briefing?.paragraph).toBe(BRIEFING.paragraph); + }); +});