Merge notification delivery identity

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

No files matched your search

@@ -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);