fix: resolve notification delivery identity

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:57:23 +02:00
1 parent 913a41b37d
commit 906118f86e
14 files changed
+266 -63

No files matched your search

@@ -114,4 +114,55 @@ describe("notification delivery identity", () => {
}),
);
});
it("refuses a managed record without an explicit recipient", async () => {
const outcome = await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: recordUserId,
title: "Record content",
message: "Record schedule",
});
expect(outcome).toEqual({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
});
expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled();
expect(sendViaWebPushMock).not.toHaveBeenCalled();
});
it("uses the recipient preference without changing record content", async () => {
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
id: "recipient-channel",
userId: recipientUserId,
type: "WEB_PUSH",
config: "{}",
nextRetryAt: null,
preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }],
},
] as never);
const outcome = await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: recordUserId,
recordUserId,
recipientUserId,
title: "Record content",
message: "Record schedule",
});
expect(outcome).toEqual({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
});
expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: recipientUserId, enabled: true },
}),
);
expect(sendViaWebPushMock).not.toHaveBeenCalled();
});
});
@@ -86,7 +86,9 @@ function makeChannel(over: Partial<MockChannel> = {}): MockChannel {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
managedProfileAt: null,
} as never);
// Default: every update returns a counter of 1 — individual tests
// override the resolved value when they need to drive a specific
// failure count (e.g. the "5th in a row" give-up test).
@@ -0,0 +1,45 @@
import { prisma } from "@/lib/db";
import type { NotificationPayload } from "@/lib/notifications/types";
export interface NotificationDeliveryIdentity {
recordUserId: string;
recipientUserId: string;
managed: boolean;
}
/**
* Resolve notification identities without treating a managed record as its
* own destination. Legacy payloads remain valid only for ordinary self
* delivery; a managed record must name its recipient explicitly.
*/
export async function resolveNotificationDeliveryIdentity(
payload: NotificationPayload,
): Promise<NotificationDeliveryIdentity | null> {
const hasRecordUserId = payload.recordUserId !== undefined;
const hasRecipientUserId = payload.recipientUserId !== undefined;
if (hasRecordUserId !== hasRecipientUserId) return null;
const recordUserId = payload.recordUserId ?? payload.userId;
const recipientUserId = payload.recipientUserId ?? payload.userId;
if (!recordUserId || !recipientUserId || payload.userId !== recordUserId) {
return null;
}
const record = await prisma.user.findUnique({
where: { id: recordUserId },
select: { managedProfileAt: true },
});
if (!record) return null;
const managed = record.managedProfileAt != null;
if (managed) {
return recipientUserId === recordUserId
? null
: { recordUserId, recipientUserId, managed };
}
return recipientUserId === recordUserId
? { recordUserId, recipientUserId, managed }
: null;
}
+7 -1
View File
@@ -45,6 +45,8 @@ import type { EventType } from "@/lib/notifications/types";
interface DispatchLocalisedOptions {
userId: string;
recordUserId?: string;
recipientUserId?: string;
titleKey: string;
messageKey: string;
params?: Record<string, string | number>;
@@ -147,7 +149,9 @@ async function resolveRecipientLocale(userId: string): Promise<Locale> {
export async function dispatchLocalisedNotification(
opts: DispatchLocalisedOptions,
): Promise<void> {
const locale = await resolveRecipientLocale(opts.userId);
const locale = await resolveRecipientLocale(
opts.recipientUserId ?? opts.userId,
);
const t = getServerTranslator(locale).t;
const title = t(opts.titleKey, opts.params);
@@ -173,6 +177,8 @@ export async function dispatchLocalisedNotification(
await dispatchNotification({
eventType: opts.eventType ?? "SYSTEM_ALERT",
userId: opts.userId,
recordUserId: opts.recordUserId,
recipientUserId: opts.recipientUserId,
title,
message,
metadata,
+39 -21
View File
@@ -29,6 +29,7 @@ import {
hasClientManagedApnsGate,
} from "@/lib/notifications/client-managed-apns";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity";
/**
* Dispatch a notification to all enabled channels for a user.
@@ -63,11 +64,21 @@ export async function dispatchNotification(
let channelsSucceeded = 0;
try {
const delivery = await resolveNotificationDeliveryIdentity(payload);
if (!delivery) {
getEvent()?.addWarning("Notification delivery identity was invalid");
return { dispatched: false, channelsAttempted, channelsSucceeded };
}
const deliveryPayload: NotificationPayload = {
...payload,
recordUserId: delivery.recordUserId,
recipientUserId: delivery.recipientUserId,
};
const channels = await prisma.notificationChannel.findMany({
where: { userId: payload.userId, enabled: true },
where: { userId: delivery.recipientUserId, enabled: true },
include: {
preferences: {
where: { eventType: payload.eventType },
where: { eventType: deliveryPayload.eventType },
},
},
});
@@ -79,7 +90,7 @@ export async function dispatchNotification(
if (!hasTelegramChannel) {
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
where: { id: delivery.recipientUserId },
select: {
telegramBotToken: true,
telegramChatId: true,
@@ -101,12 +112,12 @@ export async function dispatchNotification(
const created = await prisma.notificationChannel.upsert({
where: {
userId_type: {
userId: payload.userId,
userId: delivery.recipientUserId,
type: "TELEGRAM",
},
},
create: {
userId: payload.userId,
userId: delivery.recipientUserId,
type: "TELEGRAM",
enabled: true,
config: channelConfig,
@@ -117,13 +128,16 @@ export async function dispatchNotification(
},
include: {
preferences: {
where: { eventType: payload.eventType },
where: { eventType: deliveryPayload.eventType },
},
},
});
channels.push(created);
getEvent()?.addMeta("telegram_legacy_migration", payload.userId);
getEvent()?.addMeta(
"telegram_legacy_migration",
delivery.recipientUserId,
);
}
} catch (err) {
getEvent()?.addWarning(`Legacy Telegram migration failed: ${err}`);
@@ -145,7 +159,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[payload.eventType as EventType] ?? true;
EVENT_DEFAULT_ENABLED[deliveryPayload.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
@@ -156,9 +170,9 @@ 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 (payload.eventType === "MOOD_REMINDER") {
if (deliveryPayload.eventType === "MOOD_REMINDER") {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
where: { id: delivery.recipientUserId },
select: { moodReminderEnabled: true },
});
defaultEnabled = user?.moodReminderEnabled === true;
@@ -170,13 +184,13 @@ 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[payload.eventType];
const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType];
if (!gate) {
clientManagedApns = false;
return false;
}
const row = await prisma.user.findUnique({
where: { id: payload.userId },
where: { id: delivery.recipientUserId },
select: { notificationPrefs: true },
});
clientManagedApns = gate.isClientManaged(row?.notificationPrefs);
@@ -209,21 +223,23 @@ export async function dispatchNotification(
// about whether anything actually reached the user.
if (
channel.type === "APNS" &&
hasClientManagedApnsGate(payload.eventType) &&
!delivery.managed &&
hasClientManagedApnsGate(deliveryPayload.eventType) &&
(await resolveClientManagedApns())
) {
const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType];
getEvent()?.addMeta(gate.metaKey, gate.tag(payload.metadata));
const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType];
getEvent()?.addMeta(gate.metaKey, gate.tag(deliveryPayload.metadata));
if (gate.detail && gate.detailKey) {
getEvent()?.addMeta(
gate.detailKey,
gate.detail(payload.userId, payload.metadata),
gate.detail(deliveryPayload.userId, deliveryPayload.metadata),
);
}
recordPushAttempt({
userId: payload.userId,
recordUserId: delivery.recordUserId,
recipientUserId: delivery.recipientUserId,
channel: "APNS",
eventType: payload.eventType,
eventType: deliveryPayload.eventType,
result: "skipped",
reason: "client_managed",
});
@@ -236,7 +252,8 @@ export async function dispatchNotification(
const outcome = await sendToChannel(
channel.type as ChannelType,
channel.config,
payload,
delivery.recipientUserId,
deliveryPayload,
);
if (outcome.ok) {
@@ -310,6 +327,7 @@ export async function dispatchNotification(
async function sendToChannel(
type: ChannelType,
encryptedConfig: string,
recipientUserId: string,
payload: NotificationPayload,
): Promise<SendOutcome> {
let decrypted: string;
@@ -393,7 +411,7 @@ async function sendToChannel(
return sendViaEmail(config, payload);
}
case "WEB_PUSH": {
return sendViaWebPush(payload.userId, payload);
return sendViaWebPush(recipientUserId, payload);
}
case "APNS": {
// APNs config lives on the Device row, not in the channel config.
@@ -401,7 +419,7 @@ async function sendToChannel(
// we just keep the symmetric path so future per-user overrides
// (e.g. a custom APNs topic) can hang off the channel without
// changing the dispatcher shape.
return sendViaApns(payload.userId, payload);
return sendViaApns(recipientUserId, payload);
}
default:
getEvent()?.addWarning(`Unknown notification channel type: ${type}`);
+13 -7
View File
@@ -29,7 +29,7 @@ import apn from "@parse/node-apn";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { plainPushText } from "@/lib/notifications/strip-emoji";
export interface ApnsPayload {
@@ -532,11 +532,13 @@ export async function sendViaApns(
metadata?: Record<string, unknown>;
discreet?: boolean;
urgent?: boolean;
recordUserId?: string;
recipientUserId?: string;
},
): Promise<SendOutcome> {
const config = loadApnsConfig();
if (!config) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "APNS",
eventType: payload.eventType,
@@ -565,7 +567,7 @@ export async function sendViaApns(
if (devices.length === 0) {
// Same rationale as web-push: a user who hasn't paired an iPhone yet
// shouldn't see the channel auto-disabled. Treat as soft "no recipient".
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "APNS",
eventType: payload.eventType,
@@ -626,6 +628,10 @@ export async function sendViaApns(
process.env.APNS_CRITICAL_ENTITLEMENT === "true";
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
const managedDelivery =
payload.recordUserId !== undefined &&
payload.recipientUserId !== undefined &&
payload.recordUserId !== payload.recipientUserId;
// Discreet mode (cycle privacy): the lock-screen-visible routing
// metadata must not carry the event name. Collapse the category /
// threadId / collapseId and the data eventType to a generic value so
@@ -677,7 +683,7 @@ export async function sendViaApns(
// ignores categories it doesn't know about, so adding the key
// here is safe for event-types that don't have an actionable
// category registered yet — they fall back to a plain alert.
category: routingEvent,
category: managedDelivery ? undefined : routingEvent,
// Future-proof for an iOS Notification Service Extension. iOS
// ignores the flag when no extension is registered, so it's a
// no-op on today's binary but unblocks NSE work without a
@@ -742,7 +748,7 @@ export async function sendViaApns(
}
if (anySuccess) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "APNS",
eventType: payload.eventType,
@@ -755,7 +761,7 @@ export async function sendViaApns(
// until the user re-registers from a fresh iOS install.
if (deadDeviceIds.length === devices.length) {
const reason = lastFailureReason ?? "apns_all_devices_unregistered";
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "APNS",
eventType: payload.eventType,
@@ -771,7 +777,7 @@ export async function sendViaApns(
}
const reason = lastFailureReason ?? "apns_send_failed";
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "APNS",
eventType: payload.eventType,
+6 -5
View File
@@ -7,7 +7,7 @@ import type {
} from "@/lib/notifications/types";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { loadEmailConfig } from "@/lib/notifications/senders/email-config";
import { plainPushText } from "@/lib/notifications/strip-emoji";
@@ -97,12 +97,13 @@ export async function sendViaEmail(
config: EmailChannelConfig,
payload: NotificationPayload,
): Promise<SendOutcome> {
const userId = payload.recipientUserId ?? payload.userId;
const start = performance.now();
const transport = getTransporter();
if (!transport) {
// Operator hasn't configured SMTP — soft skip, no channel burn.
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "EMAIL",
eventType: payload.eventType,
@@ -113,7 +114,7 @@ export async function sendViaEmail(
}
if (!config.recipient) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "EMAIL",
eventType: payload.eventType,
@@ -143,7 +144,7 @@ export async function sendViaEmail(
method: "sendMail",
duration_ms: Math.round(performance.now() - start),
});
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "EMAIL",
eventType: payload.eventType,
@@ -158,7 +159,7 @@ export async function sendViaEmail(
duration_ms: Math.round(performance.now() - start),
error: classified.message,
});
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "EMAIL",
eventType: payload.eventType,
+5 -4
View File
@@ -5,7 +5,7 @@ import type {
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { isUrgentPayload } from "@/lib/notifications/types";
@@ -23,6 +23,7 @@ export async function sendViaNtfy(
config: NtfyChannelConfig,
payload: NotificationPayload,
): Promise<SendOutcome> {
const userId = payload.recipientUserId ?? payload.userId;
const start = performance.now();
try {
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
@@ -84,7 +85,7 @@ export async function sendViaNtfy(
});
if (res.ok) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
@@ -93,7 +94,7 @@ export async function sendViaNtfy(
return { ok: true, statusCode: res.status };
}
const classified = classifyHttpStatus(res.status, "ntfy");
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
@@ -114,7 +115,7 @@ export async function sendViaNtfy(
duration_ms: Math.round(performance.now() - start),
error: message,
});
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { NotificationPayload } from "@/lib/notifications/types";
/**
* v1.4.49 — fire-and-forget push-attempt ledger write.
@@ -55,6 +56,27 @@ interface AttributedPushAttemptRecord extends PushAttemptRecordBase {
export type PushAttemptRecord =
SelfPushAttemptRecord | AttributedPushAttemptRecord;
/**
* Preserve the supplied record subject while a sender records delivery for
* the recipient selected by the dispatcher. The legacy `userId` property in
* sender call sites is deliberately ignored here: 0298 stores it as a
* recipient mirror and the explicit pair is authoritative.
*/
export function recordPushAttemptForPayload(
payload: Pick<NotificationPayload, "recordUserId" | "recipientUserId">,
legacyUserId: string,
record: PushAttemptRecordBase & { userId?: string },
): void {
recordPushAttempt({
recordUserId: payload.recordUserId ?? legacyUserId,
recipientUserId: payload.recipientUserId ?? legacyUserId,
channel: record.channel,
eventType: record.eventType,
result: record.result,
reason: record.reason,
});
}
export function recordPushAttempt(record: PushAttemptRecord): void {
const recordUserId = "userId" in record ? record.userId : record.recordUserId;
const recipientUserId =
+13 -13
View File
@@ -8,7 +8,7 @@ import { classifyTelegramError } from "@/lib/notifications/retry-policy";
import { prisma } from "@/lib/db";
import type { ReminderPhase } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
import {
buildMoodKeyboard,
@@ -18,8 +18,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
/**
* v1.19.0 — resolve the bot locale for an interactive reminder. The
* sender knows the user via `payload.userId`; the keyboard labels need
* the user's locale. Defaults to "de" (the historical bot default) when
* sender knows the recipient through the delivery identity; keyboard labels
* need that account's locale. Defaults to "de" when
* unset, matching the webhook's `resolveBotLocale`.
*/
async function resolveSenderLocale(userId: string): Promise<Locale> {
@@ -118,6 +118,7 @@ export async function sendViaTelegram(
config: TelegramChannelConfig,
payload: NotificationPayload,
): Promise<TelegramSendResult> {
const userId = payload.recipientUserId ?? payload.userId;
const medicationId = payload.metadata?.medicationId as string | undefined;
const scheduleId = payload.metadata?.scheduleId as string | undefined;
const phase = payload.metadata?.phase as string | undefined;
@@ -152,24 +153,25 @@ export async function sendViaTelegram(
let interactiveKeyboard:
| { inline_keyboard: { text: string; callback_data: string }[][] }
| undefined;
if (!replyMarkup) {
const selfDelivery = payload.recordUserId === payload.recipientUserId;
if (selfDelivery && !replyMarkup) {
if (payload.eventType === "MOOD_REMINDER") {
interactiveKeyboard = buildMoodKeyboard(
await resolveSenderLocale(payload.userId),
await resolveSenderLocale(userId),
);
} else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) {
interactiveKeyboard = buildMeasurementKeyboard(
reminderId,
await resolveSenderLocale(payload.userId),
await resolveSenderLocale(userId),
);
}
}
// Build reply markup
const keyboard =
replyMarkup ??
(selfDelivery ? replyMarkup : undefined) ??
interactiveKeyboard ??
(payload.eventType === "MEDICATION_REMINDER" && medicationId
(selfDelivery && payload.eventType === "MEDICATION_REMINDER" && medicationId
? {
inline_keyboard: [
[
@@ -251,13 +253,11 @@ export async function sendViaTelegram(
// tapped prompt is deleted immediately by the webhook callback handler;
// this row is the fallback and is harmless once the message is gone.
if (result.ok && result.messageId && interactiveKeyboard) {
await scheduleTelegramAutoDelete(payload.userId, config.chatId, [
result.messageId,
]);
await scheduleTelegramAutoDelete(userId, config.chatId, [result.messageId]);
}
if (result.ok) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
@@ -266,7 +266,7 @@ export async function sendViaTelegram(
return { ok: true, messageId: result.messageId };
}
const classified = classifyTelegramError(result.errorDescription);
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
+7 -7
View File
@@ -5,7 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
@@ -36,7 +36,7 @@ export async function sendViaWebPush(
const config = await getVapidConfig();
if (!config) {
getEvent()?.addWarning("Web Push: VAPID keys not configured");
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
@@ -65,7 +65,7 @@ export async function sendViaWebPush(
// may simply not have hit "Subscribe" on this browser. Counting
// this as a give-up signal would lock the user out of the channel
// they just configured. Treat it as a soft "no recipient" instead.
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
@@ -231,7 +231,7 @@ export async function sendViaWebPush(
}
if (anySuccess) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
@@ -243,7 +243,7 @@ export async function sendViaWebPush(
// Every sub returned a permanent 410/404 → channel is dead until
// the user re-pairs a device. This is a hard reject.
if (allPermanentReject && expiredIds.length === subscriptions.length) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
@@ -260,7 +260,7 @@ export async function sendViaWebPush(
// Soft failure (5xx / 429 / fetch error) on at least one sub.
const classified = classifyHttpStatus(lastTransientStatus, "web-push");
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
@@ -279,7 +279,7 @@ export async function sendViaWebPush(
? err
: new Error("[web-push] sendViaWebPush failed"),
);
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
+5 -4
View File
@@ -5,7 +5,7 @@ import type {
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
@@ -32,6 +32,7 @@ export async function sendViaWebhook(
config: WebhookChannelConfig,
payload: NotificationPayload,
): Promise<SendOutcome> {
const userId = payload.recipientUserId ?? payload.userId;
const start = performance.now();
try {
const headers: Record<string, string> = {
@@ -82,7 +83,7 @@ export async function sendViaWebhook(
});
if (res.ok) {
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "WEBHOOK",
eventType: payload.eventType,
@@ -91,7 +92,7 @@ export async function sendViaWebhook(
return { ok: true, statusCode: res.status };
}
const classified = classifyHttpStatus(res.status, "webhook");
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "WEBHOOK",
eventType: payload.eventType,
@@ -112,7 +113,7 @@ export async function sendViaWebhook(
duration_ms: Math.round(performance.now() - start),
error: message,
});
recordPushAttempt({
recordPushAttemptForPayload(payload, userId, {
userId: payload.userId,
channel: "WEBHOOK",
eventType: payload.eventType,
+8
View File
@@ -252,7 +252,15 @@ export interface EmailChannelConfig {
export interface NotificationPayload {
eventType: EventType;
/**
* Health-record subject retained for established self-delivery callers.
* Managed delivery must also declare `recordUserId` and `recipientUserId`.
*/
userId: string;
/** The health record that supplies notification content and calculations. */
recordUserId?: string;
/** The account whose channel, locale, and preferences receive the delivery. */
recipientUserId?: string;
title: string;
message: string;
/** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
let sequence = 0;
@@ -173,4 +174,45 @@ describe("notification attribution (real Postgres)", () => {
}),
).toBeNull();
});
it("principal resolution accepts only an explicit managed subject and human recipient", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const ordinaryRecord = await createUser("ordinary");
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toEqual({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
managed: true,
});
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: ordinaryRecord.id,
recordUserId: ordinaryRecord.id,
recipientUserId: recipientUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
});
});