mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge notification delivery identity
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user