From 836fd7405ef5ac129e9ab3b6252ee6fbb6e21d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:58:21 +0200 Subject: [PATCH] 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. --- .../intake/__tests__/route.test.ts | 46 +++++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 34 ++++++++++++++ src/app/api/medications/intake/route.ts | 14 ++++++ 3 files changed, 94 insertions(+) diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index d1c15d0f9..ae320ccb6 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index b923db783..2c05515e1 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { 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 { } } + // 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, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 002c91589..506fb6607 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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); });