Resolve job-side locale through the operator default

Every background path that resolved a user row's locale collapsed a
NULL column to English: the nightly briefing warm, the status crons,
the period-narrative warm, reaction lines, the morning refresh, the
briefing push, coach nudges, the reminder workers, and the document and
workout summaries each carried their own copy of
`locales.includes(x) ? x : defaultLocale` or called
`normalizeLocale(user.locale)`. None consulted the instance's configured
default language, so an account that never touched the language picker
got English prose and English pushes on a German-language instance.

Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale
when valid, else the operator's app_settings.default_locale when valid,
else English. The settings read lives beside the other app-settings
accessors. Every job and daily path resolves through it once, where it
has the row; the template helpers keep coerceLocale for a value that is
already resolved, and queue payloads keep normalizeLocale.

The briefing push hands its resolved locale down to the digest, and the
digest to the snapshot read, so the push copy and the digest lines are
in the same language instead of two different fallbacks.

A structural test walks src/lib/jobs and src/lib/daily and fails on a
hand-rolled coercion, a validator applied to a row column, or a row
reader that does not import the helper. It was red against the previous
tree on all three counts.
This commit is contained in:
Marc-André Bombeck committed 2026-09-02 10:44:04 +02:00
1 parent ba4e34f09e
commit f003b6f413
20 files changed
+322 -139

No files matched your search

@@ -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);
}
});
});
+19
View File
@@ -68,3 +68,22 @@ export async function getReminderThresholds(): Promise<ReminderThresholds> {
return { lateMinutes: 120, missedMinutes: 240 };
}
}
/**
* The operator's configured default locale (`AppSettings.defaultLocale`), or
* `null` when the settings row is absent or unreadable. Validation against
* the shipped locale list is the caller's job (`resolveJobLocale`); this only
* reads the column.
*/
export async function getOperatorDefaultLocale(): Promise<string | null> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { defaultLocale: true },
});
return settings?.defaultLocale ?? null;
} catch {
getEvent()?.addWarning("Failed to load the operator default locale");
return null;
}
}
@@ -196,10 +196,12 @@ describe("maybeDispatchDailyBriefing", () => {
);
expect(result).toBe("sent");
// The push resolves the locale once (stored, then operator default) and
// hands it to the digest so both speak the same language.
expect(loadDailyDigest).toHaveBeenCalledWith(
expect.objectContaining({ id: "u1" }),
IN_WINDOW,
{ enabledItemKinds: PRIORITY_ITEM_KINDS },
{ enabledItemKinds: PRIORITY_ITEM_KINDS, locale: "en" },
);
expect(deps.dispatch).toHaveBeenCalledOnce();
});
+15 -11
View File
@@ -28,7 +28,8 @@ import type { User } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { isModuleEnabled } from "@/lib/modules/gate";
@@ -79,22 +80,21 @@ export type DailyBriefingDispatchResult =
export interface DailyBriefingDispatchDeps {
dispatch?: typeof dispatchNotification;
loadDigest?: (user: User, now: Date) => Promise<DailyDigest>;
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
isModuleEnabled?: typeof isModuleEnabled;
}
function loadDailyBriefingDigest(user: User, now: Date): Promise<DailyDigest> {
function loadDailyBriefingDigest(
user: User,
now: Date,
locale: Locale,
): Promise<DailyDigest> {
return loadDailyDigest(user, now, {
enabledItemKinds: PRIORITY_ITEM_KINDS,
locale,
});
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* A digest is "substantive" — worth a morning push — when it carries a score, a
* briefing lead, a top signal, OR a worth-a-look item. A brand-new / empty
@@ -211,13 +211,17 @@ export async function maybeDispatchDailyBriefing(
return "suppressed-frequency";
}
const digest = await loadDigest(user, now);
// Resolved once, off the job path (stored locale, then the operator
// default), and handed to the digest so its lines and the push copy
// are in the same language.
const locale = await resolveJobLocale(user.locale);
const digest = await loadDigest(user, now, locale);
if (!digestHasSubstance(digest)) {
annotate({ action: { name: "daily.briefing_push.no_digest" } });
return "no-digest";
}
const { t } = getServerTranslator(resolveLocale(user.locale));
const { t } = getServerTranslator(locale);
const { title, body } = buildDailyBriefingPush(digest, t);
const outcome = await dispatch({
+9 -9
View File
@@ -19,7 +19,7 @@ import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import type { Locale } from "@/lib/i18n/config";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
@@ -293,12 +293,6 @@ function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan {
};
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Today's arrival markers, decrypted fault-isolated.
*
@@ -348,6 +342,12 @@ export interface DailyDigestLoadOptions {
* from which cards the user chose to display in the Today hero.
*/
enabledItemKinds?: readonly PriorityItemKind[];
/**
* A locale already resolved by a background caller (the morning briefing
* push). Request-driven readers leave it unset and the snapshot read
* resolves the locale from the request.
*/
locale?: Locale;
}
export async function loadDailyDigest(
@@ -375,7 +375,7 @@ export async function loadDailyDigest(
arrivalRows,
nextVisitRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
readDashboardSnapshotCached(user, undefined, { locale: options.locale }),
resolveModuleMap(user.id),
prisma.integrationStatus.findMany({
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
@@ -597,7 +597,7 @@ export async function loadDailyDigest(
: [];
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
const resolvedLocale = resolveLocale(locale);
const resolvedLocale: Locale = locale;
const { t } = getServerTranslator(resolvedLocale);
// Group the two figures for the reader. `Intl.NumberFormat` is the only
+52
View File
@@ -0,0 +1,52 @@
/**
* `resolveJobLocale` — the locale a background path writes in.
*
* A job has no request to read a cookie or Accept-Language from, so the
* stored `User.locale` is the only per-user signal. When it is NULL the
* operator's configured default is the next best answer, and English only
* when that is unset or invalid too. The old job-side coercions skipped the
* middle step and wrote English for every account that had never touched
* the language picker.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const getOperatorDefaultLocale = vi.hoisted(() =>
vi.fn<() => Promise<string | null>>(),
);
vi.mock("@/lib/app-settings", () => ({
getOperatorDefaultLocale,
}));
import { resolveJobLocale } from "@/lib/i18n/job-locale";
beforeEach(() => {
getOperatorDefaultLocale.mockReset();
getOperatorDefaultLocale.mockResolvedValue(null);
});
describe("resolveJobLocale", () => {
it("a valid stored user locale wins without consulting the operator default", async () => {
getOperatorDefaultLocale.mockResolvedValue("de");
await expect(resolveJobLocale("fr")).resolves.toBe("fr");
expect(getOperatorDefaultLocale).not.toHaveBeenCalled();
});
it("a NULL user locale falls back to the operator default", async () => {
getOperatorDefaultLocale.mockResolvedValue("de");
await expect(resolveJobLocale(null)).resolves.toBe("de");
await expect(resolveJobLocale(undefined)).resolves.toBe("de");
});
it("NULL user locale and no operator default resolve to English", async () => {
getOperatorDefaultLocale.mockResolvedValue(null);
await expect(resolveJobLocale(null)).resolves.toBe("en");
});
it("ignores invalid strings at both levels", async () => {
getOperatorDefaultLocale.mockResolvedValue("xx");
await expect(resolveJobLocale("zz-ZZ")).resolves.toBe("en");
getOperatorDefaultLocale.mockResolvedValue("de");
await expect(resolveJobLocale("")).resolves.toBe("de");
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Locale resolution for background paths.
*
* A job or worker has no request to read a cookie or Accept-Language from,
* so the stored `User.locale` is the only per-user signal. When it is NULL
* (the account never touched the language picker) the operator's configured
* default locale is the answer, and English only when that is unset or
* invalid as well.
*
* Every nightly writer that resolved a user row's locale used to land on
* English for a NULL column, so a German-language instance warmed its
* briefings in English while every request-driven write was German. This
* is the one place the job-side fallback order lives; the structural guard
* `src/__tests__/job-locale-resolution-guard.test.ts` keeps the job tree on
* it. `normalizeLocale` / `coerceLocale` remain for validating a value that
* is already resolved, such as a queue payload.
*/
import { getOperatorDefaultLocale } from "@/lib/app-settings";
import { defaultLocale, locales, type Locale } from "./config";
function isShippedLocale(value: string | null | undefined): value is Locale {
return (
typeof value === "string" && (locales as readonly string[]).includes(value)
);
}
export async function resolveJobLocale(
userLocale: string | null | undefined,
): Promise<Locale> {
if (isShippedLocale(userLocale)) return userLocale;
const operatorDefault = await getOperatorDefaultLocale();
if (isShippedLocale(operatorDefault)) return operatorDefault;
return defaultLocale;
}
+4 -10
View File
@@ -87,8 +87,8 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs";
import { recordProactiveNudge } from "@/lib/ai/coach/persistence";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { coerceLocale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import {
composeNudgeWithAI,
createNudgeAiTickBudget,
@@ -221,12 +221,6 @@ export interface CoachNudgeSummary {
failed: number;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Pure trigger predicates — exported for the unit tests so the
* thresholds stay pinned without a DB.
@@ -484,7 +478,7 @@ export function buildCoachNudgePayload(
locale: string | null | undefined,
options: NudgePayloadOptions = {},
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const t = getServerTranslator(coerceLocale(locale)).t;
const name = options.name?.trim() || null;
const opener =
GREETING_KEYS[(options.openerSeed ?? 0) % GREETING_KEYS.length];
@@ -987,7 +981,7 @@ export async function runCoachNudgeTick(
hasCoachFocus = profileRow?.coachFocusEncrypted != null;
}
const locale = resolveLocale(user.locale);
const locale = await resolveJobLocale(user.locale);
const name = resolveGreetingName(user);
// Rotate the opener day to day, stable within a tick.
const openerSeed = now.getUTCDate();
+4 -10
View File
@@ -38,8 +38,8 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { coerceLocale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dayDiff } from "@/lib/cycle/day-math";
@@ -109,12 +109,6 @@ function utcDateString(now: Date, offsetDays: number): string {
.padStart(2, "0")}-${d.getUTCDate().toString().padStart(2, "0")}`;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
function emptySummary(): CycleReminderSummary {
return {
candidatesScanned: 0,
@@ -221,7 +215,7 @@ export function buildCycleReminderPayload(
locale: string | null | undefined,
discreet: boolean,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const t = getServerTranslator(coerceLocale(locale)).t;
if (discreet) {
return {
title: t("cycleReminders.discreetTitle"),
@@ -452,7 +446,7 @@ export async function runCycleReminderTick(
const { title, body } = buildCycleReminderPayload(
event,
user.locale,
await resolveJobLocale(user.locale),
discreet,
);
+5 -8
View File
@@ -37,7 +37,8 @@ import {
prepareVisionInput,
} from "@/lib/documents/ai-route-support";
import { runDocumentSummary } from "@/lib/documents/describe";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { encryptDocumentSummary } from "@/lib/documents/store";
@@ -202,17 +203,13 @@ export async function runDocumentSummaryJob(
}
// The screen picks its pattern banks by locale; this job has no request, so
// the reader's stored preference is the source. An unset/unknown value falls
// back to English, never to a silent no-locale path.
// the reader's stored preference is the source, then the operator default,
// then English — never a silent no-locale path.
const owner = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const locale: Locale = (locales as readonly string[]).includes(
owner?.locale ?? "",
)
? (owner?.locale as Locale)
: defaultLocale;
const locale: Locale = await resolveJobLocale(owner?.locale);
try {
const { summary, blocked } = await runDocumentSummary({
+6 -18
View File
@@ -54,10 +54,8 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import {
normalizeLocale as normalizeStatusLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import type { SupportedLocale } from "@/lib/insights/status-shared";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import {
generateComprehensiveInsight,
type GenerateOutcome,
@@ -535,19 +533,6 @@ export async function findPregenerateCandidates(
});
}
/**
* The user's stored locale, validated against the six the UI ships.
*
* This was a local `value === "de" ? "de" : "en"` copy, which flattened a
* fr/es/it/pl account to English before the warm pass ever reached a prompt.
* It now defers to the one shared validator so the reader's locale survives
* into the prompt's output-language directive; an unknown value still
* defaults to English, never German.
*/
function normalizeLocale(value: string | null): SupportedLocale {
return normalizeStatusLocale(value);
}
/**
* Run one pre-generation pass. Pure of pg-boss so the unit test can
* drive it directly. The master assistant kill-switch short-circuits
@@ -649,7 +634,10 @@ export async function runInsightPregenerate(
PREGENERATE_BUDGET_WINDOW_MS,
);
const locale = normalizeLocale(candidate.locale);
// The stored locale, then the operator default: a NULL column used to
// resolve to English here, so a German-language instance warmed its
// briefings in English every night.
const locale = await resolveJobLocale(candidate.locale);
let outcome: GenerateOutcome | null = null;
if (!budget.allowed) {
result.budgetBlocked++;
+4 -10
View File
@@ -30,8 +30,8 @@
import type { PrismaClient } from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { coerceLocale, type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import {
@@ -129,12 +129,6 @@ async function cleanupExpiredCoachReminders(
return result.count;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Pure due-predicate: at this instant, is the reminder due AND inside its
* local notify-hour window?
@@ -171,7 +165,7 @@ export function buildMeasurementReminderPayload(
label: string,
location: string | null,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const t = getServerTranslator(coerceLocale(locale)).t;
const base = t("measurementReminders.pushBody", { label });
const body = location
? `${base} ${t("measurementReminders.pushLocation", { location })}`
@@ -358,7 +352,7 @@ export async function runMeasurementReminderTick(
}
const { title, body } = buildMeasurementReminderPayload(
reminder.user.locale,
await resolveJobLocale(reminder.user.locale),
reminder.label,
reminder.location,
);
+5 -11
View File
@@ -75,8 +75,8 @@ import {
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { coerceLocale, type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { annotate, getEvent } from "@/lib/logging/context";
/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */
@@ -228,12 +228,6 @@ export function decideLowStockAction(input: {
return "notify";
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* v1.17.0 — localised push copy. When the supply data is rich enough to
* date (`runwayDays >= 1` with a derivable cadence) the body names the
@@ -255,7 +249,7 @@ export function buildLowStockPayload(input: {
schedules: RunwaySchedule[];
today: Date;
}): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(input.locale)).t;
const t = getServerTranslator(coerceLocale(input.locale)).t;
const title = t("lowStockReminders.title", { medName: input.medName });
if (input.runwayDays < 1) {
@@ -294,7 +288,7 @@ export function buildLowStockPayload(input: {
// Day-only, UTC: the runway dates are UTC-midnight calendar days, so a
// UTC formatter renders the intended day in every locale. A medium
// style stays compact and unambiguous for a push body.
const dateFmt = new Intl.DateTimeFormat(resolveLocale(input.locale), {
const dateFmt = new Intl.DateTimeFormat(coerceLocale(input.locale), {
timeZone: "UTC",
day: "numeric",
month: "short",
@@ -505,7 +499,7 @@ export async function runMedicationLowStockTick(
break;
case "notify": {
const { title, body } = buildLowStockPayload({
locale: user.locale,
locale: await resolveJobLocale(user.locale),
medName: med.name,
runwayDays: evaluation.runwayDays ?? 0,
unitsRemaining: evaluation.unitsRemaining,
+6 -10
View File
@@ -22,8 +22,8 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { coerceLocale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { getEvent } from "@/lib/logging/context";
@@ -59,12 +59,6 @@ export interface MoodReminderSummary {
failed: number;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Pure decision predicate: should the worker fire a mood reminder for
* this user at this instant?
@@ -115,7 +109,7 @@ export function buildMoodReminderPayload(locale: string | null | undefined): {
title: string;
body: string;
} {
const t = getServerTranslator(resolveLocale(locale)).t;
const t = getServerTranslator(coerceLocale(locale)).t;
return {
title: t("moodReminders.dailyTitle"),
body: t("moodReminders.dailyBody"),
@@ -222,7 +216,9 @@ export async function runMoodReminderTick(
continue;
}
const { title, body } = buildMoodReminderPayload(user.locale);
const { title, body } = buildMoodReminderPayload(
await resolveJobLocale(user.locale),
);
const outcome = await dispatchImpl({
eventType: "MOOD_REMINDER",
+3 -5
View File
@@ -35,10 +35,8 @@ import {
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared";
import {
normalizeLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import type { SupportedLocale } from "@/lib/insights/status-shared";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import {
MORNING_DIGEST_REFRESH_QUEUE,
type MorningDigestRefreshPayload,
@@ -102,7 +100,7 @@ export async function runMorningDigestRefresh(
return { status: "already-final" };
}
const locale = normalizeLocale(user.locale);
const locale = await resolveJobLocale(user.locale);
const outcome = await generate(userId, { locale, force: true });
// A hard provider failure leaves the day provisional and hands recovery to
+6 -15
View File
@@ -32,7 +32,8 @@ import {
type NarrativeGenerateOutcome,
} from "@/lib/insights/narrative/period-narrative-generate";
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import {
PERIOD_NARRATIVE_QUEUE,
PERIOD_NARRATIVE_CRON,
@@ -132,19 +133,6 @@ export async function findNarrativeCandidates(
.slice(0, cap);
}
/**
* The user's stored locale, validated against the shipped UI locale union.
*
* The narrative row is keyed `(user, period, locale)`, so the nightly warm has
* to write the SAME key the read route reads. Collapsing fr/es/it/pl to `en`
* here would leave those users' rows permanently cold on the nightly path.
* An unset or unknown value falls back to the app default (`en`), never to
* German.
*/
function normalizeLocale(value: string | null): Locale {
return locales.includes(value as Locale) ? (value as Locale) : defaultLocale;
}
/**
* Run one nightly warm pass. Pure of pg-boss so the unit test can drive it
* directly. Short-circuits to a no-op when (a) the master assistant briefing
@@ -197,7 +185,10 @@ export async function runPeriodNarrativeWarm(
continue;
}
const locale = normalizeLocale(candidate.locale);
// The narrative row is keyed `(user, period, locale)`, so the nightly
// warm has to write the SAME key the read route reads: the stored
// locale, then the operator default for an account that never set one.
const locale = await resolveJobLocale(candidate.locale);
for (const period of periods) {
const outcome = await generate(candidate.id, {
period,
+3 -8
View File
@@ -53,7 +53,8 @@ import { extractAssessmentSummary } from "@/lib/insights/status-shared";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import type { DailyDigest } from "@/lib/daily/digest";
@@ -101,12 +102,6 @@ export const REACTION_LINE_CLAIM_LEASE_MS = 2 * 60_000;
export type ReactionLineOutcome =
{ status: "skipped"; reason: string } | { status: "generated" };
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Normalise the model's output into a shippable sentence, or null.
*
@@ -458,7 +453,7 @@ export async function runReactionLine(
const user = await prisma.user.findUnique({ where: { id: job.userId } });
if (!user) return { status: "skipped", reason: "no_user" };
const locale = resolveLocale(user.locale);
const locale = await resolveJobLocale(user.locale);
const chain = await resolveProviderChain(job.userId);
if (chain.length === 0) return { status: "skipped", reason: "no_provider" };
+8 -5
View File
@@ -7,6 +7,7 @@
import { type Job } from "pg-boss";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status";
import {
INSIGHT_PREGENERATE_QUEUE,
@@ -96,7 +97,10 @@ export async function runStatusCronGenerate(
for (const user of users) {
try {
await generate(user.id, { locale: user.locale, force: false });
await generate(user.id, {
locale: await resolveJobLocale(user.locale),
force: false,
});
generated++;
} catch (error) {
failed++;
@@ -153,10 +157,9 @@ async function runStatusBatchCron(taskName: string): Promise<JobOutcome> {
for (const user of users) {
try {
const result = await generateStatusBatchForUser(user.id, {
// Validate against the six UI locales rather than collapsing to a
// binary — the per-metric generators normalise identically, and the
// prompt names the reader's own language from this value.
locale: normalizeLocale(user.locale),
// The stored locale, then the operator default — the prompt names
// the reader's own language from this value.
locale: await resolveJobLocale(user.locale),
force: false,
});
generated += result.batched + result.fellBack;
@@ -6,6 +6,7 @@
*/
import { type Job } from "pg-boss";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { decrypt } from "@/lib/crypto";
@@ -623,13 +624,14 @@ export async function handleReminderCheck(
// — so the gate lives in the dispatcher's APNs branch, which is
// the only place that knows whether an APNs send was on the table.
if (med.notificationsEnabled) {
const recipientLocale = await resolveJobLocale(med.user.locale);
const { title, message } = getPhaseMessage(
currentPhase,
med.name,
doseInfo,
timeWindow,
Math.ceil(minutesToEnd),
med.user.locale,
recipientLocale,
);
const renderForRecipient = (locale: Locale) =>
getPhaseMessage(
@@ -644,7 +646,7 @@ export async function handleReminderCheck(
const keyboard = getPhaseKeyboard(
currentPhase,
med.id,
med.user.locale,
recipientLocale,
);
// v0.5.4 — surface the slot time as an ISO 8601 string so
+3 -6
View File
@@ -45,7 +45,8 @@ import {
} from "@/lib/ai/prompts/workout-insight";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { finalizeStatusSummary } from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
@@ -380,11 +381,7 @@ export async function runWorkoutInsightGenerate(
return { status: "unchanged" as const };
}
const locale: Locale = (locales as readonly string[]).includes(
profile?.locale ?? "",
)
? (profile?.locale as Locale)
: defaultLocale;
const locale: Locale = await resolveJobLocale(profile?.locale);
return { status: "ready" as const, evidence, inputHash, locale };
})().catch(async (err) => {
// Nothing could have reached a provider. Delete the reservation so a