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, + }); +}