From 2c9d17c333d30a86846b7e2f2701e41335b24474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:36:19 +0200 Subject: [PATCH] feat(notifications): add a translator-aware dispatch helper The base dispatchNotification is locale-agnostic by design: it forwards title and message to every sender verbatim. Several call-sites (admin alerts, deploy webhook, Telegram test routes, reminder-check) compose bodies in English without consulting User.locale, leaving recipients with mismatched copy. Introduce dispatchLocalisedNotification next to the base dispatcher. The helper resolves the recipient's User.locale (falls back to the project default when missing, unsupported, or unreadable), calls getServerTranslator(locale).t on both the title and message keys with optional params, and delegates to dispatchNotification with the composed strings. Missing translation keys fall back to the raw key and log a wide-event warning so ops can spot bundle drift. Locale resolution is intentionally restricted to the persisted User.locale column. The helper runs from background jobs and webhook handlers where the request context may not belong to the recipient (an admin alert resolves the admin's locale, not the affected user's), so cookies and Accept-Language are out of scope. Forward-compat scaffolding: an optional channel selector ('telegram' | 'email') is accepted and forwarded as metadata.preferredChannel so call-sites can declare intent today; the dispatcher continues to fan across every enabled channel until a follow-up wires routing. Tests: - notifications/__tests__/admin-locale.test.ts drives the helper through de / fr / null / unsupported / missing-row locale paths, the missing-translation-key fallback, the event-type and metadata pass-through, and a Prisma-throws path that must not propagate. Bundle keys referenced by this commit (B6 will land the JSON edits): - notifications.admin.* - notifications.user.* --- .../__tests__/admin-locale.test.ts | 247 ++++++++++++++++++ src/lib/notifications/dispatch-localised.ts | 116 ++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/lib/notifications/__tests__/admin-locale.test.ts create mode 100644 src/lib/notifications/dispatch-localised.ts diff --git a/src/lib/notifications/__tests__/admin-locale.test.ts b/src/lib/notifications/__tests__/admin-locale.test.ts new file mode 100644 index 000000000..f27fc3444 --- /dev/null +++ b/src/lib/notifications/__tests__/admin-locale.test.ts @@ -0,0 +1,247 @@ +/** + * v1.4.27 F21 — translator-aware dispatch helper. + * + * Drives `dispatchLocalisedNotification` through the three locale + * resolution paths plus the missing-key fallback. The dispatcher and + * translator are mocked so the test is hermetic. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); + +const dispatchNotificationMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: (...args: unknown[]) => + dispatchNotificationMock(...args), +})); + +const addWarningMock = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: addWarningMock, + addMeta: vi.fn(), + }), +})); + +// Translator mock that mimics the real `getServerTranslator` shape: +// returns the registered string for the locale + key, otherwise falls +// back to the raw key (the real translator does the same after trying +// the default locale). +const fakeStrings: Record> = { + de: { + "notifications.admin.testTitle": "Test-Hinweis", + "notifications.admin.testBody": "Hallo {name}, der Test läuft.", + }, + en: { + "notifications.admin.testTitle": "Test notification", + "notifications.admin.testBody": "Hello {name}, the test is running.", + }, + fr: { + "notifications.admin.testTitle": "Notification de test", + "notifications.admin.testBody": "Bonjour {name}, le test est en cours.", + }, +}; + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: (locale: string) => ({ + locale, + t: (key: string, params?: Record) => { + const bundle = fakeStrings[locale] ?? fakeStrings.en; + let value = bundle[key] ?? fakeStrings.en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + } + } + return value; + }, + }), +})); + +import { dispatchLocalisedNotification } from "../dispatch-localised"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("dispatchLocalisedNotification — locale resolution", () => { + it("uses User.locale=de → German title and body", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-de", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Test-Hinweis"); + expect(payload.message).toBe("Hallo Test, der Test läuft."); + expect(payload.userId).toBe("u-de"); + expect(payload.eventType).toBe("SYSTEM_ALERT"); + }); + + it("uses User.locale=fr → French title and body", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "fr", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-fr", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Notification de test"); + expect(payload.message).toBe("Bonjour Test, le test est en cours."); + }); + + it("falls back to the project default locale (en) when User.locale is null", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: null, + } as never); + + await dispatchLocalisedNotification({ + userId: "u-no-locale", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Test notification"); + expect(payload.message).toBe("Hello Test, the test is running."); + }); + + it("falls back to the project default locale when User.locale is an unsupported string", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "klingon", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-bogus", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Test notification"); + }); + + it("falls back to the default locale when the user row is missing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null); + + await dispatchLocalisedNotification({ + userId: "u-missing", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Test notification"); + }); +}); + +describe("dispatchLocalisedNotification — missing translation key", () => { + it("falls back to the raw key string and logs a warning", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "en", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-en", + titleKey: "notifications.admin.unknownTitleKey", + messageKey: "notifications.admin.unknownBodyKey", + }); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + // The translator falls back to the raw key — we still send + // *something* rather than dropping the notification. + expect(payload.title).toBe("notifications.admin.unknownTitleKey"); + expect(payload.message).toBe("notifications.admin.unknownBodyKey"); + // Warning so ops can spot the missing-bundle drift. + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("missing translation"), + ); + }); +}); + +describe("dispatchLocalisedNotification — pass-through plumbing", () => { + it("forwards a custom event type when supplied", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "en", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-en", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + eventType: "MEDICATION_REMINDER", + }); + + expect(dispatchNotificationMock.mock.calls[0][0].eventType).toBe( + "MEDICATION_REMINDER", + ); + }); + + it("annotates metadata with preferredChannel when channel is supplied", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + locale: "en", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-en", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + channel: "telegram", + metadata: { source: "test" }, + }); + + expect(dispatchNotificationMock.mock.calls[0][0].metadata).toEqual({ + source: "test", + preferredChannel: "telegram", + }); + }); + + it("does not crash when the locale lookup throws (logs warning, falls back to default)", async () => { + vi.mocked(prisma.user.findUnique).mockRejectedValueOnce( + new Error("db down"), + ); + + await expect( + dispatchLocalisedNotification({ + userId: "u-bad", + titleKey: "notifications.admin.testTitle", + messageKey: "notifications.admin.testBody", + params: { name: "Test" }, + }), + ).resolves.toBeUndefined(); + + const payload = dispatchNotificationMock.mock.calls[0][0]; + expect(payload.title).toBe("Test notification"); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("locale lookup failed"), + ); + }); +}); diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts new file mode 100644 index 000000000..b58e46a66 --- /dev/null +++ b/src/lib/notifications/dispatch-localised.ts @@ -0,0 +1,116 @@ +/** + * v1.4.27 F21 — translator-aware notification dispatch. + * + * The base `dispatchNotification` (in `./dispatcher`) is deliberately + * locale-agnostic: it forwards `title` and `message` verbatim to every + * sender. The contract is "the caller composes the localised body". + * + * Several call-sites (admin alerts, deploy-webhook, Telegram test + * routes, reminder-check) compose bodies in English without consulting + * `User.locale`. This helper closes that gap: + * + * 1. resolves the recipient's `User.locale` (falls back to the + * project default when missing). + * 2. calls `getServerTranslator(locale).t(titleKey, params)` and + * `.t(messageKey, params)`. + * 3. delegates to `dispatchNotification` with the resolved strings. + * + * The base dispatcher stays untouched so already-localised callers + * (e.g. `jobs/reminder-phases.ts`) don't churn. Use this helper from + * every new admin- or user-facing surface that emits notifications. + * + * The default `eventType` is `SYSTEM_ALERT` — the only event type that + * predates F21 outside of the medication-reminder lane. Callers that + * need a different event type pass it explicitly; the `channel` option + * is reserved for a future per-channel routing pass and is currently + * a no-op (the dispatcher fans the message across every enabled + * channel as before). + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import type { EventType } from "@/lib/notifications/types"; + +interface DispatchLocalisedOptions { + userId: string; + titleKey: string; + messageKey: string; + params?: Record; + /** + * Per-channel routing is forward-compat scaffolding. Today the + * dispatcher fans out across every enabled channel for the user; the + * option is accepted so call-sites can declare intent without + * needing a second helper later. + */ + channel?: "telegram" | "email"; + /** Event type for preference gating. Defaults to SYSTEM_ALERT. */ + eventType?: EventType; + /** Channel-specific extras forwarded to the dispatcher metadata. */ + metadata?: Record; +} + +function isLocale(value: string | null | undefined): value is Locale { + return value === "de" || value === "en" || value === "fr" || + value === "es" || value === "it" || value === "pl"; +} + +/** + * Resolve a user's persisted locale, falling back to the project + * default when the column is null/empty. We deliberately do NOT chain + * through cookies or Accept-Language here — this helper runs from + * background jobs and webhook handlers where the request context may + * not belong to the recipient (an admin alert resolves the admin's + * locale, not the affected user's), so the only authoritative source + * is the persisted column. + */ +async function resolveRecipientLocale(userId: string): Promise { + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + if (isLocale(row?.locale)) return row.locale; + } catch (err) { + getEvent()?.addWarning( + `dispatchLocalisedNotification — locale lookup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return defaultLocale; +} + +export async function dispatchLocalisedNotification( + opts: DispatchLocalisedOptions, +): Promise { + const locale = await resolveRecipientLocale(opts.userId); + const t = getServerTranslator(locale).t; + + const title = t(opts.titleKey, opts.params); + const message = t(opts.messageKey, opts.params); + + // Warn when a translation key falls back to its own raw string — + // that signals a missing entry in `messages/{locale}.json` (or a + // typo in the call-site). The dispatcher still fires so the user + // gets *something*, but ops should see the gap. + if (title === opts.titleKey || message === opts.messageKey) { + getEvent()?.addWarning( + `dispatchLocalisedNotification — missing translation for ${opts.titleKey} / ${opts.messageKey} in locale ${locale}`, + ); + } + + // `channel` is accepted for future per-channel routing; today it + // is silently passed through. Suppress unused-var lint by referencing + // it on the metadata if present. + const metadata = opts.channel + ? { ...(opts.metadata ?? {}), preferredChannel: opts.channel } + : opts.metadata; + + await dispatchNotification({ + eventType: opts.eventType ?? "SYSTEM_ALERT", + userId: opts.userId, + title, + message, + metadata, + }); +}