mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
42 files changed
+744
-165
No files matched your search
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -316,6 +316,7 @@ export const USER_RESET = {
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
insightsSnapshotHash: null,
|
||||
insightsWarmFailedAt: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user