mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix: isolate guardian notification preferences
This commit is contained in:
1 parent
be4ee9ac6d
commit
59de92b62e
6 files changed
+91
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user