mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge notification delivery identity
This commit is contained in:
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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import type { EventType } from "@/lib/notifications/types";
|
||||
|
||||
interface DispatchLocalisedOptions {
|
||||
userId: string;
|
||||
recordUserId?: string;
|
||||
recipientUserId?: string;
|
||||
titleKey: string;
|
||||
messageKey: string;
|
||||
params?: Record<string, string | number>;
|
||||
@@ -147,7 +149,9 @@ async function resolveRecipientLocale(userId: string): Promise<Locale> {
|
||||
export async function dispatchLocalisedNotification(
|
||||
opts: DispatchLocalisedOptions,
|
||||
): Promise<void> {
|
||||
const locale = await resolveRecipientLocale(opts.userId);
|
||||
const locale = await resolveRecipientLocale(
|
||||
opts.recipientUserId ?? opts.userId,
|
||||
);
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
const title = t(opts.titleKey, opts.params);
|
||||
@@ -173,6 +177,8 @@ export async function dispatchLocalisedNotification(
|
||||
await dispatchNotification({
|
||||
eventType: opts.eventType ?? "SYSTEM_ALERT",
|
||||
userId: opts.userId,
|
||||
recordUserId: opts.recordUserId,
|
||||
recipientUserId: opts.recipientUserId,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
hasClientManagedApnsGate,
|
||||
} from "@/lib/notifications/client-managed-apns";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity";
|
||||
|
||||
/**
|
||||
* Dispatch a notification to all enabled channels for a user.
|
||||
@@ -63,11 +64,21 @@ export async function dispatchNotification(
|
||||
let channelsSucceeded = 0;
|
||||
|
||||
try {
|
||||
const delivery = await resolveNotificationDeliveryIdentity(payload);
|
||||
if (!delivery) {
|
||||
getEvent()?.addWarning("Notification delivery identity was invalid");
|
||||
return { dispatched: false, channelsAttempted, channelsSucceeded };
|
||||
}
|
||||
const deliveryPayload: NotificationPayload = {
|
||||
...payload,
|
||||
recordUserId: delivery.recordUserId,
|
||||
recipientUserId: delivery.recipientUserId,
|
||||
};
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
where: { userId: payload.userId, enabled: true },
|
||||
where: { userId: delivery.recipientUserId, enabled: true },
|
||||
include: {
|
||||
preferences: {
|
||||
where: { eventType: payload.eventType },
|
||||
where: { eventType: deliveryPayload.eventType },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -79,7 +90,7 @@ export async function dispatchNotification(
|
||||
if (!hasTelegramChannel) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
where: { id: delivery.recipientUserId },
|
||||
select: {
|
||||
telegramBotToken: true,
|
||||
telegramChatId: true,
|
||||
@@ -101,12 +112,12 @@ export async function dispatchNotification(
|
||||
const created = await prisma.notificationChannel.upsert({
|
||||
where: {
|
||||
userId_type: {
|
||||
userId: payload.userId,
|
||||
userId: delivery.recipientUserId,
|
||||
type: "TELEGRAM",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: payload.userId,
|
||||
userId: delivery.recipientUserId,
|
||||
type: "TELEGRAM",
|
||||
enabled: true,
|
||||
config: channelConfig,
|
||||
@@ -117,13 +128,16 @@ export async function dispatchNotification(
|
||||
},
|
||||
include: {
|
||||
preferences: {
|
||||
where: { eventType: payload.eventType },
|
||||
where: { eventType: deliveryPayload.eventType },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
channels.push(created);
|
||||
getEvent()?.addMeta("telegram_legacy_migration", payload.userId);
|
||||
getEvent()?.addMeta(
|
||||
"telegram_legacy_migration",
|
||||
delivery.recipientUserId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`Legacy Telegram migration failed: ${err}`);
|
||||
@@ -145,7 +159,7 @@ export async function dispatchNotification(
|
||||
// from /settings/notifications once they've seen the badge a few
|
||||
// times and decided they want push.
|
||||
let defaultEnabled =
|
||||
EVENT_DEFAULT_ENABLED[payload.eventType as EventType] ?? true;
|
||||
EVENT_DEFAULT_ENABLED[deliveryPayload.eventType as EventType] ?? true;
|
||||
|
||||
// v1.7.0 — MOOD_REMINDER single source of truth = the visible card.
|
||||
// The event used to default OFF, layered on top of the per-user
|
||||
@@ -156,9 +170,9 @@ export async function dispatchNotification(
|
||||
// opt-out (a `NotificationPreference` row with `enabled = false`) is
|
||||
// still honoured below via `pref.enabled` — only the no-row default
|
||||
// is derived from the card.
|
||||
if (payload.eventType === "MOOD_REMINDER") {
|
||||
if (deliveryPayload.eventType === "MOOD_REMINDER") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
where: { id: delivery.recipientUserId },
|
||||
select: { moodReminderEnabled: true },
|
||||
});
|
||||
defaultEnabled = user?.moodReminderEnabled === true;
|
||||
@@ -170,13 +184,13 @@ export async function dispatchNotification(
|
||||
let clientManagedApns: boolean | null = null;
|
||||
const resolveClientManagedApns = async (): Promise<boolean> => {
|
||||
if (clientManagedApns !== null) return clientManagedApns;
|
||||
const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType];
|
||||
const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType];
|
||||
if (!gate) {
|
||||
clientManagedApns = false;
|
||||
return false;
|
||||
}
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
where: { id: delivery.recipientUserId },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
clientManagedApns = gate.isClientManaged(row?.notificationPrefs);
|
||||
@@ -209,21 +223,23 @@ export async function dispatchNotification(
|
||||
// about whether anything actually reached the user.
|
||||
if (
|
||||
channel.type === "APNS" &&
|
||||
hasClientManagedApnsGate(payload.eventType) &&
|
||||
!delivery.managed &&
|
||||
hasClientManagedApnsGate(deliveryPayload.eventType) &&
|
||||
(await resolveClientManagedApns())
|
||||
) {
|
||||
const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType];
|
||||
getEvent()?.addMeta(gate.metaKey, gate.tag(payload.metadata));
|
||||
const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType];
|
||||
getEvent()?.addMeta(gate.metaKey, gate.tag(deliveryPayload.metadata));
|
||||
if (gate.detail && gate.detailKey) {
|
||||
getEvent()?.addMeta(
|
||||
gate.detailKey,
|
||||
gate.detail(payload.userId, payload.metadata),
|
||||
gate.detail(deliveryPayload.userId, deliveryPayload.metadata),
|
||||
);
|
||||
}
|
||||
recordPushAttempt({
|
||||
userId: payload.userId,
|
||||
recordUserId: delivery.recordUserId,
|
||||
recipientUserId: delivery.recipientUserId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
eventType: deliveryPayload.eventType,
|
||||
result: "skipped",
|
||||
reason: "client_managed",
|
||||
});
|
||||
@@ -236,7 +252,8 @@ export async function dispatchNotification(
|
||||
const outcome = await sendToChannel(
|
||||
channel.type as ChannelType,
|
||||
channel.config,
|
||||
payload,
|
||||
delivery.recipientUserId,
|
||||
deliveryPayload,
|
||||
);
|
||||
|
||||
if (outcome.ok) {
|
||||
@@ -310,6 +327,7 @@ export async function dispatchNotification(
|
||||
async function sendToChannel(
|
||||
type: ChannelType,
|
||||
encryptedConfig: string,
|
||||
recipientUserId: string,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendOutcome> {
|
||||
let decrypted: string;
|
||||
@@ -393,7 +411,7 @@ async function sendToChannel(
|
||||
return sendViaEmail(config, payload);
|
||||
}
|
||||
case "WEB_PUSH": {
|
||||
return sendViaWebPush(payload.userId, payload);
|
||||
return sendViaWebPush(recipientUserId, payload);
|
||||
}
|
||||
case "APNS": {
|
||||
// APNs config lives on the Device row, not in the channel config.
|
||||
@@ -401,7 +419,7 @@ async function sendToChannel(
|
||||
// we just keep the symmetric path so future per-user overrides
|
||||
// (e.g. a custom APNs topic) can hang off the channel without
|
||||
// changing the dispatcher shape.
|
||||
return sendViaApns(payload.userId, payload);
|
||||
return sendViaApns(recipientUserId, payload);
|
||||
}
|
||||
default:
|
||||
getEvent()?.addWarning(`Unknown notification channel type: ${type}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -29,7 +29,7 @@ import apn from "@parse/node-apn";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
export interface ApnsPayload {
|
||||
@@ -532,11 +532,13 @@ export async function sendViaApns(
|
||||
metadata?: Record<string, unknown>;
|
||||
discreet?: boolean;
|
||||
urgent?: boolean;
|
||||
recordUserId?: string;
|
||||
recipientUserId?: string;
|
||||
},
|
||||
): Promise<SendOutcome> {
|
||||
const config = loadApnsConfig();
|
||||
if (!config) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
@@ -565,7 +567,7 @@ export async function sendViaApns(
|
||||
if (devices.length === 0) {
|
||||
// Same rationale as web-push: a user who hasn't paired an iPhone yet
|
||||
// shouldn't see the channel auto-disabled. Treat as soft "no recipient".
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
@@ -626,6 +628,10 @@ export async function sendViaApns(
|
||||
process.env.APNS_CRITICAL_ENTITLEMENT === "true";
|
||||
|
||||
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
|
||||
const managedDelivery =
|
||||
payload.recordUserId !== undefined &&
|
||||
payload.recipientUserId !== undefined &&
|
||||
payload.recordUserId !== payload.recipientUserId;
|
||||
// Discreet mode (cycle privacy): the lock-screen-visible routing
|
||||
// metadata must not carry the event name. Collapse the category /
|
||||
// threadId / collapseId and the data eventType to a generic value so
|
||||
@@ -677,7 +683,7 @@ export async function sendViaApns(
|
||||
// ignores categories it doesn't know about, so adding the key
|
||||
// here is safe for event-types that don't have an actionable
|
||||
// category registered yet — they fall back to a plain alert.
|
||||
category: routingEvent,
|
||||
category: managedDelivery ? undefined : routingEvent,
|
||||
// Future-proof for an iOS Notification Service Extension. iOS
|
||||
// ignores the flag when no extension is registered, so it's a
|
||||
// no-op on today's binary but unblocks NSE work without a
|
||||
@@ -742,7 +748,7 @@ export async function sendViaApns(
|
||||
}
|
||||
|
||||
if (anySuccess) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
@@ -755,7 +761,7 @@ export async function sendViaApns(
|
||||
// until the user re-registers from a fresh iOS install.
|
||||
if (deadDeviceIds.length === devices.length) {
|
||||
const reason = lastFailureReason ?? "apns_all_devices_unregistered";
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
@@ -771,7 +777,7 @@ export async function sendViaApns(
|
||||
}
|
||||
|
||||
const reason = lastFailureReason ?? "apns_send_failed";
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: payload.eventType,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from "@/lib/notifications/types";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { loadEmailConfig } from "@/lib/notifications/senders/email-config";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
@@ -97,12 +97,13 @@ export async function sendViaEmail(
|
||||
config: EmailChannelConfig,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendOutcome> {
|
||||
const userId = payload.recipientUserId ?? payload.userId;
|
||||
const start = performance.now();
|
||||
|
||||
const transport = getTransporter();
|
||||
if (!transport) {
|
||||
// Operator hasn't configured SMTP — soft skip, no channel burn.
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "EMAIL",
|
||||
eventType: payload.eventType,
|
||||
@@ -113,7 +114,7 @@ export async function sendViaEmail(
|
||||
}
|
||||
|
||||
if (!config.recipient) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "EMAIL",
|
||||
eventType: payload.eventType,
|
||||
@@ -143,7 +144,7 @@ export async function sendViaEmail(
|
||||
method: "sendMail",
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
});
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "EMAIL",
|
||||
eventType: payload.eventType,
|
||||
@@ -158,7 +159,7 @@ export async function sendViaEmail(
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
error: classified.message,
|
||||
});
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "EMAIL",
|
||||
eventType: payload.eventType,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
import { isUrgentPayload } from "@/lib/notifications/types";
|
||||
@@ -23,6 +23,7 @@ export async function sendViaNtfy(
|
||||
config: NtfyChannelConfig,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendOutcome> {
|
||||
const userId = payload.recipientUserId ?? payload.userId;
|
||||
const start = performance.now();
|
||||
try {
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
|
||||
@@ -84,7 +85,7 @@ export async function sendViaNtfy(
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "NTFY",
|
||||
eventType: payload.eventType,
|
||||
@@ -93,7 +94,7 @@ export async function sendViaNtfy(
|
||||
return { ok: true, statusCode: res.status };
|
||||
}
|
||||
const classified = classifyHttpStatus(res.status, "ntfy");
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "NTFY",
|
||||
eventType: payload.eventType,
|
||||
@@ -114,7 +115,7 @@ export async function sendViaNtfy(
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
error: message,
|
||||
});
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "NTFY",
|
||||
eventType: payload.eventType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
|
||||
/**
|
||||
* v1.4.49 — fire-and-forget push-attempt ledger write.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
@@ -36,7 +36,7 @@ export async function sendViaWebPush(
|
||||
const config = await getVapidConfig();
|
||||
if (!config) {
|
||||
getEvent()?.addWarning("Web Push: VAPID keys not configured");
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
@@ -65,7 +65,7 @@ export async function sendViaWebPush(
|
||||
// may simply not have hit "Subscribe" on this browser. Counting
|
||||
// this as a give-up signal would lock the user out of the channel
|
||||
// they just configured. Treat it as a soft "no recipient" instead.
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
@@ -231,7 +231,7 @@ export async function sendViaWebPush(
|
||||
}
|
||||
|
||||
if (anySuccess) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
@@ -243,7 +243,7 @@ export async function sendViaWebPush(
|
||||
// Every sub returned a permanent 410/404 → channel is dead until
|
||||
// the user re-pairs a device. This is a hard reject.
|
||||
if (allPermanentReject && expiredIds.length === subscriptions.length) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
@@ -260,7 +260,7 @@ export async function sendViaWebPush(
|
||||
|
||||
// Soft failure (5xx / 429 / fetch error) on at least one sub.
|
||||
const classified = classifyHttpStatus(lastTransientStatus, "web-push");
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
@@ -279,7 +279,7 @@ export async function sendViaWebPush(
|
||||
? err
|
||||
: new Error("[web-push] sendViaWebPush failed"),
|
||||
);
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: payload.eventType,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function sendViaWebhook(
|
||||
config: WebhookChannelConfig,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendOutcome> {
|
||||
const userId = payload.recipientUserId ?? payload.userId;
|
||||
const start = performance.now();
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -82,7 +83,7 @@ export async function sendViaWebhook(
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
@@ -91,7 +92,7 @@ export async function sendViaWebhook(
|
||||
return { ok: true, statusCode: res.status };
|
||||
}
|
||||
const classified = classifyHttpStatus(res.status, "webhook");
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
@@ -112,7 +113,7 @@ export async function sendViaWebhook(
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
error: message,
|
||||
});
|
||||
recordPushAttempt({
|
||||
recordPushAttemptForPayload(payload, userId, {
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
|
||||
@@ -252,7 +252,15 @@ export interface EmailChannelConfig {
|
||||
|
||||
export interface NotificationPayload {
|
||||
eventType: EventType;
|
||||
/**
|
||||
* Health-record subject retained for established self-delivery callers.
|
||||
* Managed delivery must also declare `recordUserId` and `recipientUserId`.
|
||||
*/
|
||||
userId: string;
|
||||
/** The health record that supplies notification content and calculations. */
|
||||
recordUserId?: string;
|
||||
/** The account whose channel, locale, and preferences receive the delivery. */
|
||||
recipientUserId?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
/** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user