fix(push): stop the invisible push sent for every recorded dose

Each taken or skipped dose sent a `clear` push to every subscription of
the account. The service worker closed the pending reminder and showed
nothing. A subscription made with userVisibleOnly may not receive
pushes that show no notification: Safari revokes it after a few, the
next reminder meets a 410 and the channel is switched off, with nothing
in the app saying why.

The clear push, its three call sites and the badge count only it fed
are removed. A reminder already on screen now stays until it is tapped,
dismissed or replaced by the next reminder for the same dose. The
sender's badge field went too: nothing else ever set it. The parity
snapshots lose the two recorded effects.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:39:07 +01:00
1 parent 70295f45cb
commit 31f296a03e
39 files changed
+79 -1233

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 344,
"total": 111520,
"total": 111507,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -331,7 +331,7 @@
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 255,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2892,
"total": 9220,
"count": 2890,
"total": 9216,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2590,7 +2590,6 @@
"lib/notifications/__tests__/dispatch-localised-cache.test.ts": 1,
"lib/notifications/__tests__/dispatcher.test.ts": 1,
"lib/notifications/__tests__/medication-intake-sync.test.ts": 1,
"lib/notifications/__tests__/web-push-dose-clear.test.ts": 1,
"lib/notifications/delegated-intake.ts": 2,
"lib/notifications/dispatch-localised.ts": 3,
"lib/notifications/dispatcher.ts": 3,
@@ -2604,10 +2603,9 @@
"lib/notifications/senders/push-attempt-record.ts": 1,
"lib/notifications/senders/telegram-keyboards.ts": 1,
"lib/notifications/senders/telegram.ts": 4,
"lib/notifications/senders/web-push.ts": 5,
"lib/notifications/senders/web-push.ts": 3,
"lib/notifications/senders/webhook.ts": 1,
"lib/notifications/types.ts": 21,
"lib/notifications/web-push-clear.ts": 1,
"lib/nutrients/__tests__/catalog.test.ts": 1,
"lib/nutrients/catalog.ts": 8,
"lib/oauth/signed-state.ts": 1,
@@ -45,10 +45,15 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
auditLog: { create: vi.fn() },
pushSubscription: { findMany: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn(),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
@@ -94,15 +99,6 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
// The route dispatches the PWA clear fire-and-forget after a recorded dose;
// both halves are mocked so the test asserts the dispatch, not web-push.
vi.mock("@/lib/notifications/web-push-clear", () => ({
dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/outstanding-doses", () => ({
countOutstandingDosesToday: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -127,8 +123,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
const USER = {
id: "user-1",
@@ -210,30 +205,33 @@ describe("POST intake — transport-derived source (iOS #64)", () => {
expect(createdSource()).toBe("API");
});
it("closes the pending dose-due web reminder after a recorded intake", async () => {
// Before this route dispatched the clear, an iOS offline drain (which
// replays free intakes through exactly this endpoint) left the web
// dose-due reminder and the app badge standing after the dose landed.
it("sends no Web Push of its own for a recorded dose", async () => {
// A push that shows no notification gets the subscription revoked by
// Safari after a few. The reminder already on screen stays until it is
// tapped or replaced by the next one for the same slot.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(countOutstandingDosesToday).mockResolvedValue(3);
vi.mocked(getVapidConfig).mockResolvedValue({
subject: "mailto:ops@example.com",
publicKey: "pub",
privateKey: "priv",
});
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "s1",
userId: USER.id,
endpoint: "https://push.example.com/x",
p256dh: "a",
auth: "b",
userAgent: null,
createdAt: new Date(0),
},
]);
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
// Fire-and-forget: the dispatch runs detached from the response.
await expect
.poll(
() => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length,
{ timeout: 2_000, interval: 25 },
)
.toBe(1);
expect(
vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0],
).toMatchObject({
userId: "user-1",
medicationId: "med-1",
badgeCount: 3,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(prisma.pushSubscription.findMany).not.toHaveBeenCalled();
});
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
-19
View File
@@ -23,8 +23,6 @@ import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
import { unstableExternalIdMeta } from "@/lib/validations/external-id";
import type { InjectionSiteValue } from "@/lib/validations/medication";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
createBatchState,
type BatchState,
@@ -198,23 +196,6 @@ async function settleLandedRows(input: BatchInput, state: BatchState) {
userId: user.id,
originDeviceToken: input.originDeviceToken,
});
// PWA-only counterpart: close the still-pending dose-due
// reminders for every affected slot over Web Push and refresh the app
// badge once with the post-batch outstanding-dose count. Best-effort,
// fire-and-forget — a clear miss never affects the batch response.
const affectedSlots = Array.from(state.syncSlots.values());
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
user.timezone,
);
await dispatchMedicationIntakeWebClearBulk({
userId: user.id,
slots: affectedSlots,
badgeCount,
});
})();
}
}
@@ -23,8 +23,6 @@ import {
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
@@ -203,11 +201,7 @@ async function recordAfterWrite(
}
/** The tails that never affect the response. */
function startTails(
ctx: RecordIntakeContext,
skipped: boolean,
event: WrittenIntake["event"],
): void {
function startTails(ctx: RecordIntakeContext, skipped: boolean): void {
const { user, medicationId: id } = ctx;
// (#22) — silent cross-device intake sync: wake the user's OTHER iOS
// devices so a running Live Activity / widget reconciles this dose.
@@ -217,22 +211,6 @@ function startTails(
originDeviceToken: ctx.originDeviceToken,
});
// PWA counterpart of the sync wake above: a dose resolved here (taken or
// skipped) closes the still-pending dose-due Web Push reminder for the
// slot and refreshes the app badge. The other intake routes have carried
// this since v1.18.4; this route is the replay target of the iOS offline
// queue, so a drained dose must clear the web reminder the same way.
// Best-effort, fire-and-forget — the canonical row is already persisted.
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor.toISOString(),
badgeCount,
});
})();
// "Somebody else marked your dose". This route has no snooze arm,
// so the state is whichever of the two markings the payload carried. The
// helper refuses on self, so a person marking their own dose is unaffected.
@@ -304,7 +282,7 @@ export async function recordIntake(
await settleInventory(ctx, skipped, written);
await recordAfterWrite(ctx, skipped, written.event);
startTails(ctx, skipped, written.event);
startTails(ctx, skipped);
return { kind: "recorded", event: written.event };
}
@@ -19,9 +19,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
refuseDelegatedChange,
refusesUntrackedIntake,
@@ -95,7 +93,7 @@ async function recordAfterUpdate(
data: IntakeStatusUpdate,
updated: UpdatedRow | null,
userTzForHook: string,
): Promise<Date | undefined> {
): Promise<void> {
const { user, existing } = ctx;
const { intakeId, status } = data;
await auditLog("medications.intake.update", {
@@ -135,15 +133,12 @@ async function recordAfterUpdate(
tz: userTzForHook,
});
}
return movedTo;
}
/** The tails that never affect the response. */
function startTails(
ctx: StatusContext,
status: IntakeStatusUpdate["status"],
movedTo: Date | undefined,
userTzForHook: string,
): void {
const { user, existing } = ctx;
// (#22) — silent cross-device intake sync. Wake the user's OTHER
@@ -157,27 +152,6 @@ function startTails(
originDeviceToken: ctx.originDeviceToken,
});
// PWA-only equivalent of the Live Activity end: when a dose is
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
// subscriptions so the still-pending dose-due reminder for this slot is
// closed by the service worker (matched on the stable slot tag) and the app
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
// outstanding, so it neither clears the reminder nor changes the badge.
if (status === "taken" || status === "skipped") {
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
userTzForHook,
);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
badgeCount,
});
})();
}
// "Somebody else marked your dose". The helper refuses on self and
// on a snooze, resolves both names itself, and never throws.
//
@@ -243,8 +217,8 @@ export async function updateIntakeStatus(
updated = await applySkipOrSnooze(ctx, data);
}
const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook);
startTails(ctx, status, movedTo, userTzForHook);
await recordAfterUpdate(ctx, data, updated, userTzForHook);
startTails(ctx, status);
return { kind: "updated", row: updated };
}
@@ -1,298 +0,0 @@
/**
* v1.18.4 — PWA-only dose lifecycle over Web Push.
*
* Verifies the three pieces a self-hoster without an Apple Developer account
* relies on:
* 1. The dose-due reminder push carries a STABLE per-slot tag + badge count.
* 2. The clear-on-taken push reuses the SAME tag (so the SW closes the
* pending reminder — the equivalent of ending a Live Activity) and
* refreshes the badge.
* 3. A Web Push channel still delivers when APNs is absent.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// safeFetch's requirePublicHost path drives undici's OWN fetch (version-locked
// with its dispatcher). Delegate it to the global fetch these tests stub so the
// existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
const { generateRequestDetailsMock, findManyMock, deleteManyMock } = vi.hoisted(
() => ({
generateRequestDetailsMock: vi.fn(),
findManyMock: vi.fn(),
deleteManyMock: vi.fn(),
}),
);
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
setError: vi.fn(),
addMeta: vi.fn(),
}),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
pushSubscription: {
findMany: findManyMock,
deleteMany: deleteManyMock,
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (v: string) => v,
encrypt: (v: string) => v,
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: () =>
Promise.resolve({
subject: "mailto:a@b.c",
publicKey: "pub",
privateKey: "priv",
}),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: () => true,
}));
vi.mock("web-push", () => ({
setVapidDetails: vi.fn(),
// The sender signs + encrypts here and dials through `safeFetch`; the
// payload assertions below read the same second argument as before.
generateRequestDetails: (...args: unknown[]) => {
generateRequestDetailsMock(...args);
return {
endpoint: "https://push.example.com/x",
method: "POST",
headers: {},
body: null,
};
},
}));
import { sendViaWebPush } from "../senders/web-push";
import { dispatchMedicationIntakeWebClear } from "../web-push-clear";
import { medicationDoseTag } from "../dose-tag";
const SUB = {
id: "s1",
endpoint: "https://push.example.com/x",
p256dh: "a",
auth: "b",
};
beforeEach(() => {
generateRequestDetailsMock.mockReset();
findManyMock.mockReset();
deleteManyMock.mockReset();
deleteManyMock.mockResolvedValue({ count: 0 });
});
afterEach(() => vi.clearAllMocks());
describe("medicationDoseTag", () => {
it("is stable across ISO instants that differ only in milliseconds", () => {
const a = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
const b = medicationDoseTag("med-1", "2026-06-18T07:00:00Z");
expect(a).toBe(b);
expect(a).toBe("med:med-1:2026-06-18T07:00:00.000Z");
});
});
describe("web-push reminder push — stable tag + badge", () => {
it("uses metadata.webPushTag as the notification tag and carries the badge", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
const tag = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
const res = await sendViaWebPush("user-1", {
eventType: "MEDICATION_REMINDER",
userId: "user-1",
title: "Time for your dose",
message: "Ramipril 5mg",
metadata: { webPushTag: tag, badgeCount: 2, url: "/medications/med-1" },
});
expect(res.ok).toBe(true);
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.tag).toBe(tag);
expect(body.badge).toBe(2);
expect(body.url).toBe("/medications/med-1");
});
it("discreet mode still wins over a stable tag (no event name leak)", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("user-1", {
eventType: "CYCLE_PERIOD_SOON",
userId: "user-1",
title: "HealthLog reminder",
message: "HealthLog reminder",
discreet: true,
metadata: { webPushTag: "med:should-not-leak" },
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.tag).toBe("REMINDER");
});
it("uses an ordinary landing route without actions for a managed Guardian", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("guardian-1", {
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
title: "Time for your dose",
message: "Ramipril 5mg",
urgent: true,
metadata: { url: "/medications/med-1" },
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.url).toBe("/");
expect(body.actions).toBeUndefined();
});
});
describe("web-push tap target", () => {
async function sentBody(payload: Parameters<typeof sendViaWebPush>[1]) {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("user-1", payload);
return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string);
}
it("carries the dispatcher's resolved tap path as data url", async () => {
const body = await sentBody({
eventType: "MOOD_REMINDER",
userId: "user-1",
title: "Log your mood",
message: "How are you feeling today?",
tap: { path: "/mood?add=1", url: null, label: "Open" },
});
expect(body.url).toBe("/mood?add=1");
});
it("refuses a protocol-relative metadata url when no tap was resolved", async () => {
const body = await sentBody({
eventType: "SYSTEM_ALERT",
userId: "user-1",
title: "t",
message: "m",
metadata: { url: "//evil.example/x" },
});
expect(body.url).toBe("/");
});
});
describe("clear-on-taken push", () => {
it("emits type:clear with the SAME slot tag as the reminder + badge", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
badgeCount: 1,
});
expect(generateRequestDetailsMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.type).toBe("clear");
expect(body.tag).toBe(
medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"),
);
expect(body.badge).toBe(1);
});
it("a count of 0 clears the badge (badge:0 on the wire)", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
badgeCount: 0,
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.badge).toBe(0);
});
it("no subscriptions → no-op, no throw", async () => {
findManyMock.mockResolvedValue([]);
await expect(
dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
}),
).resolves.toBeUndefined();
expect(generateRequestDetailsMock).not.toHaveBeenCalled();
});
it("reaps expired (410) subscriptions", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 410 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
});
expect(deleteManyMock).toHaveBeenCalledWith({
where: { id: { in: ["s1"] } },
});
});
});
@@ -11,8 +11,8 @@
*
* `web-push`'s `sendNotification` dials with its own internal `https.request`,
* which bypasses `safeFetch` entirely and therefore bypasses the pinned
* dispatcher. Both senders now sign and encrypt via `generateRequestDetails`
* — identical VAPID + payload handling — and dial through `safeFetch` with
* dispatcher. The sender signs and encrypts via `generateRequestDetails`
* — identical VAPID + payload handling — and dials through `safeFetch` with
* `requirePublicHost: true`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -76,7 +76,6 @@ vi.mock("web-push", () => ({
}));
import { sendViaWebPush } from "../senders/web-push";
import { dispatchMedicationIntakeWebClear } from "../web-push-clear";
const SUB = {
id: "s1",
@@ -134,15 +133,3 @@ describe("sendViaWebPush egress", () => {
});
});
});
describe("dispatchMedicationIntakeWebClear egress", () => {
it("dials through safeFetch with requirePublicHost too", async () => {
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
});
expect(safeFetchMock).toHaveBeenCalledTimes(1);
expect(optsOfFirstCall()?.requirePublicHost).toBe(true);
});
});
+4 -15
View File
@@ -82,24 +82,14 @@ export async function sendViaWebPush(
};
}
// v1.18.4 — a dispatcher payload may carry a stable per-slot tag in
// `metadata.webPushTag` (e.g. a medication dose slot, see
// `medicationDoseTag`). The matching `type:"clear"` push reuses the SAME
// tag so the SW can replace/close the pending reminder when the dose is
// logged — the PWA-only equivalent of ending a Live Activity. Discreet
// mode still wins so no event name leaks even when a stable tag exists.
// A payload may carry a stable per-slot tag in `metadata.webPushTag`
// (a medication dose slot, see `medicationDoseTag`), so a repeated
// reminder for the same dose replaces the one on screen instead of
// stacking. Discreet mode still wins so no event name leaks.
const stableTag =
typeof payload.metadata?.webPushTag === "string"
? payload.metadata.webPushTag
: undefined;
// v1.18.4 — server-authoritative app-badge count. The dispatcher puts the
// user's current outstanding-dose count in `metadata.badgeCount`; the SW
// calls `navigator.setAppBadge(count)` (feature-detected, no-op elsewhere).
const badgeCount =
typeof payload.metadata?.badgeCount === "number" &&
Number.isFinite(payload.metadata.badgeCount)
? Math.max(0, Math.trunc(payload.metadata.badgeCount))
: undefined;
const pushPayload = JSON.stringify({
title: plainPushText(payload.title, payload.eventType),
@@ -109,7 +99,6 @@ export async function sendViaWebPush(
// but the discreet contract still requires no event name on the wire —
// mirror the APNs GENERIC_EVENT collapse (QA M-sec3).
tag: payload.discreet ? "REMINDER" : (stableTag ?? payload.eventType),
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
// v1.15.20 — opt-in deep link: a dispatcher payload may carry a
// same-origin path in `metadata.url` (e.g. the Coach nudge's
// `/coach`); everything else keeps the legacy "/" click
-157
View File
@@ -1,157 +0,0 @@
/**
* v1.18.4 — Web Push "clear" for a logged medication dose.
*
* A self-hoster on the installed PWA with no Apple Developer account has no
* Live Activity / Home-Screen widget to reconcile when a dose is logged on
* another tab/device. The native path (`queueMedicationIntakeSync`) ends
* the Live Activity over APNs; this module is the PWA-only equivalent: it
* pushes a `{ type: "clear", tag }` payload to the user's Web Push
* subscriptions so the service worker closes the still-pending dose-due
* reminder for that exact slot (same stable tag the reminder used) and
* refreshes the app badge.
*
* Targeted + best-effort: it talks straight to the user's PushSubscription
* rows (not the full dispatcher cascade) because a clear has no Telegram /
* ntfy / email analogue — those channels can't retract a sent message. Every
* failure is swallowed + annotated; a clear miss never fails the intake
* mutation that triggered it. Expired (410/404) subscriptions are reaped, the
* same as the alert sender.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
import { medicationDoseTag } from "@/lib/notifications/dose-tag";
export interface WebClearInput {
userId: string;
medicationId: string;
/** ISO-8601 scheduled-for instant of the affected dose slot. */
scheduledFor: string;
/**
* Server-authoritative count of the user's still-outstanding doses after
* this mutation. The SW reflects it via `navigator.setAppBadge`. Omit to
* leave the badge untouched.
*/
badgeCount?: number;
}
/**
* Send a `type:"clear"` web push that closes the pending reminder for one
* dose slot and updates the app badge. Best-effort, fire-and-forget safe.
*/
export async function dispatchMedicationIntakeWebClear(
input: WebClearInput,
): Promise<void> {
try {
const config = await getVapidConfig();
if (!config) return; // VAPID not configured — nothing to clear.
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: input.userId },
});
if (!subscriptions.length) return;
const webpush = await import("web-push");
webpush.setVapidDetails(
config.subject,
config.publicKey,
config.privateKey,
);
const badgeCount =
typeof input.badgeCount === "number" && Number.isFinite(input.badgeCount)
? Math.max(0, Math.trunc(input.badgeCount))
: undefined;
const clearPayload = JSON.stringify({
type: "clear",
tag: medicationDoseTag(input.medicationId, input.scheduledFor),
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
});
const expiredIds: string[] = [];
for (const sub of subscriptions) {
try {
if (!isPublicUrl(sub.endpoint)) {
expiredIds.push(sub.id);
continue;
}
const p256dh = decrypt(sub.p256dh);
const auth = decrypt(sub.auth);
// Same transport swap as the main sender: sign + encrypt via
// `generateRequestDetails`, then dial through `safeFetch` with the
// pinned dispatcher so a DNS rebind between the literal
// `isPublicUrl` check above and the connect cannot reach a private
// address (issue #217).
const details = webpush.generateRequestDetails(
{ endpoint: sub.endpoint, keys: { p256dh, auth } },
clearPayload,
) as {
endpoint: string;
method: string;
headers: Record<string, string>;
body: Buffer | string | null;
};
const clearRes = await safeFetch(
details.endpoint,
{
method: details.method,
headers: details.headers,
body: (details.body ?? undefined) as BodyInit | undefined,
},
{ requirePublicHost: true },
);
if (clearRes.status < 200 || clearRes.status >= 300) {
throw Object.assign(
new Error(`Web Push clear responded ${clearRes.status}`),
{ statusCode: clearRes.status },
);
}
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 410 || status === 404) expiredIds.push(sub.id);
// Soft errors on a best-effort clear are ignored — the next reminder
// tick or badge refresh self-corrects.
}
}
if (expiredIds.length > 0) {
await prisma.pushSubscription
.deleteMany({ where: { id: { in: expiredIds } } })
.catch(() => {
/* best-effort cleanup */
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`medication_intake_web_clear_failed: ${message}`);
}
}
/**
* Bulk entry point — de-duplicates affected slots so one clear fires per
* distinct `(medicationId, scheduledFor)` slot a batch touched. The badge
* count is the same post-mutation outstanding total for every slot, so it is
* passed through unchanged.
*/
export async function dispatchMedicationIntakeWebClearBulk(args: {
userId: string;
slots: Array<{ medicationId: string; scheduledFor: string }>;
badgeCount?: number;
}): Promise<void> {
const seen = new Set<string>();
for (const slot of args.slots) {
const key = `${slot.medicationId}|${slot.scheduledFor}`;
if (seen.has(key)) continue;
seen.add(key);
await dispatchMedicationIntakeWebClear({
userId: args.userId,
medicationId: slot.medicationId,
scheduledFor: slot.scheduledFor,
badgeCount: args.badgeCount,
});
}
}
@@ -167,52 +167,6 @@
"originDeviceToken": null
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:10>",
"UTC"
]
},
{
"effect": "dispatchMedicationIntakeWebClearBulk",
"args": [
{
"userId": "<id:10>",
"slots": [
{
"medicationId": "<id:11>",
"scheduledFor": "2026-10-15T08:00:00.000Z"
},
{
"medicationId": "<id:11>",
"scheduledFor": "2026-10-13T08:00:00.000Z"
},
{
"medicationId": "<id:11>",
"scheduledFor": "2026-10-15T20:00:00.000Z"
},
{
"medicationId": "<id:11>",
"scheduledFor": "2026-10-16T08:00:00.000Z"
},
{
"medicationId": "<id:12>",
"scheduledFor": "2026-10-15T10:00:00.000Z"
},
{
"medicationId": "<id:9>",
"scheduledFor": "2026-10-15T09:00:00.000Z"
},
{
"medicationId": "<id:9>",
"scheduledFor": "2026-10-14T09:30:00.000Z"
}
],
"badgeCount": 2
}
]
}
],
"stored": {
@@ -253,7 +207,7 @@
"updatedAt": "<updatedAt>"
},
{
"id": "<id:12>",
"id": "<id:11>",
"userId": "<id:10>",
"name": "As-needed pen",
"dose": "10mg",
@@ -291,7 +245,7 @@
"updatedAt": "<updatedAt>"
},
{
"id": "<id:13>",
"id": "<id:12>",
"userId": "<id:10>",
"name": "Record only",
"dose": "10mg",
@@ -326,7 +280,7 @@
"updatedAt": "<updatedAt>"
},
{
"id": "<id:11>",
"id": "<id:13>",
"userId": "<id:10>",
"name": "Twice daily",
"dose": "10mg",
@@ -364,7 +318,7 @@
"schedules": [
{
"id": "<id:14>",
"medicationId": "<id:13>",
"medicationId": "<id:12>",
"windowStart": "07:00",
"windowEnd": "08:00",
"label": null,
@@ -384,7 +338,7 @@
},
{
"id": "<id:15>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"windowStart": "08:00",
"windowEnd": "10:00",
"label": null,
@@ -424,7 +378,7 @@
},
{
"id": "<id:17>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"windowStart": "20:00",
"windowEnd": "22:00",
"label": null,
@@ -450,7 +404,7 @@
{
"id": "<id:2>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-13T08:00:00.000Z",
"takenAt": "2026-10-13T11:00:00.000Z",
"skipped": false,
@@ -518,7 +472,7 @@
{
"id": "<id:4>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-14T20:00:00.000Z",
"takenAt": "2026-10-14T20:05:00.000Z",
"skipped": false,
@@ -539,7 +493,7 @@
{
"id": "<id:1>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"takenAt": "2026-10-15T08:30:00.000Z",
"skipped": false,
@@ -586,7 +540,7 @@
{
"id": "<id:6>",
"userId": "<id:10>",
"medicationId": "<id:12>",
"medicationId": "<id:11>",
"scheduledFor": "2026-10-15T10:00:00.000Z",
"takenAt": null,
"skipped": true,
@@ -607,7 +561,7 @@
{
"id": "<id:3>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-15T20:00:00.000Z",
"takenAt": null,
"skipped": true,
@@ -628,7 +582,7 @@
{
"id": "<id:5>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-16T08:00:00.000Z",
"takenAt": null,
"skipped": false,
@@ -652,7 +606,7 @@
{
"id": "<id:18>",
"userId": "<id:10>",
"medicationId": "<id:11>",
"medicationId": "<id:13>",
"state": "IN_USE",
"containerType": "OTHER",
"unitsTotal": "30",
@@ -163,50 +163,12 @@
"originDeviceToken": null
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:9>",
"UTC"
]
},
{
"effect": "dispatchMedicationIntakeWebClearBulk",
"args": [
{
"userId": "<id:9>",
"slots": [
{
"medicationId": "<id:10>",
"scheduledFor": "2026-10-15T08:00:00.000Z"
},
{
"medicationId": "<id:10>",
"scheduledFor": "2026-10-13T08:00:00.000Z"
},
{
"medicationId": "<id:10>",
"scheduledFor": "2026-10-15T20:00:00.000Z"
},
{
"medicationId": "<id:10>",
"scheduledFor": "2026-10-16T08:00:00.000Z"
},
{
"medicationId": "<id:11>",
"scheduledFor": "2026-10-15T10:00:00.000Z"
}
],
"badgeCount": 2
}
]
}
],
"stored": {
"medications": [
{
"id": "<id:12>",
"id": "<id:10>",
"userId": "<id:9>",
"name": "Apple mirror",
"dose": "10mg",
@@ -279,7 +241,7 @@
"updatedAt": "<updatedAt>"
},
{
"id": "<id:13>",
"id": "<id:12>",
"userId": "<id:9>",
"name": "Record only",
"dose": "10mg",
@@ -314,7 +276,7 @@
"updatedAt": "<updatedAt>"
},
{
"id": "<id:10>",
"id": "<id:13>",
"userId": "<id:9>",
"name": "Twice daily",
"dose": "10mg",
@@ -352,7 +314,7 @@
"schedules": [
{
"id": "<id:14>",
"medicationId": "<id:13>",
"medicationId": "<id:12>",
"windowStart": "07:00",
"windowEnd": "08:00",
"label": null,
@@ -372,7 +334,7 @@
},
{
"id": "<id:15>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"windowStart": "08:00",
"windowEnd": "10:00",
"label": null,
@@ -392,7 +354,7 @@
},
{
"id": "<id:16>",
"medicationId": "<id:12>",
"medicationId": "<id:10>",
"windowStart": "09:00",
"windowEnd": "11:00",
"label": null,
@@ -412,7 +374,7 @@
},
{
"id": "<id:17>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"windowStart": "20:00",
"windowEnd": "22:00",
"label": null,
@@ -438,7 +400,7 @@
{
"id": "<id:2>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-13T08:00:00.000Z",
"takenAt": "2026-10-13T11:00:00.000Z",
"skipped": false,
@@ -464,7 +426,7 @@
{
"id": "<id:19>",
"userId": "<id:9>",
"medicationId": "<id:12>",
"medicationId": "<id:10>",
"scheduledFor": "2026-10-14T09:00:00.000Z",
"takenAt": "2026-10-14T09:05:00.000Z",
"skipped": false,
@@ -485,7 +447,7 @@
{
"id": "<id:8>",
"userId": "<id:9>",
"medicationId": "<id:12>",
"medicationId": "<id:10>",
"scheduledFor": "2026-10-14T09:30:00.000Z",
"takenAt": "2026-10-14T09:30:00.000Z",
"skipped": false,
@@ -506,7 +468,7 @@
{
"id": "<id:4>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-14T20:00:00.000Z",
"takenAt": "2026-10-14T20:05:00.000Z",
"skipped": false,
@@ -527,7 +489,7 @@
{
"id": "<id:1>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"takenAt": "2026-10-15T08:30:00.000Z",
"skipped": false,
@@ -553,7 +515,7 @@
{
"id": "<id:7>",
"userId": "<id:9>",
"medicationId": "<id:12>",
"medicationId": "<id:10>",
"scheduledFor": "2026-10-15T09:00:00.000Z",
"takenAt": "2026-10-15T09:20:00.000Z",
"skipped": false,
@@ -595,7 +557,7 @@
{
"id": "<id:3>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-15T20:00:00.000Z",
"takenAt": null,
"skipped": true,
@@ -616,7 +578,7 @@
{
"id": "<id:5>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"scheduledFor": "2026-10-16T08:00:00.000Z",
"takenAt": null,
"skipped": false,
@@ -640,7 +602,7 @@
{
"id": "<id:18>",
"userId": "<id:9>",
"medicationId": "<id:10>",
"medicationId": "<id:13>",
"state": "IN_USE",
"containerType": "OTHER",
"unitsTotal": "30",
@@ -50,28 +50,6 @@
"originDeviceToken": null
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:3>",
"UTC"
]
},
{
"effect": "dispatchMedicationIntakeWebClearBulk",
"args": [
{
"userId": "<id:3>",
"slots": [
{
"medicationId": "<id:2>",
"scheduledFor": "2026-10-13T09:00:00.000Z"
}
],
"badgeCount": 2
}
]
}
],
"stored": {
@@ -49,28 +49,6 @@
"originDeviceToken": null
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:3>",
"UTC"
]
},
{
"effect": "dispatchMedicationIntakeWebClearBulk",
"args": [
{
"userId": "<id:3>",
"slots": [
{
"medicationId": "<id:2>",
"scheduledFor": "2026-10-12T09:00:00.000Z"
}
],
"badgeCount": 2
}
]
}
],
"stored": {
@@ -52,28 +52,6 @@
"originDeviceToken": null
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:4>",
"UTC"
]
},
{
"effect": "dispatchMedicationIntakeWebClearBulk",
"args": [
{
"userId": "<id:4>",
"slots": [
{
"medicationId": "<id:2>",
"scheduledFor": "2026-10-12T08:00:00.000Z"
}
],
"badgeCount": 2
}
]
}
],
"stored": {
@@ -54,13 +54,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -71,17 +64,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -54,13 +54,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -71,17 +64,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-14T20:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -54,13 +54,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -71,17 +64,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-13T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -48,13 +48,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -65,17 +58,6 @@
"status": "skipped"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-12T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -48,13 +48,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -65,17 +58,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-13T07:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -52,13 +52,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -69,17 +62,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T09:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -53,13 +53,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -70,17 +63,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -53,13 +53,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -70,17 +63,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T11:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -50,13 +50,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -67,17 +60,6 @@
"status": "skipped"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T20:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -59,13 +59,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -76,17 +69,6 @@
"status": "skipped"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -50,13 +50,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -67,17 +60,6 @@
"status": "skipped"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T11:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-14T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-13T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-13T20:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-12T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-11T20:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -50,13 +50,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -67,17 +60,6 @@
"status": "skipped"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-14T20:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -55,13 +55,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -72,17 +65,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-11T08:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -53,13 +53,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -70,17 +63,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T09:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -52,13 +52,6 @@
}
]
},
{
"effect": "countOutstandingDosesToday",
"args": [
"<id:2>",
"UTC"
]
},
{
"effect": "notifyDelegatedIntake",
"args": [
@@ -69,17 +62,6 @@
"status": "taken"
}
]
},
{
"effect": "dispatchMedicationIntakeWebClear",
"args": [
{
"userId": "<id:2>",
"medicationId": "<id:3>",
"scheduledFor": "2026-10-15T09:00:00.000Z",
"badgeCount": 2
}
]
}
],
"stored": {
@@ -46,9 +46,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => {
};
});
// The tails the route starts without awaiting them (the cross-device push,
// the web-push dose clear with its badge count), recorded instead of run
// (see `effectRecorder`).
// The tail the route starts without awaiting it (the cross-device push),
// recorded instead of run (see `effectRecorder`).
vi.mock(
"@/lib/notifications/medication-intake-sync",
async (importOriginal) => {
@@ -65,31 +64,6 @@ vi.mock(
};
},
);
vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/notifications/web-push-clear")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
dispatchMedicationIntakeWebClearBulk: async (...args: unknown[]) => {
effectRecorder.record("dispatchMedicationIntakeWebClearBulk", args);
},
};
});
vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/medications/outstanding-doses")
>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
countOutstandingDosesToday: async (...args: unknown[]) => {
effectRecorder.record("countOutstandingDosesToday", args);
return 2;
},
};
});
const prisma = getPrismaClient();
const NOW = new Date("2026-10-15T12:00:00.000Z");
@@ -42,8 +42,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => {
});
// The tails the route starts without awaiting them (the cross-device push,
// the web-push dose clear with its badge count, the owner's notification of a
// delegated dose), recorded instead of run (see `effectRecorder`).
// the owner's notification of a delegated dose), recorded instead of run (see
// `effectRecorder`).
vi.mock(
"@/lib/notifications/medication-intake-sync",
async (importOriginal) => {
@@ -60,31 +60,6 @@ vi.mock(
};
},
);
vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/notifications/web-push-clear")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
dispatchMedicationIntakeWebClear: async (...args: unknown[]) => {
effectRecorder.record("dispatchMedicationIntakeWebClear", args);
},
};
});
vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/medications/outstanding-doses")
>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
countOutstandingDosesToday: async (...args: unknown[]) => {
effectRecorder.record("countOutstandingDosesToday", args);
return 2;
},
};
});
vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => {
const actual =
await importOriginal<
@@ -48,8 +48,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => {
});
// The tails the route starts without awaiting them (the cross-device push,
// the web-push dose clear with its badge count, the owner's notification of a
// delegated dose), recorded instead of run (see `effectRecorder`).
// the owner's notification of a delegated dose), recorded instead of run (see
// `effectRecorder`).
vi.mock(
"@/lib/notifications/medication-intake-sync",
async (importOriginal) => {
@@ -66,31 +66,6 @@ vi.mock(
};
},
);
vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/notifications/web-push-clear")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
dispatchMedicationIntakeWebClear: async (...args: unknown[]) => {
effectRecorder.record("dispatchMedicationIntakeWebClear", args);
},
};
});
vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/medications/outstanding-doses")
>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
countOutstandingDosesToday: async (...args: unknown[]) => {
effectRecorder.record("countOutstandingDosesToday", args);
return 2;
},
};
});
vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => {
const actual =
await importOriginal<