mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
2563690e4b
commit
54c9b4206f
2 files changed
+90
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user