fix(i18n): persist User.locale so background messages match the UI language

The language switcher wrote the choice to localStorage, the
`healthlog-locale` cookie and `<html lang>` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:50:32 +02:00
1 parent 2563690e4b
commit 54c9b4206f
2 files changed
+90

No files matched your search

+62
View File
@@ -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 `<html lang>` 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 });
});
+28
View File
@@ -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;