mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix: resolve notification delivery identity
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user