mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep. The headline is a tighter dashboard and insights surface: six widgets retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>` mount, GLP-1 detail-page intake history + inventory, the `<InsightAdvisorCard>` block and its regeneration affordance, the weekly-report route), four broken edges close (workout-edit duplicate- timestamp 409, BD-Zielbereich tile rebuilt on the shared `<TrendCard>` primitive, `/insights/puls` chart timeout fallback, sticky tab-strip scroll lock), and one consistency contract lands per maintainer directive: every medication-list row, every medication- detail section header, every trend tile, every Coach launch glyph, every chart-height contract reads on one shape. The HealthScore card grows to fill its column, gains an accessible `?` tooltip explaining the delta, and drops the placeholder "Wochenbericht erstellen" button. iOS contracts are intact; the only `/api/*` evolution is additive (the `aggregate=monthly` grain on `/api/measurements` and an internal-only `/api/internal/web-vitals` beacon route).
168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* v1.4.28 R3d (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, 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<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>;
|
|
}
|
|
|
|
function isLocale(value: string | null | undefined): value is Locale {
|
|
return value === "de" || value === "en" || value === "fr" ||
|
|
value === "es" || value === "it" || value === "pl";
|
|
}
|
|
|
|
/**
|
|
* 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 R3d (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.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,
|
|
});
|
|
}
|