feat(medications): dispatch the cross-device intake sync on a dose mutation

Wire the silent intake-sync push into both intake writers. The single
route fires once for the affected slot after the row is persisted; the
bulk route collects the distinct (medicationId, scheduledFor) slots whose
state actually changed and fires one push per slot, so a 500-row backfill
wakes a device per affected slot rather than per row. Pending echoes,
duplicates, and no-downgrade no-ops change nothing visible and are
excluded.

Both pass the originating device through the X-Device-Id header so the
device that recorded the dose is skipped. The dispatch is best-effort and
fire-and-forget: the canonical rows are already written, so a push miss
never affects the response.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:58:21 +02:00
1 parent a5ec4ef8dd
commit 836fd7405e
3 files changed
+94

No files matched your search

@@ -53,6 +53,13 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
// 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.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -75,6 +82,7 @@ import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -388,6 +396,44 @@ describe("POST /api/medications/intake", () => {
expect(consumeForIntake).not.toHaveBeenCalled();
});
// ── v1.17.1 (#22) — silent cross-device intake sync wiring ──────────
it("dispatches the silent intake sync with the affected slot + origin device", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const slot = new Date("2026-05-18T10:00:00.000Z");
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: slot,
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
skipped: true,
takenAt: null,
scheduledFor: slot,
} as never);
const r = new NextRequest("http://localhost/api/medications/intake", {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-id": "origin-device-token",
},
body: JSON.stringify({ intakeId: "e1", status: "skipped" }),
});
const res = await POST(r);
expect(res.status).toBe(200);
expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1);
expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({
userId: "user-1",
medicationId: "m1",
scheduledFor: "2026-05-18T10:00:00.000Z",
originDeviceToken: "origin-device-token",
});
});
// ── v1.16.10 — inventory consumption seams ──────────────────────────
it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => {
@@ -68,6 +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";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -239,6 +240,14 @@ async function postBulk(request: NextRequest): Promise<Response> {
string,
{ medicationId: string; dayKey: string }
>();
// v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose
// state actually changed in this batch (a row inserted / updated), so the
// silent cross-device sync fires once per affected slot — not once per
// row, and not for pending echoes / duplicates that changed nothing.
const syncSlots = new Map<
string,
{ medicationId: string; scheduledFor: string }
>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -583,6 +592,18 @@ async function postBulk(request: NextRequest): Promise<Response> {
medicationId: entry.medicationId,
dayKey,
});
// v1.17.1 (#22) — collect the slot for the silent cross-device sync
// only when the row's state actually changed (inserted / updated). A
// pending echo, duplicate, or no-downgrade no-op leaves the visible
// dose state untouched, so it must not wake the user's other devices.
const lastStatus = results[results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
}
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
@@ -679,6 +700,19 @@ 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
// persisted, so a sync-push miss never affects the batch response.
if (syncSlots.size > 0) {
void dispatchMedicationIntakeSyncBulk({
userId: user.id,
slots: Array.from(syncSlots.values()),
originDeviceToken: request.headers.get("x-device-id"),
});
}
return apiSuccess({
processed: entries.length,
inserted,
+14
View File
@@ -50,6 +50,7 @@ import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -684,5 +685,18 @@ 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 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({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
originDeviceToken: request.headers.get("x-device-id"),
});
return apiSuccess(updated);
});