Merge pull request #886 from MBombeck/fix/briefing-locale

fix: never serve the briefing in another language than the reader's
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-02 11:09:23 +02:00
commit ad0a055fdc
42 files changed
+744 -165

No files matched your search

+18
View File
@@ -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
@@ -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;
+7
View File
@@ -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
@@ -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 (`<row>.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<string>();
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);
}
});
});
@@ -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,
});
});
@@ -82,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
},
});
@@ -22,6 +22,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
codexConnectionStatus: "disconnected",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
},
});
@@ -216,6 +216,7 @@ describe("about-me module gate", () => {
data: {
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
},
});
expect(invalidateInsights).toHaveBeenCalledWith("u1");
+1
View File
@@ -271,6 +271,7 @@ export const PUT = apiHandler(async (req: Request) => {
data: {
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
},
});
}
@@ -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<Response>)(
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<Response>)(
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({
+43 -16
View File
@@ -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,
+1
View File
@@ -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) {
@@ -287,6 +287,7 @@ function user(healthScoreConfigJson: unknown): SnapshotUserInput {
disableCoach: false,
insightsCachedText: null,
insightsCachedAt: null,
insightsCachedLocale: null,
dashboardWidgetsJson: null,
thresholdsJson: null,
healthScoreConfigJson,
+19
View File
@@ -68,3 +68,22 @@ export async function getReminderThresholds(): Promise<ReminderThresholds> {
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<string | null> {
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;
}
}
@@ -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();
});
+15 -11
View File
@@ -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<DailyDigest>;
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
isModuleEnabled?: typeof isModuleEnabled;
}
function loadDailyBriefingDigest(user: User, now: Date): Promise<DailyDigest> {
function loadDailyBriefingDigest(
user: User,
now: Date,
locale: Locale,
): Promise<DailyDigest> {
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({
+9 -9
View File
@@ -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
@@ -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 /
+17 -2
View File
@@ -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?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
options: SnapshotReadOptions = {},
): Promise<SnapshotReadResult> {
// `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
+26 -4
View File
@@ -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<boolean>,
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),
+1
View File
@@ -316,6 +316,7 @@ export const USER_RESET = {
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
insightsSnapshotHash: null,
insightsWarmFailedAt: null,
insightsBriefingRerollDate: null,
+5 -1
View File
@@ -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,
},
});
}
+52
View File
@@ -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<string | null>>(),
);
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");
});
});
+34
View File
@@ -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<Locale> {
if (isShippedLocale(userLocale)) return userLocale;
const operatorDefault = await getOperatorDefaultLocale();
if (isShippedLocale(operatorDefault)) return operatorDefault;
return defaultLocale;
}
@@ -226,10 +226,31 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
expect(write).toBeTruthy();
const data = (write![0] as { data: Record<string, unknown> }).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({
@@ -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,
},
);
+4 -10
View File
@@ -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();
+4 -10
View File
@@ -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,
);
+5 -8
View File
@@ -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({
+6 -18
View File
@@ -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++;
+4 -10
View File
@@ -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,
);
+5 -11
View File
@@ -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,
+6 -10
View File
@@ -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",
+3 -5
View File
@@ -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
+6 -15
View File
@@ -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,
+3 -8
View File
@@ -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" };
+8 -5
View File
@@ -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<JobOutcome> {
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;
@@ -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
+3 -6
View File
@@ -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
+16 -3
View File
@@ -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,
},
});
});
+1
View File
@@ -60,6 +60,7 @@ export async function invalidateHealthProfileFactConsumers(
data: {
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
},
});
invalidateUserInsights(userId);
@@ -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);
});
});