mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user