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