mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(notifications): coalesce the intake sync push per user and strip its payload
The MEDICATION_INTAKE_SYNC fan-out used to fire one silent APNs per distinct dose slot, so a bulk intake marking five doses woke every other device five times. Replace the per-slot dispatch with a per-user coalescer: the first mutation in a burst dispatches immediately, every further mutation inside the 2 s window folds into one trailing fan-out. A bulk request now costs exactly one queue call, and a rapid burst of N single-dose mutations costs at most two fan-outs. The window state is in-process; multiple app instances throttle independently, which is accepted because the iOS reconcile is idempotent and the deployment model is a single container. The silent payload shrinks to a pure sync trigger — eventType + syncedAt. The per-slot medicationId / scheduledFor fields are gone: the iOS handler runs a full reconcile regardless, a coalesced burst has no single slot to name, and a push payload travels through Apple's infrastructure, so it now carries no health data by construction. The Live Activity end push likewise sends only the ActivityKit envelope. The trailing flush keeps the origin skip only when every coalesced mutation came from the same device; a mixed-origin burst wakes everyone. Each flush still records one push_attempts ledger row, reaps hard-rejected device tokens, and annotates the wide event with the recipient count and coalesced-mutation count.
This commit is contained in:
1 parent
0da11811e2
commit
b9b38d182c
7 files changed
+512
-283
No files matched your search
@@ -54,10 +54,10 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
}));
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. Mocked so the route
|
||||
// test asserts the wiring (called with the right slot + origin token)
|
||||
// without reaching the APNs senders.
|
||||
// test asserts the wiring (queued with the right origin token) without
|
||||
// reaching the APNs senders or the coalescing timers.
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined),
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
@@ -82,7 +82,7 @@ import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -425,11 +425,9 @@ describe("POST /api/medications/intake", () => {
|
||||
const res = await POST(r);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-05-18T10:00:00.000Z",
|
||||
originDeviceToken: "origin-device-token",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,11 +63,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// (#22) — silent cross-device intake sync. Mocked so the route test can
|
||||
// assert the batch queues exactly ONE fan-out without reaching the APNs
|
||||
// senders or the coalescing timers.
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
@@ -313,6 +321,61 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("queues exactly ONE intake-sync fan-out for a multi-entry batch", async () => {
|
||||
// Two pending rows at the day's two slots; the batch resolves both.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "row-am",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "row-pm",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T17:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mockResolvedValueOnce({ id: "row-am" } as never)
|
||||
.mockResolvedValueOnce({ id: "row-pm" } as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T17:00:30.000Z",
|
||||
takenAt: "2026-06-15T17:02:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { updated: number } };
|
||||
expect(body.data.updated).toBe(2);
|
||||
|
||||
// The whole batch produces ONE queued fan-out, not one per dose; the
|
||||
// web caller carries no X-Device-Id, so no origin is excluded.
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
originDeviceToken: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => {
|
||||
// First slot find empty → create races a P2002 → re-find returns the
|
||||
// racing pending row → update applies the incoming takenAt.
|
||||
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
|
||||
@@ -702,15 +702,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. One push per device
|
||||
// per distinct affected slot (the map de-dupes), excluding the
|
||||
// originating device (`X-Device-Id` = registered `Device.token`).
|
||||
// APNs-only, best-effort, fire-and-forget: the canonical rows are already
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out
|
||||
// for the whole batch regardless of how many slots it touched (the
|
||||
// per-user coalescer folds bursts further), excluding the originating
|
||||
// device (`X-Device-Id` = registered `Device.token`). APNs-only,
|
||||
// best-effort, fire-and-forget: the canonical rows are already
|
||||
// persisted, so a sync-push miss never affects the batch response.
|
||||
if (syncSlots.size > 0) {
|
||||
void dispatchMedicationIntakeSyncBulk({
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
slots: Array.from(syncSlots.values()),
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
|
||||
@@ -522,14 +522,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER
|
||||
// iOS devices so a running Live Activity / Home-Screen widget reconciles
|
||||
// the dose state changed here. APNs-only, best-effort, fire-and-forget:
|
||||
// the dose state changed here. APNs-only, best-effort, coalesced per user:
|
||||
// the canonical row is already persisted, so a sync-push miss never
|
||||
// affects the response. The originating device (the one that POSTed) is
|
||||
// excluded via its `X-Device-Id` (= registered `Device.token`).
|
||||
void dispatchMedicationIntakeSync({
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,15 +5,21 @@
|
||||
* 1. Silent background push to the user's OTHER devices (origin skip).
|
||||
* 2. APNs-only: the dispatch never reaches the dispatcher cascade, so
|
||||
* Telegram / ntfy / Web Push senders are never imported here.
|
||||
* 3. Live Activity end push only when a `liveActivityPushToken` is stored.
|
||||
* 4. Bulk de-dup: one silent push per device per distinct slot.
|
||||
* 5. Explicit APNS-channel opt-out suppresses the fan-out.
|
||||
* 3. Payload hygiene: the silent push is a pure sync trigger — event
|
||||
* type + timestamp, no medication id / name / dose instant.
|
||||
* 4. Coalescing: the first mutation in a burst dispatches immediately,
|
||||
* everything else folds into ONE trailing dispatch per user.
|
||||
* 5. Live Activity end push only when a `liveActivityPushToken` is stored.
|
||||
* 6. Explicit APNS-channel opt-out suppresses the fan-out.
|
||||
* 7. Failure isolation: a broken flush never throws into the caller.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rawSendMock = vi.fn();
|
||||
const loadConfigMock = vi.fn();
|
||||
const recordPushAttemptMock = vi.fn();
|
||||
const annotateMock = vi.fn();
|
||||
const addWarningMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/apns", () => ({
|
||||
loadApnsConfig: () => loadConfigMock(),
|
||||
@@ -25,7 +31,8 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn(), addMeta: vi.fn() }),
|
||||
annotate: (...args: unknown[]) => annotateMock(...args),
|
||||
getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }),
|
||||
}));
|
||||
|
||||
const deviceFindMany = vi.fn();
|
||||
@@ -44,46 +51,61 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
dispatchMedicationIntakeSync,
|
||||
dispatchMedicationIntakeSyncBulk,
|
||||
} from "@/lib/notifications/medication-intake-sync";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
|
||||
const APNS_CONFIG = { bundleId: "test.healthlog.ios" };
|
||||
|
||||
/**
|
||||
* The queue's fan-out is fire-and-forget; drain the microtask queue so
|
||||
* the leading / trailing flush promise chains settle before asserting.
|
||||
* (Fake timers do not fake microtasks, so plain awaits drain them.)
|
||||
*/
|
||||
async function drainFlush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
/** Unique per-test user id so per-user coalescing windows never leak
|
||||
* across tests (the window map is module state by design). */
|
||||
let userSeq = 0;
|
||||
function nextUserId(): string {
|
||||
userSeq += 1;
|
||||
return `u-${userSeq}`;
|
||||
}
|
||||
|
||||
function device(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "d-other",
|
||||
token: "other-token",
|
||||
apnsToken: "bbbb2222",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
loadConfigMock.mockReturnValue(APNS_CONFIG);
|
||||
channelFindUnique.mockResolvedValue({ enabled: true, preferences: [] });
|
||||
rawSendMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
deviceDeleteMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () => {
|
||||
it("sends a silent background push to every device EXCEPT the origin", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d-origin",
|
||||
token: "origin-token",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "production",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
{
|
||||
id: "d-other",
|
||||
token: "other-token",
|
||||
apnsToken: "bbbb2222",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
]);
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
originDeviceToken: "origin-token",
|
||||
});
|
||||
describe("queueMedicationIntakeSync — origin skip + other-devices-only", () => {
|
||||
it("dispatches immediately to every device EXCEPT the origin", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }),
|
||||
device(),
|
||||
]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" });
|
||||
await drainFlush();
|
||||
|
||||
// Exactly one push — to the non-origin device, silent background.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(1);
|
||||
@@ -94,31 +116,25 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", ()
|
||||
expect(call.payload).toMatchObject({
|
||||
aps: { "content-available": 1 },
|
||||
eventType: "MEDICATION_INTAKE_SYNC",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
// No alert/sound/badge keys in the silent payload.
|
||||
expect(typeof call.payload.syncedAt).toBe("string");
|
||||
// Pure sync trigger — no health data, no alert/sound/badge keys.
|
||||
expect(call.payload.medicationId).toBeUndefined();
|
||||
expect(call.payload.scheduledFor).toBeUndefined();
|
||||
expect(call.payload.aps.alert).toBeUndefined();
|
||||
expect(call.payload.aps.sound).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips nothing when the origin token has no matching device (web caller)", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d1",
|
||||
token: "tok-1",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
device({ id: "d1", token: "tok-1", apnsToken: "aaaa1111" }),
|
||||
]);
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
queueMedicationIntakeSync({
|
||||
userId: nextUserId(),
|
||||
originDeviceToken: "no-such-token",
|
||||
});
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(1);
|
||||
expect(rawSendMock.mock.calls[0][0].deviceToken).toBe("aaaa1111");
|
||||
@@ -126,21 +142,12 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", ()
|
||||
|
||||
it("records a skipped attempt when only the origin device exists", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d-origin",
|
||||
token: "origin-token",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }),
|
||||
]);
|
||||
const userId = nextUserId();
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
originDeviceToken: "origin-token",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" });
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).not.toHaveBeenCalled();
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
@@ -154,23 +161,142 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchMedicationIntakeSync — Live Activity push", () => {
|
||||
describe("queueMedicationIntakeSync — coalescing", () => {
|
||||
it("folds a rapid burst into ONE leading + ONE trailing fan-out", async () => {
|
||||
deviceFindMany.mockResolvedValue([device()]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: null });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: null });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: null });
|
||||
await drainFlush();
|
||||
|
||||
// Leading fan-out only — the burst is still inside the window.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await drainFlush();
|
||||
|
||||
// Trailing fan-out covering the two coalesced mutations.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(2);
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledTimes(2);
|
||||
expect(annotateMock).toHaveBeenLastCalledWith({
|
||||
meta: {
|
||||
medication_intake_sync_devices: 1,
|
||||
medication_intake_sync_coalesced: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("a single queue call (bulk route) produces exactly ONE fan-out", async () => {
|
||||
deviceFindMany.mockResolvedValue([device()]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: null });
|
||||
await drainFlush();
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(1);
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
medication_intake_sync_devices: 1,
|
||||
medication_intake_sync_coalesced: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not coalesce across users", async () => {
|
||||
deviceFindMany.mockResolvedValue([device()]);
|
||||
|
||||
queueMedicationIntakeSync({
|
||||
userId: nextUserId(),
|
||||
originDeviceToken: null,
|
||||
});
|
||||
queueMedicationIntakeSync({
|
||||
userId: nextUserId(),
|
||||
originDeviceToken: null,
|
||||
});
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("trailing flush keeps the origin skip when the burst has ONE origin", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }),
|
||||
device(),
|
||||
]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" });
|
||||
await drainFlush();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await drainFlush();
|
||||
|
||||
// Leading + trailing, and neither ever pushed to the origin device.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(2);
|
||||
const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken);
|
||||
expect(targets).toEqual(["bbbb2222", "bbbb2222"]);
|
||||
});
|
||||
|
||||
it("trailing flush wakes EVERY device when the coalesced mutations mix origins", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
device({ id: "d-a", token: "origin-a", apnsToken: "aaaa1111" }),
|
||||
device({ id: "d-b", token: "origin-b", apnsToken: "bbbb2222" }),
|
||||
]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-b" });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" });
|
||||
await drainFlush();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await drainFlush();
|
||||
|
||||
// Leading: excludes origin-a. Trailing covers mutations from BOTH
|
||||
// devices, so neither is skipped — each still has to learn about the
|
||||
// other's change.
|
||||
const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken);
|
||||
expect(targets).toEqual(["bbbb2222", "aaaa1111", "bbbb2222"]);
|
||||
});
|
||||
|
||||
it("trailing flush excludes the origin when ONLY that device queued mutations", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
device({ id: "d-a", token: "origin-a", apnsToken: "aaaa1111" }),
|
||||
device({ id: "d-b", token: "origin-b", apnsToken: "bbbb2222" }),
|
||||
]);
|
||||
const userId = nextUserId();
|
||||
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" });
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: "origin-b" });
|
||||
await drainFlush();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await drainFlush();
|
||||
|
||||
// Leading (origin-a) → d-b. Trailing covers ONLY origin-b's
|
||||
// mutation, so it wakes just d-a: origin-b already holds its own
|
||||
// state and the leading flush already synced d-b.
|
||||
const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken);
|
||||
expect(targets).toEqual(["bbbb2222", "aaaa1111"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueMedicationIntakeSync — Live Activity push", () => {
|
||||
it("sends a liveactivity end push only for devices with a stored token", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
device({
|
||||
id: "d1",
|
||||
token: "tok-1",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "production",
|
||||
liveActivityPushToken: "la-token-1",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
// One silent + one liveactivity push.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(2);
|
||||
@@ -183,39 +309,30 @@ describe("dispatchMedicationIntakeSync — Live Activity push", () => {
|
||||
expect(la.deviceToken).toBe("la-token-1");
|
||||
expect(la.topic).toBe("test.healthlog.ios.push-type.liveactivity");
|
||||
expect(la.payload.aps.event).toBe("end");
|
||||
// ActivityKit envelope only — no health data rides along.
|
||||
expect(la.payload.medicationId).toBeUndefined();
|
||||
expect(la.payload.scheduledFor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends no liveactivity push when no token is stored", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d1",
|
||||
token: "tok-1",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
device({ id: "d1", token: "tok-1", apnsToken: "aaaa1111" }),
|
||||
]);
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(1);
|
||||
expect(rawSendMock.mock.calls[0][0].pushType).toBe("background");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchMedicationIntakeSync — gating", () => {
|
||||
describe("queueMedicationIntakeSync — gating + failure isolation", () => {
|
||||
it("no-ops silently when APNs is not configured", async () => {
|
||||
loadConfigMock.mockReturnValue(null);
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
expect(deviceFindMany).not.toHaveBeenCalled();
|
||||
expect(rawSendMock).not.toHaveBeenCalled();
|
||||
@@ -228,11 +345,8 @@ describe("dispatchMedicationIntakeSync — gating", () => {
|
||||
preferences: [{ enabled: false }],
|
||||
});
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
expect(deviceFindMany).not.toHaveBeenCalled();
|
||||
expect(rawSendMock).not.toHaveBeenCalled();
|
||||
@@ -247,24 +361,15 @@ describe("dispatchMedicationIntakeSync — gating", () => {
|
||||
it("suppresses when the APNS channel row itself is disabled", async () => {
|
||||
channelFindUnique.mockResolvedValue({ enabled: false, preferences: [] });
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
expect(rawSendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reaps devices APNs reports as permanently dead", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d-dead",
|
||||
token: "tok-1",
|
||||
apnsToken: "aaaa1111",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
device({ id: "d-dead", token: "tok-1", apnsToken: "aaaa1111" }),
|
||||
]);
|
||||
rawSendMock.mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -272,11 +377,8 @@ describe("dispatchMedicationIntakeSync — gating", () => {
|
||||
shouldDisable: true,
|
||||
});
|
||||
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: "2026-06-14T07:00:00.000Z",
|
||||
});
|
||||
queueMedicationIntakeSync({ userId: nextUserId() });
|
||||
await drainFlush();
|
||||
|
||||
expect(deviceDeleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["d-dead"] } },
|
||||
@@ -285,42 +387,18 @@ describe("dispatchMedicationIntakeSync — gating", () => {
|
||||
expect.objectContaining({ result: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchMedicationIntakeSyncBulk — slot de-dup", () => {
|
||||
it("fires one silent push per device per DISTINCT slot, not per row", async () => {
|
||||
deviceFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "d-other",
|
||||
token: "other-token",
|
||||
apnsToken: "bbbb2222",
|
||||
apnsEnvironment: "sandbox",
|
||||
liveActivityPushToken: null,
|
||||
},
|
||||
]);
|
||||
it("never throws into the caller when the flush blows up", async () => {
|
||||
channelFindUnique.mockRejectedValue(new Error("db down"));
|
||||
|
||||
await dispatchMedicationIntakeSyncBulk({
|
||||
userId: "u1",
|
||||
originDeviceToken: "origin-token",
|
||||
slots: [
|
||||
{ medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" },
|
||||
// duplicate of the first — must collapse
|
||||
{ medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" },
|
||||
{ medicationId: "m1", scheduledFor: "2026-06-14T19:00:00.000Z" },
|
||||
{ medicationId: "m2", scheduledFor: "2026-06-14T07:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
expect(() =>
|
||||
queueMedicationIntakeSync({ userId: nextUserId() }),
|
||||
).not.toThrow();
|
||||
await drainFlush();
|
||||
|
||||
// 3 distinct slots × 1 recipient device = 3 silent pushes.
|
||||
expect(rawSendMock).toHaveBeenCalledTimes(3);
|
||||
const slots = rawSendMock.mock.calls.map((c) => [
|
||||
c[0].payload.medicationId,
|
||||
c[0].payload.scheduledFor,
|
||||
]);
|
||||
expect(slots).toEqual([
|
||||
["m1", "2026-06-14T07:00:00.000Z"],
|
||||
["m1", "2026-06-14T19:00:00.000Z"],
|
||||
["m2", "2026-06-14T07:00:00.000Z"],
|
||||
]);
|
||||
expect(rawSendMock).not.toHaveBeenCalled();
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("medication_intake_sync_dispatch_failed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,64 @@
|
||||
/**
|
||||
* v1.17.1 (#22) — server-authoritative medication-intake cross-device sync.
|
||||
*
|
||||
* When a dose is logged / skipped / snoozed on ONE device, the user's
|
||||
* OTHER iOS devices need to reconcile: end or update the running Live
|
||||
* Activity, refresh the Home-Screen widget, drop the local reminder. The
|
||||
* server is the single source of truth for the intake state, so it owns
|
||||
* the fan-out. This module dispatches two APNs pushes per affected device:
|
||||
* When intake state changes on ONE device — a dose logged, edited,
|
||||
* undone, imported or purged — the user's OTHER iOS devices need to
|
||||
* reconcile: end or update the running Live Activity, refresh the
|
||||
* Home-Screen widget, drop the local reminder. The server is the single
|
||||
* source of truth for the intake state, so it owns the fan-out. Per
|
||||
* flush it dispatches two APNs pushes per affected device:
|
||||
*
|
||||
* 1. A SILENT background push (`apns-push-type: background`,
|
||||
* `content-available: 1`, no alert) carrying
|
||||
* `{ eventType: "MEDICATION_INTAKE_SYNC", medicationId, scheduledFor }`.
|
||||
* The iOS `NotificationService` consumes it and runs
|
||||
* `BackgroundSyncCoordinator.runMedicationReconcile`.
|
||||
* `{ eventType: "MEDICATION_INTAKE_SYNC", syncedAt }`. The iOS
|
||||
* `NotificationService` consumes it and runs
|
||||
* `BackgroundSyncCoordinator.runMedicationReconcile`, which
|
||||
* re-reads the full intake state from the API. The payload is a
|
||||
* pure sync trigger — it carries NO health data (no medication
|
||||
* ids, names or dose instants); the reconcile fetches everything
|
||||
* it needs over the authenticated channel.
|
||||
*
|
||||
* 2. A Live Activity end/update push (`apns-push-type: liveactivity`,
|
||||
* topic `<bundle>.push-type.liveactivity`) to any device that has a
|
||||
* 2. A Live Activity end push (`apns-push-type: liveactivity`, topic
|
||||
* `<bundle>.push-type.liveactivity`) to any device that has a
|
||||
* stored `liveActivityPushToken`, so the lock-screen Activity ends
|
||||
* immediately rather than waiting for the next background wake.
|
||||
*
|
||||
* COALESCING. Mutations arrive in bursts — a bulk intake marking five
|
||||
* doses, or a user resolving several slots back-to-back. The fan-out is
|
||||
* throttled per user: the first mutation dispatches immediately (the
|
||||
* felt requirement is "the other phone clears within seconds"), and
|
||||
* every further mutation inside the coalescing window folds into a
|
||||
* single trailing dispatch when the window closes. A burst of N
|
||||
* mutations therefore costs at most two fan-outs, and a single bulk
|
||||
* request costs exactly one. The window state is in-process
|
||||
* (per-Node-process `Map`); with multiple app instances each instance
|
||||
* throttles independently, so a burst split across instances can emit
|
||||
* one fan-out per instance. That is accepted: the iOS reconcile is
|
||||
* idempotent and the deployment model is a single container.
|
||||
*
|
||||
* APNs-ONLY by design. This event has no Telegram / ntfy / Web Push
|
||||
* semantics (those channels have no silent-sync transport), so it never
|
||||
* touches the dispatcher cascade — it talks straight to the APNs senders.
|
||||
* touches the dispatcher cascade — the flush talks straight to the APNs
|
||||
* senders via `fanOutIntakeSyncApns`. A future channel with a real
|
||||
* silent-sync transport slots in as a sibling fan-out inside
|
||||
* `flushIntakeSync`.
|
||||
*
|
||||
* Origin skip: the device that POSTed the mutation already knows the new
|
||||
* state and must not be woken. The caller passes the originating device's
|
||||
* registered `token` (the value the iOS client sends as `X-Device-Id`);
|
||||
* the matching `Device` row is excluded from both fan-outs. A web caller —
|
||||
* or a token with no matching row — skips nothing, which is harmless
|
||||
* (the iOS reconcile is idempotent).
|
||||
* state and must not be woken. The caller passes the originating
|
||||
* device's registered `token` (the value the iOS client sends as
|
||||
* `X-Device-Id`); the matching `Device` row is excluded from the
|
||||
* fan-out. A web caller — or a token with no matching row — skips
|
||||
* nothing, which is harmless (the iOS reconcile is idempotent). When a
|
||||
* trailing flush covers mutations from MORE than one origin device, no
|
||||
* device is excluded: each origin still has to learn about the other's
|
||||
* change.
|
||||
*
|
||||
* Best-effort: every failure is swallowed + annotated. A sync-push miss
|
||||
* must never fail the intake mutation that triggered it; the canonical
|
||||
* row is already persisted.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
loadApnsConfig,
|
||||
sendApnsRawPush,
|
||||
@@ -44,11 +68,15 @@ import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
|
||||
|
||||
const EVENT_TYPE = "MEDICATION_INTAKE_SYNC";
|
||||
|
||||
export interface IntakeSyncInput {
|
||||
/**
|
||||
* Trailing-coalesce window. Long enough to fold a rapid-fire burst of
|
||||
* single-slot mutations into one trailing fan-out, short enough that
|
||||
* the second device still reconciles "within seconds".
|
||||
*/
|
||||
const COALESCE_WINDOW_MS = 2_000;
|
||||
|
||||
export interface IntakeSyncRequest {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
/** ISO-8601 scheduled-for instant of the affected dose slot. */
|
||||
scheduledFor: string;
|
||||
/**
|
||||
* The originating device's registered `Device.token` (sent by the iOS
|
||||
* client as the `X-Device-Id` header). The matching device is excluded
|
||||
@@ -57,6 +85,70 @@ export interface IntakeSyncInput {
|
||||
originDeviceToken?: string | null;
|
||||
}
|
||||
|
||||
interface CoalesceWindow {
|
||||
/** Mutations that arrived AFTER the leading dispatch. */
|
||||
queued: number;
|
||||
/** Distinct non-null origin tokens among the queued mutations. */
|
||||
originTokens: Set<string>;
|
||||
/** Whether any queued mutation had no origin token (web caller). */
|
||||
sawNullOrigin: boolean;
|
||||
}
|
||||
|
||||
const openWindows = new Map<string, CoalesceWindow>();
|
||||
|
||||
/**
|
||||
* Queue a cross-device intake-sync fan-out for the user. Synchronous and
|
||||
* non-throwing — the actual dispatch is fire-and-forget, so a push
|
||||
* failure can never fail the intake write that triggered it.
|
||||
*
|
||||
* First call per user opens a coalescing window and dispatches
|
||||
* immediately; further calls inside the window fold into one trailing
|
||||
* dispatch when it closes.
|
||||
*/
|
||||
export function queueMedicationIntakeSync(input: IntakeSyncRequest): void {
|
||||
const existing = openWindows.get(input.userId);
|
||||
if (existing) {
|
||||
existing.queued += 1;
|
||||
if (input.originDeviceToken) {
|
||||
existing.originTokens.add(input.originDeviceToken);
|
||||
} else {
|
||||
existing.sawNullOrigin = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const window: CoalesceWindow = {
|
||||
queued: 0,
|
||||
originTokens: new Set(),
|
||||
sawNullOrigin: false,
|
||||
};
|
||||
openWindows.set(input.userId, window);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
openWindows.delete(input.userId);
|
||||
if (window.queued === 0) return;
|
||||
// Exclude an origin only when every queued mutation came from the
|
||||
// SAME device; a mixed-origin burst wakes everyone.
|
||||
const origin =
|
||||
!window.sawNullOrigin && window.originTokens.size === 1
|
||||
? [...window.originTokens][0]
|
||||
: null;
|
||||
void flushIntakeSync(input.userId, {
|
||||
coalesced: window.queued,
|
||||
originDeviceToken: origin,
|
||||
});
|
||||
}, COALESCE_WINDOW_MS);
|
||||
// Never keep the process alive for a pending trailing flush.
|
||||
timer.unref?.();
|
||||
|
||||
// Leading dispatch — immediate, so the common single-mutation case
|
||||
// clears the other device at APNs latency, not after the window.
|
||||
void flushIntakeSync(input.userId, {
|
||||
coalesced: 0,
|
||||
originDeviceToken: input.originDeviceToken ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has explicitly opted the APNS channel out of
|
||||
* MEDICATION_INTAKE_SYNC. There is no second user-facing toggle — this is
|
||||
@@ -83,15 +175,15 @@ async function apnsSyncEnabled(userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the silent intake-sync push (+ Live Activity push) to the
|
||||
* user's other devices. De-duplicated to ONE silent push per device
|
||||
* regardless of how many intake rows the triggering mutation touched —
|
||||
* the caller passes a single `(medicationId, scheduledFor)` pair per
|
||||
* affected slot, but a bulk call should call this once with the affected
|
||||
* slot, not once per row (see `dispatchIntakeSyncBulk`).
|
||||
* One fan-out: silent sync push (+ Live Activity end) to every APNs
|
||||
* device except the origin. Records ONE `push_attempts` ledger row per
|
||||
* flush and annotates the wide event (best-effort — the trailing flush
|
||||
* runs after the triggering request closed, where the annotation is a
|
||||
* no-op by construction).
|
||||
*/
|
||||
export async function dispatchMedicationIntakeSync(
|
||||
input: IntakeSyncInput,
|
||||
async function flushIntakeSync(
|
||||
userId: string,
|
||||
opts: { coalesced: number; originDeviceToken: string | null },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = loadApnsConfig();
|
||||
@@ -101,9 +193,9 @@ export async function dispatchMedicationIntakeSync(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await apnsSyncEnabled(input.userId))) {
|
||||
if (!(await apnsSyncEnabled(userId))) {
|
||||
recordPushAttempt({
|
||||
userId: input.userId,
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: EVENT_TYPE,
|
||||
result: "skipped",
|
||||
@@ -113,7 +205,7 @@ export async function dispatchMedicationIntakeSync(
|
||||
}
|
||||
|
||||
const devices = await prisma.device.findMany({
|
||||
where: { userId: input.userId, apnsToken: { not: null } },
|
||||
where: { userId, apnsToken: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
token: true,
|
||||
@@ -125,12 +217,19 @@ export async function dispatchMedicationIntakeSync(
|
||||
|
||||
// Exclude the originating device — it already holds the new state.
|
||||
const recipients = devices.filter(
|
||||
(d) => !input.originDeviceToken || d.token !== input.originDeviceToken,
|
||||
(d) => !opts.originDeviceToken || d.token !== opts.originDeviceToken,
|
||||
);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
medication_intake_sync_devices: recipients.length,
|
||||
medication_intake_sync_coalesced: opts.coalesced,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipients.length === 0) {
|
||||
recordPushAttempt({
|
||||
userId: input.userId,
|
||||
userId,
|
||||
channel: "APNS",
|
||||
eventType: EVENT_TYPE,
|
||||
result: "skipped",
|
||||
@@ -139,64 +238,10 @@ export async function dispatchMedicationIntakeSync(
|
||||
return;
|
||||
}
|
||||
|
||||
const silentPayload = {
|
||||
aps: { "content-available": 1 },
|
||||
eventType: EVENT_TYPE,
|
||||
medicationId: input.medicationId,
|
||||
scheduledFor: input.scheduledFor,
|
||||
};
|
||||
|
||||
let anySuccess = false;
|
||||
const deadDeviceIds: string[] = [];
|
||||
let lastFailureReason: string | undefined;
|
||||
|
||||
for (const device of recipients) {
|
||||
if (!device.apnsToken) continue;
|
||||
const environment: "sandbox" | "production" =
|
||||
device.apnsEnvironment === "production" ? "production" : "sandbox";
|
||||
|
||||
// 1) Silent background reconcile push — one per device.
|
||||
const silent = await sendApnsRawPush({
|
||||
deviceToken: device.apnsToken,
|
||||
environment,
|
||||
pushType: "background",
|
||||
payload: silentPayload,
|
||||
priority: 5,
|
||||
});
|
||||
if (silent.ok) {
|
||||
anySuccess = true;
|
||||
} else {
|
||||
lastFailureReason = silent.reason;
|
||||
if (silent.shouldDisable) deadDeviceIds.push(device.id);
|
||||
}
|
||||
|
||||
// 2) Live Activity end/update push — only when a token is stored.
|
||||
if (device.liveActivityPushToken) {
|
||||
await sendLiveActivityEnd({
|
||||
token: device.liveActivityPushToken,
|
||||
environment,
|
||||
bundleId: config.bundleId,
|
||||
medicationId: input.medicationId,
|
||||
scheduledFor: input.scheduledFor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reap devices APNs reported as permanently dead on the silent send.
|
||||
if (deadDeviceIds.length > 0) {
|
||||
await prisma.device
|
||||
.deleteMany({ where: { id: { in: deadDeviceIds } } })
|
||||
.catch(() => {
|
||||
/* best-effort cleanup */
|
||||
});
|
||||
}
|
||||
|
||||
recordPushAttempt({
|
||||
userId: input.userId,
|
||||
channel: "APNS",
|
||||
eventType: EVENT_TYPE,
|
||||
result: anySuccess ? "ok" : "error",
|
||||
reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"),
|
||||
await fanOutIntakeSyncApns({
|
||||
userId,
|
||||
recipients,
|
||||
bundleId: config.bundleId,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -206,49 +251,98 @@ export async function dispatchMedicationIntakeSync(
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncRecipient {
|
||||
id: string;
|
||||
token: string;
|
||||
apnsToken: string | null;
|
||||
apnsEnvironment: string | null;
|
||||
liveActivityPushToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-call entry point: de-duplicates the affected slots so the fan-out
|
||||
* fires ONE silent push per device per distinct `(medicationId,
|
||||
* scheduledFor)` slot the batch touched — not one per intake row. iOS
|
||||
* coalesces multiple background wakes anyway, but de-duping here keeps the
|
||||
* APNs quota proportional to slots, not to row count.
|
||||
* The APNs leg of the fan-out. Kept as its own unit so a future channel
|
||||
* with a silent-sync transport can be added beside it without touching
|
||||
* the coalescing or gating layers.
|
||||
*/
|
||||
export async function dispatchMedicationIntakeSyncBulk(args: {
|
||||
async function fanOutIntakeSyncApns(args: {
|
||||
userId: string;
|
||||
slots: Array<{ medicationId: string; scheduledFor: string }>;
|
||||
originDeviceToken?: string | null;
|
||||
recipients: SyncRecipient[];
|
||||
bundleId: string;
|
||||
}): Promise<void> {
|
||||
const seen = new Set<string>();
|
||||
const distinct: Array<{ medicationId: string; scheduledFor: string }> = [];
|
||||
for (const slot of args.slots) {
|
||||
const key = `${slot.medicationId}|${slot.scheduledFor}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
distinct.push(slot);
|
||||
}
|
||||
for (const slot of distinct) {
|
||||
await dispatchMedicationIntakeSync({
|
||||
userId: args.userId,
|
||||
medicationId: slot.medicationId,
|
||||
scheduledFor: slot.scheduledFor,
|
||||
originDeviceToken: args.originDeviceToken,
|
||||
// Pure sync trigger — deliberately NO medication id / name / dose
|
||||
// instant. The iOS reconcile re-reads the full state itself, and a
|
||||
// push payload travels through Apple's infrastructure, so it carries
|
||||
// no health data by construction.
|
||||
const silentPayload = {
|
||||
aps: { "content-available": 1 },
|
||||
eventType: EVENT_TYPE,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let anySuccess = false;
|
||||
const deadDeviceIds: string[] = [];
|
||||
let lastFailureReason: string | undefined;
|
||||
|
||||
for (const device of args.recipients) {
|
||||
if (!device.apnsToken) continue;
|
||||
const environment: "sandbox" | "production" =
|
||||
device.apnsEnvironment === "production" ? "production" : "sandbox";
|
||||
|
||||
// 1) Silent background reconcile push — one per device.
|
||||
const silent = await sendApnsRawPush({
|
||||
deviceToken: device.apnsToken,
|
||||
environment,
|
||||
pushType: "background",
|
||||
payload: silentPayload,
|
||||
priority: 5,
|
||||
});
|
||||
if (silent.ok) {
|
||||
anySuccess = true;
|
||||
} else {
|
||||
lastFailureReason = silent.reason;
|
||||
if (silent.shouldDisable) deadDeviceIds.push(device.id);
|
||||
}
|
||||
|
||||
// 2) Live Activity end push — only when a token is stored.
|
||||
if (device.liveActivityPushToken) {
|
||||
await sendLiveActivityEnd({
|
||||
token: device.liveActivityPushToken,
|
||||
environment,
|
||||
bundleId: args.bundleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reap devices APNs reported as permanently dead on the silent send.
|
||||
if (deadDeviceIds.length > 0) {
|
||||
await prisma.device
|
||||
.deleteMany({ where: { id: { in: deadDeviceIds } } })
|
||||
.catch(() => {
|
||||
/* best-effort cleanup */
|
||||
});
|
||||
}
|
||||
|
||||
recordPushAttempt({
|
||||
userId: args.userId,
|
||||
channel: "APNS",
|
||||
eventType: EVENT_TYPE,
|
||||
result: anySuccess ? "ok" : "error",
|
||||
reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an ActivityKit `end` event to a running Live Activity. The Activity
|
||||
* push topic is the app bundle id suffixed with `.push-type.liveactivity`;
|
||||
* the body carries the ActivityKit envelope (`event: "end"`, a timestamp,
|
||||
* an empty content-state). Best-effort: a failure is annotated, never
|
||||
* thrown.
|
||||
* an empty content-state) and nothing else — the push token itself
|
||||
* addresses the Activity, so no health data rides along. Best-effort: a
|
||||
* failure is annotated, never thrown.
|
||||
*/
|
||||
async function sendLiveActivityEnd(args: {
|
||||
token: string;
|
||||
environment: "sandbox" | "production";
|
||||
bundleId: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await sendApnsRawPush({
|
||||
@@ -265,8 +359,6 @@ async function sendLiveActivityEnd(args: {
|
||||
// Dismiss the ended Activity from the lock screen promptly.
|
||||
"dismissal-date": Math.floor(Date.now() / 1000),
|
||||
},
|
||||
medicationId: args.medicationId,
|
||||
scheduledFor: args.scheduledFor,
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 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 (`dispatchMedicationIntakeSync`) ends
|
||||
* 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
|
||||
|
||||
Reference in new issue
Block a user