mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user