mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+13
-59
@@ -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",
|
||||
|
||||
+16
-54
@@ -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",
|
||||
|
||||
-22
@@ -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": {
|
||||
|
||||
-22
@@ -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": {
|
||||
|
||||
-22
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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": {
|
||||
|
||||
-18
@@ -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<
|
||||
|
||||
Reference in new issue
Block a user