Files
HealthLog/src/lib/notifications/dispatch-localised.ts
T
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00

185 lines
7.2 KiB
TypeScript

/**
* v1.4.27 — 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).
*
* v1.4.28 (R1.2 H5) — the locale lookup previously fired
* `prisma.user.findUnique` on every call. A 30-second TTL LRU keyed on
* `userId` collapses repeat reads inside the same recipient burst onto
* one DB round-trip; the next read after 30 s re-validates. The
* trade-off: a user who flips their locale in Settings won't see the
* change reflected in dispatch output for up to 30 s. That's
* acceptable for the call-sites this helper serves (admin alerts,
* deploy webhooks, reminder cron) — none of them are user-initiated
* within a tight feedback loop.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
interface DispatchLocalisedOptions {
userId: string;
recordUserId?: string;
recipientUserId?: string;
titleKey: string;
messageKey: string;
params?: Record<string, string | number>;
/**
* 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<string, unknown>;
/** Closed internal marker for an admitted managed-record event. */
managedFanoutEvent?: NotificationPayload["managedFanoutEvent"];
/**
* Escalate to every channel's highest urgency (v1.18.4). Forwarded to
* the dispatcher so APNs goes time-sensitive, ntfy max, Web Push
* `Urgency: high`, webhook `urgent` — APNs-less instances degrade
* gracefully to whatever channels are configured.
*/
urgent?: boolean;
}
/**
* Process-level locale cache.
*
* `Map`-based; capped to 1 000 entries with FIFO-style eviction once
* we hit the cap (LRU on insertion order — `Map` iterates in insertion
* order, so deleting + re-inserting on read marks the entry "fresh").
* Each entry stores the resolved `Locale` and the `Date.now()` at
* which it expires. Reads past the expiry re-fire the Prisma query
* and refresh the entry.
*
* Memory footprint at the cap is trivial (1 000 short strings + 1 000
* numbers ≈ 50 KB worst-case), so no eviction-by-size pressure is
* needed. The cap exists only to bound unbounded growth in workers
* that dispatch to many distinct users.
*/
const LOCALE_CACHE_TTL_MS = 30_000;
const LOCALE_CACHE_MAX = 1_000;
const localeCache = new Map<string, { locale: Locale; expiresAt: number }>();
/**
* Reset hook for tests. Production code never calls this — the cache
* is intentionally process-lifetime.
*/
export function __resetDispatchLocaleCacheForTests(): void {
localeCache.clear();
}
async function resolveRecipientLocale(userId: string): Promise<Locale> {
// v1.4.28 (R1.2 H5) — fast path: cached entry within TTL.
const cached = localeCache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
// Mark as fresh by re-inserting (Map iteration order doubles as
// LRU order — older entries sit at the head, newer at the tail).
localeCache.delete(userId);
localeCache.set(userId, cached);
return cached.locale;
}
// Cache miss or stale → resolve from Prisma.
let resolved: Locale = defaultLocale;
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
if (isLocale(row?.locale)) resolved = row.locale;
} catch (err) {
getEvent()?.addWarning(
`dispatchLocalisedNotification — locale lookup failed: ${err instanceof Error ? err.message : String(err)}`,
);
return defaultLocale;
}
// Insert + opportunistic eviction at the head when over the cap.
if (localeCache.size >= LOCALE_CACHE_MAX) {
const firstKey = localeCache.keys().next().value;
if (firstKey !== undefined) localeCache.delete(firstKey);
}
localeCache.set(userId, {
locale: resolved,
expiresAt: Date.now() + LOCALE_CACHE_TTL_MS,
});
return resolved;
}
export async function dispatchLocalisedNotification(
opts: DispatchLocalisedOptions,
): Promise<void> {
const locale = await resolveRecipientLocale(
opts.recipientUserId ?? opts.userId,
);
const renderForRecipient = (recipientLocale: Locale) => {
const t = getServerTranslator(recipientLocale).t;
return {
title: t(opts.titleKey, opts.params),
message: t(opts.messageKey, opts.params),
};
};
const { title, message } = renderForRecipient(locale);
// 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,
recordUserId: opts.recordUserId,
recipientUserId: opts.recipientUserId,
title,
message,
renderForRecipient,
metadata,
managedFanoutEvent: opts.managedFanoutEvent,
urgent: opts.urgent,
});
}