From 54c9b4206f0af62d2a925391e77bdd446a602baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:50:32 +0200 Subject: [PATCH] fix(i18n): persist User.locale so background messages match the UI language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language switcher wrote the choice to localStorage, the `healthlog-locale` cookie and `` only — never to the user row. Server-side background work (the proactive Coach nudge, the Telegram test message) has no request cookie to read and falls back to `User.locale`; with the column left null those messages went out in English regardless of the language the user reads the app in. Add `PUT /api/auth/me/locale` (idempotent, validated against the supported locales) and have the i18n provider mirror the active locale onto the row — on every switch and once on mount, so the column is backfilled for users who set their language before this landed. Fire-and-forget: a 401 on a public page or an offline blip never blocks the UI flip; the cookie stays the first-paint source of truth. --- src/app/api/auth/me/locale/route.ts | 62 +++++++++++++++++++++++++++++ src/lib/i18n/context.tsx | 28 +++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/app/api/auth/me/locale/route.ts diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts new file mode 100644 index 000000000..06681f44a --- /dev/null +++ b/src/app/api/auth/me/locale/route.ts @@ -0,0 +1,62 @@ +/** + * PUT /api/auth/me/locale + * + * v1.25.0 — persist the user's UI locale onto `User.locale`. + * + * The language switcher historically wrote the choice to localStorage, + * the `healthlog-locale` cookie and `` ONLY — never to the + * user row. Server-side background work (the proactive Coach nudge, the + * Telegram test message, …) has no request cookie to read, so it falls + * back to `User.locale`; with the column left null those messages went + * out in English regardless of the language the user reads the app in. + * + * This setter closes the gap: the client persists the active locale here + * (on every switch, plus a one-time backfill on mount) so the column + * reliably mirrors the UI language and the cron paths render in it. + * + * Auth: cookie session OR Bearer token. + * Validation: `{ "locale": "de" }` — must be one of the supported + * locales; anything else is rejected at the surface so the column never + * holds a value the resolver would later have to defend against. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { locales, type Locale } from "@/lib/i18n/config"; + +export const dynamic = "force-dynamic"; + +function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && (locales as readonly string[]).includes(value) + ); +} + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "user.locale.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const locale = (body as { locale?: unknown } | null)?.locale; + if (!isLocale(locale)) { + return apiError("Not a supported locale.", 422); + } + + // Idempotent: skip the write (and the annotation noise) when the column + // already matches — the mount-time backfill fires on every page load. + if (user.locale !== locale) { + await prisma.user.update({ + where: { id: user.id }, + data: { locale }, + }); + annotate({ meta: { locale_next: locale } }); + } + + return apiSuccess({ locale }); +}); diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 7c2dd1349..2add8a6d1 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -89,6 +89,26 @@ function persistLocale(newLocale: Locale) { document.documentElement.lang = newLocale; } +// v1.25.0 — mirror the active locale onto `User.locale` so server-side +// background work (the proactive Coach nudge, the Telegram test message) +// renders in the user's language. The cookie/localStorage choice is +// invisible to a cron that has no request context; without this write the +// column stays null and those messages fall back to English. Fire-and-forget +// and idempotent on the server: a 401 on a public page, an offline blip or a +// no-op equal value all fail silently and never block the UI flip. +function persistLocaleToServer(newLocale: Locale) { + void fetch("/api/auth/me/locale", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale: newLocale }), + credentials: "same-origin", + keepalive: true, + }).catch(() => { + // Best-effort: the cookie remains the first-paint source of truth, and + // the next mount retries the backfill. + }); +} + export function I18nProvider({ children, initialLocale, @@ -152,6 +172,14 @@ export function I18nProvider({ }; }, [locale]); + // v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on + // mount (backfilling the column for users who set their language before this + // landed) and on every switch, so the proactive Coach nudge and other + // cookie-blind background paths render in the language the user reads. + useEffect(() => { + persistLocaleToServer(locale); + }, [locale]); + const setLocale = useCallback((newLocale: Locale) => { if (!(locales as readonly string[]).includes(newLocale)) return;