fix: isolate guardian notification preferences

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:33:02 +02:00
1 parent be4ee9ac6d
commit 59de92b62e
6 files changed
+91 -15

No files matched your search

+9
View File
@@ -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,
+15
View File
@@ -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,
@@ -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,
+9 -4
View File
@@ -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,
+36 -11
View File
@@ -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<NotificationPayload> {
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<DispatchOutcome> {
@@ -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<boolean> => {
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) {
+11
View File
@@ -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`