Merge notification delivery identity

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:12:42 +02:00
commit 5e8b90bcc1
42 files changed
+1668 -337

No files matched your search

@@ -0,0 +1,121 @@
-- v1.37.0 — a notification names the health record it concerns and the
-- account whose channels receive it. The former remains the source of health
-- calculations and content; the latter owns delivery preferences and reach.
--
-- `user_id` remains as a legacy recipient mirror so established self-delivery
-- writers and bounded historical readers stay valid while every row gains the
-- explicit pair. The trigger maps an omitted pair to self-delivery, rejects a
-- partial or inconsistent pair, and refuses a managed profile as recipient.
ALTER TABLE "push_attempts"
ADD COLUMN "record_user_id" TEXT,
ADD COLUMN "recipient_user_id" TEXT;
UPDATE "push_attempts"
SET
"record_user_id" = "user_id",
"recipient_user_id" = "user_id";
ALTER TABLE "push_attempts"
ALTER COLUMN "record_user_id" SET NOT NULL,
ALTER COLUMN "recipient_user_id" SET NOT NULL;
ALTER TABLE "push_attempts"
ADD CONSTRAINT "push_attempts_record_user_id_fkey"
FOREIGN KEY ("record_user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "push_attempts_recipient_user_id_fkey"
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "push_attempts_recipient_user_id_created_at_desc_idx"
ON "push_attempts" ("recipient_user_id", "created_at" DESC);
CREATE INDEX "push_attempts_record_user_id_created_at_desc_idx"
ON "push_attempts" ("record_user_id", "created_at" DESC);
CREATE TABLE "notification_events" (
"id" TEXT NOT NULL,
"record_user_id" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"dedup_key" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_events_record_user_id_fkey"
FOREIGN KEY ("record_user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx"
ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC);
-- Tracking belongs to the account that received the Telegram message. The
-- prior subject-only key collapsed multiple delegated recipients into one row.
ALTER TABLE "telegram_reminder_messages"
ADD COLUMN "recipient_user_id" TEXT;
UPDATE "telegram_reminder_messages" AS "message"
SET "recipient_user_id" = "medication"."user_id"
FROM "medications" AS "medication"
WHERE "medication"."id" = "message"."medication_id";
ALTER TABLE "telegram_reminder_messages"
ALTER COLUMN "recipient_user_id" SET NOT NULL,
ADD CONSTRAINT "telegram_reminder_messages_recipient_user_id_fkey"
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key";
CREATE UNIQUE INDEX "telegram_reminder_messages_recipient_slot_key"
ON "telegram_reminder_messages" (
"recipient_user_id",
"medication_id",
"schedule_id",
"date",
"phase",
"time_of_day"
);
CREATE FUNCTION enforce_push_attempt_attribution()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.record_user_id IS NULL AND NEW.recipient_user_id IS NULL THEN
NEW.record_user_id := NEW.user_id;
NEW.recipient_user_id := NEW.user_id;
ELSIF NEW.record_user_id IS NULL OR NEW.recipient_user_id IS NULL THEN
RAISE EXCEPTION 'push attempt attribution requires both principals';
ELSIF NEW.user_id <> NEW.recipient_user_id THEN
RAISE EXCEPTION 'push attempt recipient must match user_id';
END IF;
IF EXISTS (
SELECT 1
FROM "users"
WHERE "id" = NEW.recipient_user_id
AND "managed_profile_at" IS NOT NULL
) THEN
RAISE EXCEPTION 'managed profile cannot receive notification delivery';
END IF;
IF NEW.record_user_id <> NEW.recipient_user_id
AND NOT EXISTS (
SELECT 1
FROM "users"
WHERE "id" = NEW.record_user_id
AND "managed_profile_at" IS NOT NULL
) THEN
RAISE EXCEPTION 'cross-principal delivery requires a managed record';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER push_attempt_attribution_guard
BEFORE INSERT OR UPDATE ON "push_attempts"
FOR EACH ROW
EXECUTE FUNCTION enforce_push_attempt_attribution();
+51 -21
View File
@@ -860,7 +860,10 @@ model User {
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser")
pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser")
notificationEvents NotificationEvent[]
telegramReminderMessages TelegramReminderMessage[]
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
// the 0–100 map to the user's own training load without re-integrating
@@ -3362,24 +3365,26 @@ model ReminderPhaseConfig {
}
model TelegramReminderMessage {
id String @id @default(cuid())
medicationId String @map("medication_id")
scheduleId String @map("schedule_id")
chatId String @map("chat_id")
messageId Int @map("message_id")
phase ReminderPhase
sentAt DateTime @default(now()) @map("sent_at")
date String // YYYY-MM-DD for day grouping
id String @id @default(cuid())
recipientUserId String @map("recipient_user_id")
medicationId String @map("medication_id")
scheduleId String @map("schedule_id")
chatId String @map("chat_id")
messageId Int @map("message_id")
phase ReminderPhase
sentAt DateTime @default(now()) @map("sent_at")
date String // YYYY-MM-DD for day grouping
/// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local).
/// Empty string = the single legacy window (pre-v1.7 rows + single-time
/// schedules). Part of the dedup key so a multi-time schedule dispatches
/// once per time-of-day per phase per day instead of once per day.
timeOfDay String @default("") @map("time_of_day")
timeOfDay String @default("") @map("time_of_day")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key")
@@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
@@index([medicationId, date])
@@map("telegram_reminder_messages")
}
@@ -5803,6 +5808,22 @@ model ConsentReceipt {
// `channel` is the dispatcher `ChannelType` string (`APNS`,
// `WEB_PUSH`, `TELEGRAM`, `NTFY`).
//
// Record-scoped notification events are durable event anchors rather than
// channel delivery records. They intentionally name no recipient, which keeps
// scheduler deduplication independent from future Guardian fan-out.
model NotificationEvent {
id String @id @default(cuid())
recordUserId String @map("record_user_id")
eventType String @map("event_type")
dedupKey String @map("dedup_key")
createdAt DateTime @default(now()) @map("created_at")
recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade)
@@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)])
@@map("notification_events")
}
// `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped`
// covers the "no recipient" / "config missing" soft cases.
//
@@ -5812,17 +5833,26 @@ model ConsentReceipt {
// classified `web_push_*` reason; Telegram / NTFY likewise mirror
// their sender-side `SendOutcome.reason`.
model PushAttempt {
id String @id @default(cuid())
userId String @map("user_id")
channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"
eventType String @map("event_type") // dispatcher event-type string
result String // "ok" | "error" | "skipped"
reason String? // sender-side reason on error/skip; null on ok
createdAt DateTime @default(now()) @map("created_at")
id String @id @default(cuid())
/// Legacy recipient mirror. Migration 0298 preserves established writers
/// while the database trigger requires it to equal `recipientUserId`.
userId String @map("user_id")
/// The health-record subject. This supplies calculation context and content.
recordUserId String? @map("record_user_id")
/// The account whose channel, preferences, and locale receive the delivery.
recipientUserId String? @map("recipient_user_id")
channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"
eventType String @map("event_type") // dispatcher event-type string
result String // "ok" | "error" | "skipped"
reason String? // sender-side reason on error/skip; null on ok
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade)
recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@index([recordUserId, createdAt(sort: Desc)])
@@index([recipientUserId, createdAt(sort: Desc)])
@@map("push_attempts")
}
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const transactionMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findMany: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
$transaction: (...args: unknown[]) => transactionMock(...args),
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(async () => undefined),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (value: string) => value,
encrypt: (value: string) => value,
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addMeta: vi.fn(),
}),
}));
const sendViaWebPushMock = vi.fn();
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/email", () => ({
sendViaEmail: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/telegram", () => ({
sendViaTelegram: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/web-push", () => ({
sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args),
}));
vi.mock("@/lib/notifications/senders/webhook", () => ({
sendViaWebhook: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
const recordUserId = "managed-record";
const recipientUserId = "guardian-recipient";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
managedProfileAt: new Date(),
} as never);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
id: "recipient-channel",
userId: recipientUserId,
type: "WEB_PUSH",
config: "{}",
nextRetryAt: null,
preferences: [],
},
] as never);
vi.mocked(prisma.notificationChannel.update).mockResolvedValue({
consecutiveFailures: 0,
} as never);
transactionMock.mockImplementation(
async (operation: (tx: unknown) => Promise<unknown>) =>
operation({
$queryRaw: vi.fn(),
user: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
where.id === recordUserId
? { managedProfileAt: new Date() }
: { managedProfileAt: null },
),
},
accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) },
}),
);
sendViaWebPushMock.mockResolvedValue({ ok: true });
});
describe("notification delivery identity", () => {
it("principal resolution uses the recipient channel for a managed record", async () => {
const outcome = await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: recordUserId,
recordUserId,
recipientUserId,
title: "Record content",
message: "Record schedule",
});
expect(outcome).toEqual({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
});
expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: recipientUserId, enabled: true },
}),
);
expect(sendViaWebPushMock).toHaveBeenCalledWith(
recipientUserId,
expect.objectContaining({
userId: recordUserId,
recordUserId,
recipientUserId,
title: "Record content",
message: "Record schedule",
}),
);
expect(transactionMock).toHaveBeenCalledTimes(1);
});
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();
});
});
@@ -68,7 +68,6 @@ vi.mock("@/lib/crypto", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
@@ -97,6 +96,8 @@ interface DiagnosticEnvelope {
configPresent: boolean;
}>;
recentPushAttempts: Array<{
recordUserId: string;
recipientUserId: string;
eventType: string;
channel: string;
result: string;
@@ -341,6 +342,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
// mapping step normalises to an ISO string under `at`.
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
{
recordUserId: "managed-record",
recipientUserId: ADMIN_USER_ID,
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
@@ -348,6 +351,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
createdAt: new Date("2026-05-22T09:00:00.000Z"),
},
{
recordUserId: ADMIN_USER_ID,
recipientUserId: ADMIN_USER_ID,
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
@@ -362,6 +367,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
expect(res.status).toBe(200);
expect(body.data.recentPushAttempts).toEqual([
{
recordUserId: "managed-record",
recipientUserId: ADMIN_USER_ID,
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
@@ -369,6 +376,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
at: "2026-05-22T09:00:00.000Z",
},
{
recordUserId: ADMIN_USER_ID,
recipientUserId: ADMIN_USER_ID,
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
@@ -386,28 +395,24 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { userId: string };
where: { recipientUserId: string };
orderBy: { createdAt: "desc" };
take: number;
};
expect(args.where.userId).toBe(ADMIN_USER_ID);
expect(args.where.recipientUserId).toBe(ADMIN_USER_ID);
expect(args.orderBy).toEqual({ createdAt: "desc" });
expect(args.take).toBe(20);
});
it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => {
// The medication tick anchors its per-slot dedup in this table under a
// sentinel channel. Letting those rows into the trailing window would
// push the actual delivery attempts — the thing an operator opens this
// endpoint for — off the list.
it("reads recipient-scoped delivery attempts without bookkeeping sentinels", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
await GET();
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { channel?: { not?: string } };
where: { recipientUserId?: string };
};
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
expect(args.where).toEqual({ recipientUserId: ADMIN_USER_ID });
});
});
@@ -4,7 +4,6 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import type { ChannelType } from "@/lib/notifications/types";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
export const dynamic = "force-dynamic";
@@ -59,6 +58,8 @@ interface DiagnosticChannel {
}
interface DiagnosticPushAttempt {
recordUserId: string;
recipientUserId: string;
eventType: string;
channel: string;
result: string;
@@ -170,18 +171,19 @@ export const GET = apiHandler(async () => {
where: { userId: user.id },
select: { type: true, enabled: true, config: true },
}),
// v1.4.49 — trailing 20 push attempts for the calling admin's own
// account. The `(user_id, created_at DESC)` index on `push_attempts`
// turns the orderBy + take into a single index scan, so the cost is
// bounded regardless of the user's lifetime push volume.
// Trailing delivery attempts for the calling account as recipient. The
// recipient index keeps the bounded diagnostic read independent of the
// account's lifetime notification volume.
prisma.pushAttempt.findMany({
// The reminder tick's per-slot dedup anchors live in this table under
// a sentinel channel. They are bookkeeping, not delivery attempts,
// and would push the real sends out of the trailing window.
where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } },
where: {
recipientUserId: user.id,
},
orderBy: { createdAt: "desc" },
take: 20,
select: {
userId: true,
recordUserId: true,
recipientUserId: true,
eventType: true,
channel: true,
result: true,
@@ -215,6 +217,8 @@ export const GET = apiHandler(async () => {
// `DiagnosticPushAttempt` interface above (the existing consumer
// contract uses `at`, not `createdAt`).
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
recordUserId: a.recordUserId ?? a.userId,
recipientUserId: a.recipientUserId ?? a.userId,
eventType: a.eventType,
channel: a.channel,
result: a.result,
@@ -37,7 +37,6 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
import { NextRequest } from "next/server";
const ADMIN_CTX = {
@@ -140,15 +139,12 @@ describe("GET /api/admin/notifications/health — aggregate", () => {
]);
});
it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => {
// The medication tick anchors its per-slot dedup in the same table
// under a sentinel channel. Those rows are not delivery attempts, so
// counting them would misreport what the channels are doing.
it("groups every delivery attempt in the selected window", async () => {
await GET(req());
const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as {
where: { channel?: { not?: string } };
where: { createdAt?: { gte?: Date } };
};
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
expect(args.where.createdAt?.gte).toBeInstanceOf(Date);
});
it("defaults the window to 24h and clamps out-of-range values", async () => {
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
export const dynamic = "force-dynamic";
@@ -59,12 +58,8 @@ export const GET = apiHandler(async (request) => {
const [grouped, disabled] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
// The reminder tick anchors its per-slot dedup in the same table
// under a sentinel channel. Those rows record a decision, not a
// delivery, so they stay out of the per-channel health picture.
where: {
createdAt: { gte: since },
channel: { not: REMINDER_DEDUP_CHANNEL },
},
_count: { _all: true },
}),
@@ -291,6 +291,14 @@ describe("Telegram webhook — callback dispatch", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.telegramReminderMessage.deleteMany).toHaveBeenCalledWith({
where: {
recipientUserId: "user-1",
medicationId: "med-1",
medication: { userId: "user-1" },
},
});
// No schedules wired → the take is ad-hoc and inserts standalone.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
@@ -779,6 +787,16 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.telegramReminderMessage.findFirst).toHaveBeenCalledWith({
where: {
recipientUserId: "user-1",
medicationId: "med-1",
chatId: "7777",
messageId: 555,
},
select: { date: true, timeOfDay: true },
});
// The pending row was updated in place — never a duplicate insert.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
+2
View File
@@ -161,6 +161,7 @@ export const WIPE_MODELS = [
"NotificationChannel",
"PushSubscription",
"PushAttempt",
"NotificationEvent",
"Device",
"TelegramScheduledDeletion",
"TelegramPromptContext",
@@ -421,6 +422,7 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
// attribution and must not let deleting a manager erase another record's
// import history.
MedicationIntakeImportJob: ["recordUserId"],
NotificationEvent: ["recordUserId"],
// Both sides. Wiping only the grantor side would leave the account still
// holding read access to other people's records after it asked for
// everything of its own to be deleted.
+3 -1
View File
@@ -405,7 +405,9 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
// Ledgers whose meaning is local to the deployment.
PushAttempt:
"A 90-day delivery ledger for this host's sends, and since v1.33.1 also the anchor rows the reminder dedup writes under a sentinel channel. Both describe what this instance did; restoring either onto another host would assert sends it never made, and the dedup anchors would suppress reminders that host still owes.",
"A 90-day delivery ledger for this host's sends. Restoring it onto another host would assert sends it never made.",
NotificationEvent:
"A local notification dedup anchor. Restoring it onto another host would suppress reminders and safety notices that host still owes.",
AuditLog:
"An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.",
CoachUsage:
@@ -10,10 +10,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const findFirstMock = vi.fn();
const createMock = vi.fn();
const dispatchMock = vi.fn();
const userFindUniqueMock = vi.fn();
const claimMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: {
user: {
findUnique: (...a: unknown[]) => userFindUniqueMock(...a),
},
notificationEvent: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
create: (...a: unknown[]) => createMock(...a),
},
@@ -28,6 +33,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
vi.mock("@/lib/notifications/reminder-dedup", () => ({
claimNotificationEvent: (...a: unknown[]) => claimMock(...a),
}));
import { notifyIllnessRedFlag } from "../red-flag-notify";
import type { IllnessRedFlag } from "../correlation";
@@ -45,9 +54,11 @@ const feverFlag: IllnessRedFlag = {
};
beforeEach(() => {
userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null });
findFirstMock.mockReset().mockResolvedValue(null);
createMock.mockReset().mockResolvedValue({});
dispatchMock.mockReset().mockResolvedValue(undefined);
claimMock.mockReset().mockResolvedValue(true);
});
afterEach(() => vi.clearAllMocks());
@@ -85,25 +96,46 @@ describe("notifyIllnessRedFlag", () => {
);
});
it("de-dupes when a recent ledger row exists for the episode", async () => {
findFirstMock.mockResolvedValueOnce({ id: "prior" });
it("does not dispatch when a concurrent claimant already owns the episode", async () => {
claimMock.mockResolvedValueOnce(false);
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e1",
redFlags: [feverFlag],
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(createMock).not.toHaveBeenCalled();
expect(claimMock).toHaveBeenCalledTimes(1);
});
it("stamps the ledger anchor before dispatching", async () => {
it("claims the record event anchor before dispatching", async () => {
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e9",
redFlags: [feverFlag],
});
expect(createMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0][0].data.reason).toContain("e9");
expect(claimMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
recordUserId: "u1",
eventType: "SYSTEM_ALERT",
dedupKey: expect.stringContaining("e9"),
}),
);
});
it("does not dispatch or write an anchor for a managed profile", async () => {
userFindUniqueMock.mockResolvedValueOnce({
managedProfileAt: new Date("2026-08-06T00:00:00.000Z"),
});
await notifyIllnessRedFlag({
userId: "managed-record",
episodeId: "e1",
redFlags: [feverFlag],
});
expect(claimMock).not.toHaveBeenCalled();
expect(dispatchMock).not.toHaveBeenCalled();
});
it("never throws when dispatch fails", async () => {
@@ -11,10 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const findFirstMock = vi.fn();
const createMock = vi.fn();
const dispatchMock = vi.fn();
const userFindUniqueMock = vi.fn();
const claimMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: {
user: {
findUnique: (...a: unknown[]) => userFindUniqueMock(...a),
},
notificationEvent: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
create: (...a: unknown[]) => createMock(...a),
},
@@ -29,6 +34,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
vi.mock("@/lib/notifications/reminder-dedup", () => ({
claimNotificationEvent: (...a: unknown[]) => claimMock(...a),
}));
import { notifySafetyFloor } from "../safety-floor-notify";
import type { SafetyFloorDecision } from "../safety-floors";
@@ -50,9 +59,11 @@ const glucoseLowSymptomatic: SafetyFloorDecision = {
};
beforeEach(() => {
userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null });
findFirstMock.mockReset().mockResolvedValue(null);
createMock.mockReset().mockResolvedValue({});
dispatchMock.mockReset().mockResolvedValue(undefined);
claimMock.mockReset().mockResolvedValue(true);
});
afterEach(() => vi.clearAllMocks());
@@ -94,20 +105,34 @@ describe("notifySafetyFloor", () => {
expect(opts.params).toEqual({ value: 2.7, unit: "mmol/L", threshold: 3 });
});
it("de-dupes by reason when a recent ledger row exists", async () => {
findFirstMock.mockResolvedValueOnce({ id: "prior" });
it("does not dispatch when a concurrent claimant owns the safety event", async () => {
claimMock.mockResolvedValueOnce(false);
await notifySafetyFloor({ userId: "u1", decision: bpHigh });
expect(dispatchMock).not.toHaveBeenCalled();
expect(createMock).not.toHaveBeenCalled();
expect(claimMock).toHaveBeenCalledTimes(1);
});
it("stamps the ledger anchor (keyed by reason) before dispatching", async () => {
it("claims the record event anchor (keyed by reason) before dispatching", async () => {
await notifySafetyFloor({ userId: "u9", decision: bpHigh });
expect(createMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0][0].data.reason).toContain(
"bp_hypertensive",
expect(claimMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
recordUserId: "u9",
eventType: "SYSTEM_ALERT",
dedupKey: expect.stringContaining("bp_hypertensive"),
}),
);
expect(createMock.mock.calls[0][0].data.eventType).toBe("SYSTEM_ALERT");
});
it("does not dispatch or write an anchor for a managed profile", async () => {
userFindUniqueMock.mockResolvedValueOnce({
managedProfileAt: new Date("2026-08-06T00:00:00.000Z"),
});
await notifySafetyFloor({ userId: "managed-record", decision: bpHigh });
expect(claimMock).not.toHaveBeenCalled();
expect(dispatchMock).not.toHaveBeenCalled();
});
it("never throws when dispatch fails", async () => {
@@ -6,14 +6,19 @@
* opened the episode's correlation tab. These tests pin the on-write seam:
* a sustained run escalates on INGEST through the SAME illness red-flag push,
* with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector
* uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm.
* uses, and the 24h record-event dedupe stops a 4th reading re-firing the alarm.
*
* Calendar-day bucketing runs against TZ=UTC (the gate's contract).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */
const ledger: Array<{ userId: string; reason: string }> = [];
/** In-memory event claims so the 24h dedupe behaves across calls. */
const ledger: Array<{
recordUserId: string;
eventType: string;
dedupKey: string;
createdAt: Date;
}> = [];
const measurementFindMany = vi.fn();
const dispatchMock = vi.fn();
@@ -22,17 +27,8 @@ vi.mock("@/lib/db", () => ({
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
pushAttempt: {
findFirst: ({ where }: { where: { userId: string; reason: string } }) => {
const hit = ledger.find(
(r) => r.userId === where.userId && r.reason === where.reason,
);
return Promise.resolve(hit ? { id: "prior" } : null);
},
create: ({ data }: { data: { userId: string; reason: string } }) => {
ledger.push({ userId: data.userId, reason: data.reason });
return Promise.resolve({});
},
user: {
findUnique: vi.fn().mockResolvedValue({ managedProfileAt: null }),
},
},
}));
@@ -49,6 +45,29 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
vi.mock("@/lib/notifications/reminder-dedup", () => ({
claimNotificationEvent: (
_prisma: unknown,
input: {
recordUserId: string;
eventType: string;
dedupKey: string;
since: Date;
},
) => {
const existing = ledger.find(
(event) =>
event.recordUserId === input.recordUserId &&
event.eventType === input.eventType &&
event.dedupKey === input.dedupKey &&
event.createdAt >= input.since,
);
if (existing) return Promise.resolve(false);
ledger.push({ ...input, createdAt: new Date() });
return Promise.resolve(true);
},
}));
import { runSafetyFloorCheck } from "../safety-floor-check";
/** A measured-at on a given UTC calendar day, mid-morning. */
+21 -23
View File
@@ -15,19 +15,20 @@
* the strongest tier those channels support.
*
* Owner-scoped (the authenticated episode owner) and module-gated by the
* caller (`requireIllnessEnabled`). De-duplicated through the `pushAttempt`
* ledger: at most one escalation per episode per rolling window, so a
* caller (`requireIllnessEnabled`). De-duplicated through a record event:
* at most one escalation per episode per rolling window, so a
* correlation surface that is re-read repeatedly never re-fires the alarm.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup";
import type { IllnessRedFlag } from "@/lib/illness/correlation";
/** One escalation per episode per this window. */
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
/** Stable event key prefix so the dedupe lookup is exact-matchable. */
const LEDGER_REASON_PREFIX = "illness_red_flag:";
/**
@@ -44,16 +45,26 @@ export async function notifyIllnessRedFlag(input: {
if (redFlags.length === 0) return;
try {
// Dedupe: skip when an escalation for THIS episode already landed in the
// ledger within the window. The episode id rides the `reason` column so
// the lookup is exact without a schema change.
// Plan 11 adds approved guardian fanout for this safety signal. Until
// then, managed profiles must not produce a direct delivery or anchor.
const user = await prisma.user.findUnique({
where: { id: userId },
select: { managedProfileAt: true },
});
if (user?.managedProfileAt) return;
// Claim this episode's record event before provider egress. The claim
// serializes concurrent readers without holding a transaction open for
// delivery itself.
const reason = `${LEDGER_REASON_PREFIX}${episodeId}`;
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
const prior = await prisma.pushAttempt.findFirst({
where: { userId, reason, createdAt: { gte: since } },
select: { id: true },
const claimed = await claimNotificationEvent(prisma, {
recordUserId: userId,
eventType: "SYSTEM_ALERT",
dedupKey: reason,
since,
});
if (prior) return;
if (!claimed) return;
// Prefer the most clinically actionable reason for the body. Both copy
// keys already exist in every locale bundle (i18n integrity guarantee).
@@ -62,19 +73,6 @@ export async function notifyIllnessRedFlag(input: {
? "illness.correlation.redFlagFever"
: "illness.correlation.redFlagSpo2";
// Stamp the ledger BEFORE dispatching so a slow/duplicate concurrent read
// can't double-fire. The senders write their own per-channel rows too;
// this synthetic row is purely the dedupe anchor.
await prisma.pushAttempt.create({
data: {
userId,
channel: "WEB_PUSH",
eventType: "SYSTEM_ALERT",
result: "skipped",
reason,
},
});
await dispatchLocalisedNotification({
userId,
titleKey: "illness.correlation.redFlagTitle",
+17 -19
View File
@@ -13,13 +13,14 @@
* - The body always echoes the (already re-confirmed) reading value.
*
* Dedup: at most one push per (user, reason) per rolling 24h window, anchored
* on a synthetic `pushAttempt` row keyed by reason — so a user re-checking a
* on a record event keyed by reason — so a user re-checking a
* stubbornly-high reading repeatedly doesn't get spammed. Owner-scoped, never
* throws (a notification failure must not break the measurement write).
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup";
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import {
GLUCOSE_HYPO,
@@ -31,7 +32,7 @@ import {
/** One escalation per (user, reason) per this window. */
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
/** Stable event key prefix so the dedupe lookup is exact-matchable. */
const LEDGER_REASON_PREFIX = "safety_floor:";
/**
@@ -113,26 +114,23 @@ export async function notifySafetyFloor(input: {
if (!decision) return;
try {
// Plan 11 adds approved guardian fanout for this safety signal. Until
// then, managed profiles must not produce a direct delivery or anchor.
const user = await prisma.user.findUnique({
where: { id: userId },
select: { managedProfileAt: true },
});
if (user?.managedProfileAt) return;
const reason = `${LEDGER_REASON_PREFIX}${decision.reason}`;
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
const prior = await prisma.pushAttempt.findFirst({
where: { userId, reason, createdAt: { gte: since } },
select: { id: true },
});
if (prior) return;
// Stamp the ledger BEFORE dispatching so a concurrent confirm can't
// double-fire. The senders write their own per-channel rows; this
// synthetic row is purely the dedupe anchor.
await prisma.pushAttempt.create({
data: {
userId,
channel: "WEB_PUSH",
eventType: "SYSTEM_ALERT",
result: "skipped",
reason,
},
const claimed = await claimNotificationEvent(prisma, {
recordUserId: userId,
eventType: "SYSTEM_ALERT",
dedupKey: reason,
since,
});
if (!claimed) return;
const { titleKey, messageKey } = copyKeysFor(decision);
await dispatchLocalisedNotification({
@@ -103,9 +103,9 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/reminder-dedup", () => ({
hasReminderDedupAnchor: vi.fn().mockResolvedValue(false),
claimNotificationEvent: vi.fn().mockResolvedValue(true),
medicationReminderDedupKey: vi.fn(() => "dedup-key"),
writeReminderDedupAnchor: vi.fn().mockResolvedValue(undefined),
REMINDER_DEDUP_LOOKBACK_MS: 48 * 60 * 60 * 1000,
}));
import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check";
@@ -9,7 +9,7 @@
* only when a Telegram send succeeds. A user on email / APNs / ntfy
* therefore never had a ledger row, so the 15-minute tick re-fired the
* same overdue notice for the rest of the day — roughly fifty mails per
* missed dose. The anchor now lives in `push_attempts` and is written
* missed dose. The anchor now lives in `notification_events` and is written
* for every dispatched slot regardless of channel outcome.
*
* 2. **Coarser must not mean quieter.** The dedup is per phase and per
@@ -24,18 +24,18 @@
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
interface FakePushAttempt {
userId: string;
channel: string;
interface FakeNotificationEvent {
recordUserId: string;
eventType: string;
result: string;
reason: string | null;
dedupKey: string;
createdAt: Date;
}
const pushAttempts: FakePushAttempt[] = [];
const notificationEvents: FakeNotificationEvent[] = [];
const prismaMock = {
$queryRaw: vi.fn().mockResolvedValue([{ locked: 1 }]),
$transaction: vi.fn(),
telegramScheduledDeletion: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
@@ -56,11 +56,11 @@ const prismaMock = {
telegramReminderMessage: {
findUnique: vi.fn().mockResolvedValue(null),
},
pushAttempt: {
notificationEvent: {
create: vi.fn(
async ({ data }: { data: Omit<FakePushAttempt, "createdAt"> }) => {
pushAttempts.push({ ...data, createdAt: new Date() });
return { id: `pa-${pushAttempts.length}` };
async ({ data }: { data: Omit<FakeNotificationEvent, "createdAt"> }) => {
notificationEvents.push({ ...data, createdAt: new Date() });
return { id: `ne-${notificationEvents.length}` };
},
),
findFirst: vi.fn(
@@ -68,17 +68,17 @@ const prismaMock = {
where,
}: {
where: {
userId: string;
recordUserId: string;
eventType: string;
reason: string;
dedupKey: string;
createdAt?: { gte: Date };
};
}) =>
pushAttempts.find(
notificationEvents.find(
(row) =>
row.userId === where.userId &&
row.recordUserId === where.recordUserId &&
row.eventType === where.eventType &&
row.reason === where.reason &&
row.dedupKey === where.dedupKey &&
(where.createdAt?.gte === undefined ||
row.createdAt >= where.createdAt.gte),
) ?? null,
@@ -189,7 +189,8 @@ async function tickAt(iso: string): Promise<void> {
beforeEach(() => {
vi.clearAllMocks();
pushAttempts.length = 0;
notificationEvents.length = 0;
prismaMock.$transaction.mockImplementation(async (run) => run(prismaMock));
vi.useFakeTimers();
prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]);
prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 });
@@ -331,8 +332,8 @@ describe("issue #664 — dedup follows the scheduled occurrence across midnight"
await tickAt("2026-07-28T22:30:00.000Z");
expect(dispatchNotification).toHaveBeenCalledTimes(1);
expect(pushAttempts).toHaveLength(1);
expect(pushAttempts[0]?.reason).toBe(
expect(notificationEvents).toHaveLength(1);
expect(notificationEvents[0]?.dedupKey).toBe(
"med:med-1:sched-1:23:45:YELLOW:2026-07-28",
);
});
@@ -31,9 +31,9 @@ import {
getPhaseKeyboard,
} from "@/lib/jobs/reminder-phases";
import {
hasReminderDedupAnchor,
claimNotificationEvent,
medicationReminderDedupKey,
writeReminderDedupAnchor,
REMINDER_DEDUP_LOOKBACK_MS,
} from "@/lib/notifications/reminder-dedup";
import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day";
import { getWorkerPrisma, parseTimeToMinutes } from "./shared";
@@ -544,13 +544,9 @@ export async function handleReminderCheck(
continue;
}
// Already notified for this slot, phase and local day? The
// anchor lives in `push_attempts` and is stamped for every
// dispatched slot regardless of which channels delivered. It
// used to be the Telegram message ledger, which is written only
// when a Telegram send succeeds — so a user on email / APNs /
// ntfy had no row and the tick re-sent the same overdue notice
// every 15 minutes for the rest of the day.
// Claim this slot/phase/local-day record event before mutable RED
// handling or provider egress. The short locked transaction keeps
// concurrent reminder ticks from both minting and delivering it.
const dedupReason = medicationReminderDedupKey({
medicationId: med.id,
scheduleId: schedule.id,
@@ -561,15 +557,14 @@ export async function handleReminderCheck(
}),
});
if (
await hasReminderDedupAnchor(prisma, {
userId: med.user.id,
if (med.notificationsEnabled) {
const claimed = await claimNotificationEvent(prisma, {
recordUserId: med.user.id,
eventType: "MEDICATION_REMINDER",
reason: dedupReason,
now,
})
) {
continue;
dedupKey: dedupReason,
since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS),
});
if (!claimed) continue;
}
const doseInfo = schedule.dose ?? med.dose;
@@ -651,15 +646,6 @@ export async function handleReminderCheck(
`${currentPhase}:${med.name}:${slotTime}`,
);
// Stamp the dedup anchor BEFORE dispatching: a crash between
// the two costs this phase's notice, a repeat costs the user
// a notification every 15 minutes until the day rolls.
await writeReminderDedupAnchor(prisma, {
userId: med.user.id,
eventType: "MEDICATION_REMINDER",
reason: dedupReason,
});
try {
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
@@ -137,7 +137,8 @@ describe("dispatchNotification — client-managed APNs suppression", () => {
},
);
expect(recordPushAttemptMock).toHaveBeenCalledWith({
userId: "u-1",
recordUserId: "u-1",
recipientUserId: "u-1",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "skipped",
@@ -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,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/db";
const findFirstMock = vi.fn();
const createMock = vi.fn();
const queryRawMock = vi.fn();
const transactionMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...args: unknown[]) => queryRawMock(...args),
$transaction: (...args: unknown[]) => transactionMock(...args),
notificationEvent: {
findFirst: (...args: unknown[]) => findFirstMock(...args),
create: (...args: unknown[]) => createMock(...args),
},
},
}));
import { claimNotificationEvent } from "../reminder-dedup";
beforeEach(() => {
findFirstMock.mockReset().mockResolvedValue(null);
createMock.mockReset().mockResolvedValue({ id: "event" });
queryRawMock.mockReset().mockResolvedValue([{ locked: 1 }]);
transactionMock.mockReset().mockImplementation(async (run) => run(prisma));
});
describe("reminder dedup anchors", () => {
it("claims one record-scoped event under a short transaction lock", async () => {
const now = new Date("2026-08-06T08:00:00.000Z");
await expect(
claimNotificationEvent(prisma as never, {
recordUserId: "managed-record",
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: now,
}),
).resolves.toBe(true);
expect(queryRawMock).toHaveBeenCalledTimes(1);
expect(findFirstMock).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
recordUserId: "managed-record",
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
}),
}),
);
expect(createMock).toHaveBeenCalledWith({
data: {
recordUserId: "managed-record",
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
},
});
});
it("does not claim when the locked window already contains the event", async () => {
findFirstMock.mockResolvedValueOnce({ id: "prior" });
await expect(
claimNotificationEvent(prisma as never, {
recordUserId: "managed-record",
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: new Date("2026-08-06T08:00:00.000Z"),
}),
).resolves.toBe(false);
expect(createMock).not.toHaveBeenCalled();
});
});
@@ -45,6 +45,7 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
@@ -38,6 +38,7 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
@@ -0,0 +1,76 @@
import { prisma } from "@/lib/db";
import {
activeGuardianWhere,
withManagedProfileLock,
} from "@/lib/managed-profiles/lifecycle";
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 an active Guardian explicitly.
*
* Cross-principal authorization linearizes at the locked grant read below,
* immediately before the dispatcher selects a channel. A revocation that
* commits before that read prevents delivery; one that commits afterwards
* affects later dispatches but cannot recall provider work already in flight.
* The provider call deliberately runs outside the short database transaction.
*/
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;
if (record.managedProfileAt == null) {
return recipientUserId === recordUserId
? { recordUserId, recipientUserId, managed: false }
: null;
}
if (recipientUserId === recordUserId) return null;
return prisma.$transaction(async (tx) =>
withManagedProfileLock(tx, recordUserId, async (lockedRecord) => {
if (lockedRecord?.managedProfileAt == null) return null;
const recipient = await tx.user.findUnique({
where: { id: recipientUserId },
select: { managedProfileAt: true },
});
if (!recipient || recipient.managedProfileAt != null) return null;
const guardian = await tx.accountGrant.findFirst({
where: {
grantorId: recordUserId,
granteeId: recipientUserId,
...activeGuardianWhere(new Date()),
},
select: { id: true },
});
if (!guardian) return null;
return { recordUserId, recipientUserId, managed: true };
}),
);
}
+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}`);
+56 -55
View File
@@ -8,9 +8,9 @@
* ntfy had no row, so the tick re-sent the same overdue notice on every
* pass for the rest of the local day.
*
* The anchor now lives in `push_attempts`, the ledger the daily briefing
* and the illness escalation already use for the same purpose. Two
* properties matter and both are deliberate:
* The anchor now lives in `notification_events`, a record-scoped event ledger
* rather than a channel delivery record. Three properties matter and all are
* deliberate:
*
* - **Written per dispatched slot, not per successful send.** Anchoring
* on a delivered push would re-open the flood the moment a channel
@@ -18,27 +18,20 @@
* - **Written before the dispatch.** A crash mid-dispatch burns the slot
* for that phase rather than risking a repeat; the next phase still
* escalates, and the next local day starts clean.
* - **Claimed atomically.** A transaction-scoped advisory lock serializes
* same-key workers through the window check and append, while provider
* egress remains outside that short transaction.
*
* The rows are bookkeeping, not delivery attempts, so they carry the
* `DEDUP` channel sentinel and the operator-facing notification surfaces
* filter them out.
*/
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Channel sentinel for a bookkeeping row. Never a real delivery target —
* `/api/admin/notifications/{health,diagnostic}` exclude it so the
* per-channel health picture stays a picture of actual sends.
*/
export const REMINDER_DEDUP_CHANNEL = "DEDUP";
/**
* How far back the anchor lookup reads. The key already carries the local
* date, so anything older than this cannot match; the bound exists to keep
* the read on the `(user_id, created_at DESC)` index. Two days covers every
* timezone offset plus a DST shift.
* the read on the record/event/key index. Two days covers every timezone
* offset plus a DST shift.
*/
const ANCHOR_LOOKBACK_MS = 48 * 60 * 60 * 1000;
export const REMINDER_DEDUP_LOOKBACK_MS = 48 * 60 * 60 * 1000;
/**
* Dedup key for one medication dose slot in one phase on one local day.
@@ -69,51 +62,59 @@ export function medicationReminderDedupKey(input: {
}
/**
* Has this exact slot+phase+day already been dispatched? Best-effort: a DB
* error biases toward "not yet", so a transient blip delays nothing — the
* cost of a rare duplicate is far below the cost of a silently dropped
* dose reminder.
* Atomically claim a record-scoped notification event within one rolling
* window. A `true` result is the sole permission to continue to provider
* egress; `false` means another worker already claimed it or the database
* could not establish the claim. The advisory lock, lookup, and append stay
* inside the short transaction. Callers always perform delivery afterward.
*/
export async function hasReminderDedupAnchor(
export async function claimNotificationEvent(
prisma: PrismaClient,
input: { userId: string; eventType: string; reason: string; now: Date },
input: {
recordUserId: string;
eventType: string;
dedupKey: string;
since: Date;
},
): Promise<boolean> {
try {
const row = await prisma.pushAttempt.findFirst({
where: {
userId: input.userId,
eventType: input.eventType,
reason: input.reason,
createdAt: { gte: new Date(input.now.getTime() - ANCHOR_LOOKBACK_MS) },
},
select: { id: true },
return await prisma.$transaction(async (tx) => {
const lockKey = [
"notification-event",
input.recordUserId,
input.eventType,
input.dedupKey,
].join(":");
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))
`;
const existing = await tx.notificationEvent.findFirst({
where: {
recordUserId: input.recordUserId,
eventType: input.eventType,
dedupKey: input.dedupKey,
createdAt: { gte: input.since },
},
orderBy: { createdAt: "desc" },
select: { id: true },
});
if (existing) return false;
await tx.notificationEvent.create({
data: {
recordUserId: input.recordUserId,
eventType: input.eventType,
dedupKey: input.dedupKey,
},
});
return true;
});
return row !== null;
} catch {
// Fail closed: a worker that cannot durably claim the event must not
// contact a provider. A later tick may retry after the transaction rolls
// back, so an outage delays rather than duplicates a notification.
return false;
}
}
/**
* Stamp the anchor. Never throws — a failed stamp costs a duplicate
* notification at the next tick, while a thrown error would cost the
* dispatch itself.
*/
export async function writeReminderDedupAnchor(
prisma: PrismaClient,
input: { userId: string; eventType: string; reason: string },
): Promise<void> {
try {
await prisma.pushAttempt.create({
data: {
userId: input.userId,
channel: REMINDER_DEDUP_CHANNEL,
eventType: input.eventType,
result: "skipped",
reason: input.reason,
},
});
} catch {
// Best-effort by contract; the next tick re-derives the same slot.
}
}
@@ -31,6 +31,11 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
recordPushAttemptForPayload: (
_payload: unknown,
_recipientUserId: string,
attempt: unknown,
) => recordPushAttemptMock(attempt),
}));
import { sendViaEmail, resetEmailTransporterForTesting } from "../email";
@@ -41,6 +41,7 @@ const hoisted = vi.hoisted(() => ({
webPushGenerateRequestDetailsMock: vi.fn(),
sendTelegramMessageMock: vi.fn(),
deleteTelegramMessageMock: vi.fn(),
addWarningMock: vi.fn(),
}));
vi.mock("@parse/node-apn", () => {
@@ -98,7 +99,7 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
addWarning: hoisted.addWarningMock,
setError: vi.fn(),
}),
}));
@@ -142,6 +143,7 @@ import {
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { prisma } from "@/lib/db";
const TEST_EC_PEM_LINES = [
@@ -218,6 +220,31 @@ async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
describe("recordPushAttemptForPayload — attribution boundary", () => {
it("rejects a partial delivery identity without writing a ledger row", async () => {
recordPushAttemptForPayload({ recordUserId: "record-1" }, "recipient-1", {
channel: "TELEGRAM",
eventType: "MEDICATION_REMINDER",
result: "ok",
});
recordPushAttemptForPayload(
{ recipientUserId: "recipient-1" },
"recipient-1",
{
channel: "TELEGRAM",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
);
await flushMicrotasks();
expect(prisma.pushAttempt.create).not.toHaveBeenCalled();
expect(hoisted.addWarningMock).toHaveBeenCalledWith(
"push_attempt_ledger_write_failed invalid_attribution",
);
});
});
describe("recordPushAttempt — APNS sender", () => {
it("writes one ok row when the dispatch succeeds", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
@@ -2,8 +2,9 @@
* Telegram sender unit tests.
*
* v1.7.0 SB-SCHED-4 / code-correctness H2 — the pre-send delete must be
* scoped to the single dose slot `{ medicationId, scheduleId, date,
* phase, timeOfDay }`, NOT the whole medication. A multi-time-of-day
* scoped to the recipient and single dose slot `{ recipientUserId,
* medicationId, scheduleId, date, phase, timeOfDay }`, NOT the whole
* medication. A multi-time-of-day
* schedule keeps a distinct ledger row per slot; the legacy whole-
* medication wipe deleted the morning row when the evening slot fired,
* which made the worker's dedup `findUnique` miss and re-send the
@@ -14,7 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendTelegramMessageMock = vi.fn();
const deleteMessageMock = vi.fn();
const findUniqueMock = vi.fn();
const deleteMock = vi.fn();
const upsertMock = vi.fn();
const userFindUniqueMock = vi.fn();
@@ -30,7 +30,6 @@ vi.mock("@/lib/db", () => ({
},
telegramReminderMessage: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
delete: (...args: unknown[]) => deleteMock(...args),
upsert: (...args: unknown[]) => upsertMock(...args),
},
},
@@ -42,6 +41,7 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
const scheduleTelegramAutoDeleteMock = vi.fn();
@@ -77,7 +77,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 });
deleteMessageMock.mockResolvedValue(undefined);
findUniqueMock.mockResolvedValue(null);
deleteMock.mockResolvedValue(undefined);
upsertMock.mockResolvedValue(undefined);
userFindUniqueMock.mockResolvedValue({ locale: "en" });
scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined);
@@ -93,7 +92,8 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
expect(findUniqueMock).toHaveBeenCalledTimes(1);
expect(findUniqueMock).toHaveBeenCalledWith({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: "user-1",
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
@@ -104,7 +104,7 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
});
});
it("deletes only the matching slot's ledger row, leaving sibling slots untouched", async () => {
it("replaces only the matching slot's remote message, leaving its tracking row for update", async () => {
// The 20:00 slot has a prior row; the lookup returns it.
findUniqueMock.mockResolvedValue({
chatId: "chat-1",
@@ -115,19 +115,22 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
// The Telegram message for the old 20:00 row is deleted...
expect(deleteMessageMock).toHaveBeenCalledWith("bot-token", "chat-1", 123);
// ...and exactly that single ledger row is removed (scoped delete, not deleteMany).
expect(deleteMock).toHaveBeenCalledTimes(1);
expect(deleteMock).toHaveBeenCalledWith({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
phase: "YELLOW",
timeOfDay: "20:00",
// The tracking row stays in place so the recipient-scoped upsert updates
// it rather than creating a second row when the chat binding changes.
expect(upsertMock).toHaveBeenCalledWith(
expect.objectContaining({
where: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: "user-1",
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
phase: "YELLOW",
timeOfDay: "20:00",
},
},
},
});
}),
);
});
it("does not delete anything when no prior row exists for the slot", async () => {
@@ -136,7 +139,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
await sendViaTelegram(config, reminderPayload());
expect(deleteMessageMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
// It still sends + tracks the new message.
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
expect(upsertMock).toHaveBeenCalledTimes(1);
@@ -151,7 +153,81 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
);
expect(findUniqueMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
});
it("keeps two recipients' identical subject slots independent", async () => {
await sendViaTelegram(
{ botToken: "bot-token", chatId: "guardian-chat-a" },
{
...reminderPayload(),
userId: "managed-record",
recordUserId: "managed-record",
recipientUserId: "guardian-a",
},
);
await sendViaTelegram(
{ botToken: "bot-token", chatId: "guardian-chat-b" },
{
...reminderPayload(),
userId: "managed-record",
recordUserId: "managed-record",
recipientUserId: "guardian-b",
},
);
expect(upsertMock).toHaveBeenCalledTimes(2);
expect(upsertMock.mock.calls.map((call) => call[0].where)).toEqual([
{
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: "guardian-a",
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
phase: "YELLOW",
timeOfDay: "20:00",
},
},
{
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: "guardian-b",
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
phase: "YELLOW",
timeOfDay: "20:00",
},
},
]);
});
it("updates one recipient slot when its Telegram chat changes", async () => {
findUniqueMock.mockResolvedValue({ chatId: "old-chat", messageId: 123 });
await sendViaTelegram(
{ botToken: "bot-token", chatId: "new-chat" },
reminderPayload(),
);
expect(deleteMessageMock).toHaveBeenCalledWith(
"bot-token",
"old-chat",
123,
);
expect(upsertMock).toHaveBeenCalledWith(
expect.objectContaining({
where: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: "user-1",
medicationId: "med-1",
scheduleId: "sched-1",
date: "2025-06-10",
phase: "YELLOW",
timeOfDay: "20:00",
},
},
update: { chatId: "new-chat", messageId: 999 },
}),
);
});
});
@@ -35,6 +35,11 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
recordPushAttemptForPayload: (
_payload: unknown,
_recipientUserId: string,
attempt: unknown,
) => recordPushAttemptMock(attempt),
}));
vi.mock("@/lib/db", () => ({
@@ -27,6 +27,11 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
recordPushAttemptForPayload: (
_payload: unknown,
_recipientUserId: string,
attempt: unknown,
) => recordPushAttemptMock(attempt),
}));
import { sendViaWebhook } from "../webhook";
+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.
@@ -31,22 +32,93 @@ import { getEvent } from "@/lib/logging/context";
* the sender contracts surface without contacting
* the upstream provider.
*/
export interface PushAttemptRecord {
userId: string;
interface PushAttemptRecordBase {
channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" | "WEBHOOK" | "EMAIL";
eventType: string;
result: "ok" | "error" | "skipped";
reason?: string | null;
}
/** Existing self-delivery callers map the record and recipient to one account. */
interface SelfPushAttemptRecord extends PushAttemptRecordBase {
userId: string;
recordUserId?: never;
recipientUserId?: never;
}
/** Managed delivery always supplies both principals without inference. */
interface AttributedPushAttemptRecord extends PushAttemptRecordBase {
userId?: never;
recordUserId: string;
recipientUserId: string;
}
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 {
const hasRecordUserId = payload.recordUserId !== undefined;
const hasRecipientUserId = payload.recipientUserId !== undefined;
if (hasRecordUserId !== hasRecipientUserId) {
getEvent()?.addWarning(
"push_attempt_ledger_write_failed invalid_attribution",
);
return;
}
if (!hasRecordUserId) {
recordPushAttempt({
userId: legacyUserId,
channel: record.channel,
eventType: record.eventType,
result: record.result,
reason: record.reason,
});
return;
}
recordPushAttempt({
recordUserId: payload.recordUserId!,
recipientUserId: payload.recipientUserId!,
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 =
"userId" in record ? record.userId : record.recipientUserId;
if (!recordUserId || !recipientUserId) {
getEvent()?.addWarning(
"push_attempt_ledger_write_failed invalid_attribution",
);
return;
}
// The `void` here is the doubly-explicit form: we want neither the
// caller nor a linter to wait on this promise. The `.catch` traps
// a rejected promise without surfacing it to the caller.
void prisma.pushAttempt
.create({
data: {
userId: record.userId,
userId: recipientUserId,
recordUserId,
recipientUserId,
channel: record.channel,
eventType: record.eventType,
result: record.result,
+21 -28
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> {
@@ -52,7 +52,7 @@ export interface TelegramSendResult extends SendOutcome {
/**
* Delete the existing Telegram reminder message for a single dose slot
* before re-sending it. v1.7.0 SB-SCHED-4 — scoped to the exact slot
* `{ medicationId, scheduleId, date, phase, timeOfDay }` (the same
* `{ recipientUserId, medicationId, scheduleId, date, phase, timeOfDay }` (the same
* composite the tracking upsert keys on), NOT the whole medication. A
* medication with `timesOfDay = ["08:00","20:00"]` has two distinct
* ledger rows per day; the pre-v1.7 whole-medication wipe deleted the
@@ -62,6 +62,7 @@ export interface TelegramSendResult extends SendOutcome {
*/
async function deleteExistingReminders(
botToken: string,
recipientUserId: string,
medicationId: string,
scheduleId: string,
date: string,
@@ -71,7 +72,8 @@ async function deleteExistingReminders(
try {
const existing = await prisma.telegramReminderMessage.findUnique({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId,
medicationId,
scheduleId,
date,
@@ -88,18 +90,6 @@ async function deleteExistingReminders(
} catch {
// Best-effort: message may already be deleted
}
await prisma.telegramReminderMessage.delete({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
medicationId,
scheduleId,
date,
phase,
timeOfDay,
},
},
});
} catch (err) {
getEvent()?.addWarning(`Failed to delete existing reminders: ${err}`);
}
@@ -118,6 +108,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;
@@ -135,6 +126,7 @@ export async function sendViaTelegram(
if (medicationId && scheduleId && phase && date) {
await deleteExistingReminders(
config.botToken,
userId,
medicationId,
scheduleId,
date,
@@ -152,24 +144,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: [
[
@@ -218,7 +211,8 @@ export async function sendViaTelegram(
try {
await prisma.telegramReminderMessage.upsert({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: userId,
medicationId,
scheduleId,
date,
@@ -227,6 +221,7 @@ export async function sendViaTelegram(
},
},
create: {
recipientUserId: userId,
medicationId,
scheduleId,
chatId: config.chatId,
@@ -251,13 +246,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 +259,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) */
+14 -6
View File
@@ -90,10 +90,17 @@ function resolveBotLocale(value: string | null | undefined): Locale {
return "de";
}
async function cleanupReminderTracking(medicationId: string): Promise<void> {
async function cleanupReminderTracking(
recipientUserId: string,
medicationId: string,
): Promise<void> {
try {
await prisma.telegramReminderMessage.deleteMany({
where: { medicationId },
where: {
recipientUserId,
medicationId,
medication: { userId: recipientUserId },
},
});
} catch {
// Best-effort cleanup
@@ -153,6 +160,7 @@ async function resolveReminderSlot(input: {
}): Promise<Date | null> {
const reminder = await prisma.telegramReminderMessage.findFirst({
where: {
recipientUserId: input.userId,
medicationId: input.medicationId,
chatId: input.chatId,
messageId: input.messageId,
@@ -398,7 +406,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
await cleanupReminderTracking(medicationId);
await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("snooze:")) {
// Format: "snooze:{medicationId}:{minutes}"
const parts = data.split(":");
@@ -443,7 +451,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
await cleanupReminderTracking(medicationId);
await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("skip:")) {
const medicationId = data.slice("skip:".length).trim();
if (!medicationId) {
@@ -586,7 +594,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
await cleanupReminderTracking(medicationId);
await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("ack:")) {
const medicationId = data.slice("ack:".length).trim();
if (!medicationId) {
@@ -613,7 +621,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
await cleanupReminderTracking(medicationId);
await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("add:")) {
// Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}"
const withoutPrefix = data.slice("add:".length);
@@ -0,0 +1,526 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity";
import {
claimNotificationEvent,
} from "@/lib/notifications/reminder-dedup";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
let sequence = 0;
async function createUser(label: string, managed = false) {
const suffix = sequence++;
return getPrismaClient().user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
managedProfileAt: managed ? new Date() : null,
},
});
}
async function createGuardianGrant(input: {
recordUserId: string;
recipientUserId: string;
access?: "READ" | "WRITE" | "MANAGE";
acceptedAt?: Date | null;
revokedAt?: Date | null;
expiresAt?: Date | null;
}) {
return getPrismaClient().accountGrant.create({
data: {
grantorId: input.recordUserId,
granteeId: input.recipientUserId,
access: input.access ?? "MANAGE",
acceptedAt:
input.acceptedAt === undefined ? new Date() : input.acceptedAt,
revokedAt: input.revokedAt ?? null,
revokedBy: input.revokedAt ? "GRANTOR" : null,
expiresAt: input.expiresAt ?? null,
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("notification attribution (real Postgres)", () => {
it("persistence retains distinct record and recipient principals", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
recordPushAttempt({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
});
await vi.waitFor(async () => {
const attempt = await getPrismaClient().pushAttempt.findFirstOrThrow({
where: {
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
},
select: {
recordUserId: true,
recipientUserId: true,
channel: true,
eventType: true,
},
});
expect(attempt).toEqual({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
});
});
});
it("persistence leaves explicit self attribution unchanged", async () => {
const self = await createUser("self");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: self.id,
recordUserId: self.id,
recipientUserId: self.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
select: {
userId: true,
recordUserId: true,
recipientUserId: true,
},
});
expect(attempt).toEqual({
userId: self.id,
recordUserId: self.id,
recipientUserId: self.id,
});
});
it("persists a managed reminder anchor as a record event without a delivery attempt", async () => {
const recordUser = await createUser("managed-record", true);
const client = getPrismaClient();
const now = new Date();
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: new Date(now.getTime() - 48 * 60 * 60 * 1000),
}),
).resolves.toBe(true);
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: new Date(now.getTime() - 48 * 60 * 60 * 1000),
}),
).resolves.toBe(false);
const event = await client.notificationEvent.findFirstOrThrow({
where: { recordUserId: recordUser.id, dedupKey: "med:slot" },
select: { id: true },
});
expect(await client.pushAttempt.count()).toBe(0);
await client.user.delete({ where: { id: recordUser.id } });
expect(
await client.notificationEvent.findUnique({ where: { id: event.id } }),
).toBeNull();
});
it("allows exactly one concurrent claim for a notification event", async () => {
const recordUser = await createUser("claim-record", true);
const client = getPrismaClient();
const since = new Date(Date.now() - 60_000);
const input = {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:race",
since,
};
const results = await Promise.all([
claimNotificationEvent(client, input),
claimNotificationEvent(client, input),
]);
expect(results.filter(Boolean)).toHaveLength(1);
expect(
await client.notificationEvent.count({
where: {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:race",
},
}),
).toBe(1);
});
it("uses the rolling window and allows a retry after a failed claim", async () => {
const client = getPrismaClient();
const missingRecordId = `missing-claim-${sequence++}`;
const missingInput = {
recordUserId: missingRecordId,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:retry",
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
};
await expect(claimNotificationEvent(client, missingInput)).resolves.toBe(
false,
);
await client.user.create({
data: {
id: missingRecordId,
username: `claim-retry-${sequence++}`,
email: `claim-retry-${sequence++}@example.test`,
},
});
await expect(claimNotificationEvent(client, missingInput)).resolves.toBe(
true,
);
const recordUser = await createUser("expired-claim", true);
const now = new Date();
await client.notificationEvent.create({
data: {
recordUserId: recordUser.id,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:window",
createdAt: new Date(now.getTime() - 25 * 60 * 60 * 1000),
},
});
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:window",
since: new Date(now.getTime() - 24 * 60 * 60 * 1000),
}),
).resolves.toBe(true);
});
it("keeps Telegram reminder tracking independent for each recipient slot", async () => {
const recordUser = await createUser("telegram-record", true);
const firstRecipient = await createUser("telegram-recipient-a");
const secondRecipient = await createUser("telegram-recipient-b");
const client = getPrismaClient();
const medication = await client.medication.create({
data: {
userId: recordUser.id,
name: "Tracking medication",
dose: "1 tablet",
schedules: {
create: { windowStart: "08:00", windowEnd: "08:00" },
},
},
include: { schedules: true },
});
const scheduleId = medication.schedules[0]!.id;
const slot = {
medicationId: medication.id,
scheduleId,
date: "2026-08-06",
phase: "YELLOW" as const,
timeOfDay: "08:00",
};
await client.telegramReminderMessage.createMany({
data: [
{
recipientUserId: firstRecipient.id,
...slot,
chatId: "first-chat",
messageId: 101,
},
{
recipientUserId: secondRecipient.id,
...slot,
chatId: "second-chat",
messageId: 202,
},
],
});
await client.telegramReminderMessage.upsert({
where: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: firstRecipient.id,
...slot,
},
},
create: {
recipientUserId: firstRecipient.id,
...slot,
chatId: "unused-create-chat",
messageId: 303,
},
update: { chatId: "first-chat-new", messageId: 303 },
});
expect(
await client.telegramReminderMessage.findMany({
where: { medicationId: medication.id },
orderBy: { recipientUserId: "asc" },
select: { recipientUserId: true, chatId: true, messageId: true },
}),
).toEqual([
{
recipientUserId: firstRecipient.id,
chatId: "first-chat-new",
messageId: 303,
},
{
recipientUserId: secondRecipient.id,
chatId: "second-chat",
messageId: 202,
},
]);
await client.user.delete({ where: { id: firstRecipient.id } });
expect(
await client.telegramReminderMessage.count({
where: { medicationId: medication.id },
}),
).toBe(1);
});
it("persistence maps an omitted pair from the legacy recipient", async () => {
const self = await createUser("legacy");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: self.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
select: {
recordUserId: true,
recipientUserId: true,
},
});
expect(attempt).toEqual({
recordUserId: self.id,
recipientUserId: self.id,
});
});
it("persistence rejects a partial or contradictory principal pair atomically", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const otherUser = await createUser("other");
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: recipientUser.id,
recordUserId: recordUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("push attempt attribution requires both principals");
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: otherUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("push attempt recipient must match user_id");
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
});
it("persistence refuses a managed recipient and cascades an attributed recipient deletion", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const managedRecipient = await createUser("managed-recipient", true);
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: managedRecipient.id,
recordUserId: recordUser.id,
recipientUserId: managedRecipient.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("managed profile cannot receive notification delivery");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: recipientUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
});
await getPrismaClient().user.delete({ where: { id: recipientUser.id } });
expect(
await getPrismaClient().pushAttempt.findUnique({
where: { id: attempt.id },
}),
).toBeNull();
});
it("principal resolution requires an active MANAGE grant for managed delivery", 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.toBeNull();
await createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
});
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();
});
it("principal resolution rejects inactive, insufficient, wrong-record, and managed-recipient grants", async () => {
const recordUser = await createUser("record", true);
const otherRecord = await createUser("other-record", true);
const pendingRecipient = await createUser("pending-recipient");
const revokedRecipient = await createUser("revoked-recipient");
const expiredRecipient = await createUser("expired-recipient");
const writeRecipient = await createUser("write-recipient");
const wrongRecordRecipient = await createUser("wrong-record-recipient");
const managedRecipient = await createUser("managed-recipient", true);
await Promise.all([
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: pendingRecipient.id,
acceptedAt: null,
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: revokedRecipient.id,
revokedAt: new Date(),
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: expiredRecipient.id,
expiresAt: new Date(Date.now() - 1_000),
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: writeRecipient.id,
access: "WRITE",
}),
createGuardianGrant({
recordUserId: otherRecord.id,
recipientUserId: wrongRecordRecipient.id,
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: managedRecipient.id,
}),
]);
for (const recipientUserId of [
pendingRecipient.id,
revokedRecipient.id,
expiredRecipient.id,
writeRecipient.id,
wrongRecordRecipient.id,
managedRecipient.id,
]) {
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
}
const ordinaryUser = await createUser("ordinary-self");
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: ordinaryUser.id,
title: "Personal content",
message: "Personal schedule",
}),
).resolves.toEqual({
recordUserId: ordinaryUser.id,
recipientUserId: ordinaryUser.id,
managed: false,
});
});
});