feat(medications): dispatch the intake sync push from every intake mutation route

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 10:22:06 +02:00
1 parent b9b38d182c
commit dca4ca544e
10 files changed
+126

No files matched your search

@@ -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 () => {
@@ -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 });
},
);
@@ -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,
});
});
});
@@ -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 () => {
@@ -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 });
},
);
@@ -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 () => {
@@ -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);
},
);
@@ -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 () => {
@@ -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 });
},
);
@@ -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);
}