From dca4ca544e0c417849ed149c609d6852c23c9401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:22:06 +0200 Subject: [PATCH] feat(medications): dispatch the intake sync push from every intake mutation route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-device sync push fired only from POST /api/medications/intake and /intake/bulk. The five other routes that mutate intake state — the per-medication dose log, the intake-event edit and delete, the bulk delete, the history import and the purge — persisted the change but never woke the user's other devices, so a dose undone or edited on the web left a stale Live Activity / widget until the next foreground or background wake. Queue the same coalesced fan-out from all five after the canonical write, excluding the originating device via X-Device-Id as on the existing paths. Best-effort as everywhere else: a push miss never affects the mutation response. The import skips the queue when zero rows imported, and the bulk delete returns before the hook on a no-op match. --- .../intake/[eventId]/__tests__/route.test.ts | 16 ++++++++++++++++ .../medications/[id]/intake/[eventId]/route.ts | 18 ++++++++++++++++++ .../[id]/intake/__tests__/route.test.ts | 16 ++++++++++++++++ .../intake/bulk-delete/__tests__/route.test.ts | 14 ++++++++++++++ .../[id]/intake/bulk-delete/route.ts | 10 ++++++++++ .../[id]/intake/import/__tests__/route.test.ts | 12 ++++++++++++ .../medications/[id]/intake/import/route.ts | 11 +++++++++++ .../[id]/intake/purge/__tests__/route.test.ts | 11 +++++++++++ .../api/medications/[id]/intake/purge/route.ts | 9 +++++++++ src/app/api/medications/[id]/intake/route.ts | 9 +++++++++ 10 files changed, 126 insertions(+) diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 45e17af7f..d355bf403 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -53,7 +53,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook fires on edit + undo without reaching the APNs senders +// or the coalescing timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { PUT, DELETE } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; @@ -209,6 +217,14 @@ describe("DELETE /api/medications/[id]/intake/[eventId] — one-shot reconcile", }); const calls = vi.mocked(auditLog).mock.calls.map((c) => c[0]); expect(calls).toContain("medication.oneShot.reconciled"); + + // (#22) — the undo queues exactly one cross-device sync fan-out so + // the user's other iOS devices reconcile the restored dose state. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + originDeviceToken: null, + }); }); it("is idempotent on a non-one-shot medication", async () => { diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index c3c2a857e..c4843c102 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { @@ -379,6 +380,14 @@ export const PUT = apiHandler( // deactivate again. No-op for non-one-shot medications. await reconcileOneShotState(prisma, id, user.id); + // (#22) — silent cross-device intake sync: an edited dose must + // reconcile on the user's other iOS devices too. Coalesced per user, + // best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess(updated); }, ); @@ -457,6 +466,15 @@ export const DELETE = apiHandler( // non-one-shot medications. await reconcileOneShotState(prisma, id, user.id); + // (#22) — silent cross-device intake sync: an undone dose must + // reconcile on the user's other iOS devices (the widget count and a + // pending Live Activity both change). Coalesced per user, + // best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ deleted: true }); }, ); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index b678fe833..f47647000 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -91,7 +91,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook fires without reaching the APNs senders or the +// coalescing timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { GET, POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -363,6 +371,14 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => { where: { id: "med-1", userId: "user-1", oneShot: true }, data: { active: false }, }); + + // (#22) — the logged dose queues exactly one cross-device sync + // fan-out; the web caller carries no X-Device-Id. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + originDeviceToken: null, + }); }); }); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index bcbf33582..381421305 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -82,7 +82,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -165,6 +173,8 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(json.data).toEqual({ deleted: 0 }); expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + // (#22) — a no-op delete queues no cross-device sync fan-out. + expect(queueMedicationIntakeSync).not.toHaveBeenCalled(); }); it("recomputes the rollup once per unique dayKey", async () => { @@ -190,6 +200,10 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); // Three rows, two unique dayKeys → two recompute calls. expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + + // (#22) — the whole bulk delete queues exactly ONE cross-device + // sync fan-out, not one per removed row. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index d6daf54ad..0ea7d91be 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -18,6 +18,7 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -196,6 +197,15 @@ export const POST = apiHandler( invalidateUserMedications(user.id, { evict: true }); + // (#22) — silent cross-device intake sync: removed doses change the + // widget tally and any pending Live Activity on the user's other iOS + // devices. Coalesced per user, best-effort — never affects the + // response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ deleted: count }); }, ); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 780a4aa49..e5e3a00a2 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -47,7 +47,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { consumeForIntake } from "@/lib/medications/inventory/consumption"; @@ -191,6 +199,10 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", ( expect(call?.medicationId).toBe("m1"); expect(call?.userId).toBe("user-1"); expect(call?.eventId).toBe("evt-1"); + + // (#22) — the whole import queues exactly ONE cross-device sync + // fan-out, not one per imported row. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("does not consume for duplicate (already-imported) rows", async () => { diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index be0ab8373..75fa97360 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -12,6 +12,7 @@ import { import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { recomputeMedicationComplianceForDay, dayKeyForScheduledFor, @@ -206,6 +207,16 @@ export const POST = apiHandler( }, }); + // (#22) — silent cross-device intake sync: imported rows change the + // intake state the user's other iOS devices render. Coalesced per + // user, best-effort — never affects the response. + if (imported > 0) { + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + } + return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201); }, ); diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 3123799c9..9a4a76a03 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -45,7 +45,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { DELETE } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -88,6 +96,9 @@ describe("DELETE /api/medications/[id]/intake/purge", () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.data).toEqual({ purged: true, count: 7 }); + + // (#22) — the purge queues exactly ONE cross-device sync fan-out. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("invalidates the user medication caches on success (F-1 C-3)", async () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 1988e879c..d7f342851 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -5,6 +5,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -66,6 +67,14 @@ export const DELETE = apiHandler( // PUT medication, bulk-delete) that all fire this bundle. invalidateUserMedications(user.id, { evict: true }); + // (#22) — silent cross-device intake sync: a purged history changes + // the intake state the user's other iOS devices render. Coalesced + // per user, best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ purged: true, count }); }, ); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 31bad637b..48e44bb9d 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -24,6 +24,7 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, @@ -489,6 +490,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { invalidateUserMedications(user.id, { evict: true }); } + // (#22) — silent cross-device intake sync: wake the user's OTHER iOS + // devices so a running Live Activity / widget reconciles this dose. + // Coalesced per user, best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess(event, 201); }