diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 0e797711c..880f0b6cf 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -329,12 +329,21 @@ export async function runMeasurementReminderTick( reminder.label, reminder.location, ); + const renderForRecipient = (locale: Locale) => { + const rendered = buildMeasurementReminderPayload( + locale, + reminder.label, + reminder.location, + ); + return { title: rendered.title, message: rendered.body }; + }; const outcome = await dispatchImpl({ eventType: "MEASUREMENT_REMINDER", userId: reminder.user.id, title, message: body, + renderForRecipient, metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 94d9458df..627ffdb99 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -497,11 +497,26 @@ export async function runMedicationLowStockTick( schedules: med.schedules, today: now, }); + const renderForRecipient = (locale: Locale) => { + const rendered = buildLowStockPayload({ + locale, + medName: med.name, + runwayDays: evaluation.runwayDays ?? 0, + unitsRemaining: evaluation.unitsRemaining, + expiredUnits: evaluation.expiredUnits, + leadDays, + triggerDays, + schedules: med.schedules, + today: now, + }); + return { title: rendered.title, message: rendered.body }; + }; const outcome = await dispatchImpl({ eventType: "MEDICATION_LOW_STOCK", userId: user.id, title, message: body, + renderForRecipient, metadata: { scheduledAt: now.toISOString(), medicationId: med.id, diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 1643f69ce..2decef35b 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import type { Locale } from "@/lib/i18n/config"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { decrypt } from "@/lib/crypto"; @@ -630,6 +631,15 @@ export async function handleReminderCheck( Math.ceil(minutesToEnd), med.user.locale, ); + const renderForRecipient = (locale: Locale) => + getPhaseMessage( + currentPhase, + med.name, + doseInfo, + timeWindow, + Math.ceil(minutesToEnd), + locale, + ); const keyboard = getPhaseKeyboard( currentPhase, @@ -652,6 +662,7 @@ export async function handleReminderCheck( userId: med.user.id, title, message, + renderForRecipient, metadata: { medicationId: med.id, scheduleId: schedule.id, diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index d24b9828b..4c01f0d11 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -154,10 +154,14 @@ export async function dispatchLocalisedNotification( const locale = await resolveRecipientLocale( opts.recipientUserId ?? opts.userId, ); - const t = getServerTranslator(locale).t; - - const title = t(opts.titleKey, opts.params); - const message = t(opts.messageKey, opts.params); + 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 @@ -183,6 +187,7 @@ export async function dispatchLocalisedNotification( recipientUserId: opts.recipientUserId, title, message, + renderForRecipient, metadata, managedFanoutEvent: opts.managedFanoutEvent, urgent: opts.urgent, diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index a718acb4c..751d56af5 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import type { NotificationPayload, TelegramChannelConfig, @@ -60,6 +61,26 @@ export interface DispatchOutcome { channelsSucceeded: number; } +function isLocale(value: string | null | undefined): value is Locale { + return (locales as readonly string[]).includes(value ?? ""); +} + +/** Render record-owned content only after the delivery recipient is known. */ +async function renderForRecipient( + payload: NotificationPayload, + recipientUserId: string, +): Promise { + const { renderForRecipient: render, ...deliveryPayload } = payload; + if (!render) return deliveryPayload; + + const recipient = await prisma.user.findUnique({ + where: { id: recipientUserId }, + select: { locale: true }, + }); + const locale = isLocale(recipient?.locale) ? recipient.locale : defaultLocale; + return { ...deliveryPayload, ...render(locale) }; +} + export async function dispatchNotification( payload: NotificationPayload, ): Promise { @@ -101,11 +122,15 @@ export async function dispatchNotification( recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, }; + const recipientPayload = await renderForRecipient( + deliveryPayload, + delivery.recipientUserId, + ); const channels = await prisma.notificationChannel.findMany({ where: { userId: delivery.recipientUserId, enabled: true }, include: { preferences: { - where: { eventType: deliveryPayload.eventType }, + where: { eventType: recipientPayload.eventType }, }, }, }); @@ -155,7 +180,7 @@ export async function dispatchNotification( }, include: { preferences: { - where: { eventType: deliveryPayload.eventType }, + where: { eventType: recipientPayload.eventType }, }, }, }); @@ -186,7 +211,7 @@ export async function dispatchNotification( // from /settings/notifications once they've seen the badge a few // times and decided they want push. let defaultEnabled = - EVENT_DEFAULT_ENABLED[deliveryPayload.eventType as EventType] ?? true; + EVENT_DEFAULT_ENABLED[recipientPayload.eventType as EventType] ?? true; // v1.7.0 — MOOD_REMINDER single source of truth = the visible card. // The event used to default OFF, layered on top of the per-user @@ -197,7 +222,7 @@ export async function dispatchNotification( // opt-out (a `NotificationPreference` row with `enabled = false`) is // still honoured below via `pref.enabled` — only the no-row default // is derived from the card. - if (deliveryPayload.eventType === "MOOD_REMINDER") { + if (recipientPayload.eventType === "MOOD_REMINDER") { const user = await prisma.user.findUnique({ where: { id: delivery.recipientUserId }, select: { moodReminderEnabled: true }, @@ -211,7 +236,7 @@ export async function dispatchNotification( let clientManagedApns: boolean | null = null; const resolveClientManagedApns = async (): Promise => { if (clientManagedApns !== null) return clientManagedApns; - const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; + const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType]; if (!gate) { clientManagedApns = false; return false; @@ -251,22 +276,22 @@ export async function dispatchNotification( if ( channel.type === "APNS" && !delivery.managed && - hasClientManagedApnsGate(deliveryPayload.eventType) && + hasClientManagedApnsGate(recipientPayload.eventType) && (await resolveClientManagedApns()) ) { - const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; - getEvent()?.addMeta(gate.metaKey, gate.tag(deliveryPayload.metadata)); + const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType]; + getEvent()?.addMeta(gate.metaKey, gate.tag(recipientPayload.metadata)); if (gate.detail && gate.detailKey) { getEvent()?.addMeta( gate.detailKey, - gate.detail(deliveryPayload.userId, deliveryPayload.metadata), + gate.detail(recipientPayload.userId, recipientPayload.metadata), ); } recordPushAttempt({ recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, channel: "APNS", - eventType: deliveryPayload.eventType, + eventType: recipientPayload.eventType, result: "skipped", reason: "client_managed", }); @@ -280,7 +305,7 @@ export async function dispatchNotification( channel.type as ChannelType, channel.config, delivery.recipientUserId, - deliveryPayload, + recipientPayload, ); if (outcome.ok) { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 419400ebf..a8db92882 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -1,3 +1,5 @@ +import type { Locale } from "@/lib/i18n/config"; + /** * Notification system type definitions. * Channels: Telegram, ntfy, Web Push @@ -285,6 +287,15 @@ export interface NotificationPayload { * paths may set it, and safety-floor is the only SYSTEM_ALERT admission. */ managedFanoutEvent?: ManagedGuardianFanoutEvent; + /** + * Internal rendering hook for record-owned notification content. The + * dispatcher invokes it only after choosing a recipient, then removes it + * before handing the payload to a channel adapter. + */ + renderForRecipient?: (locale: Locale) => { + title: string; + message: string; + }; /** * Discreet mode (cycle privacy). When true, the lock-screen-visible * routing metadata the senders would otherwise derive from `eventType`