From aafc1a3c1a084265bf3cd74d3d4f501583255b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 18:34:11 +0200 Subject: [PATCH] Quiet the snooze and clear the web reminder on every intake path The two halves of a resolved dose were split across routes: the single intake route reset the medication's snooze but never closed the pending dose-due Web Push reminder, while the bulk route closed the reminder but left the snooze standing. Each path was missing exactly what the other had. A dose drained from a device's offline queue lands on these routes, so a synced take could keep ringing as snoozed or leave a stale web badge behind. The bulk route now nulls snoozedUntil once per affected medication when a non-skipped entry actually lands, and the by-id route dispatches the same fire-and-forget web clear the other intake paths have carried since v1.18.4. --- .../__tests__/source-provenance.test.ts | 37 ++++++++++ src/app/api/medications/[id]/intake/route.ts | 18 +++++ .../intake/bulk/__tests__/route.test.ts | 70 +++++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 15 ++++ 4 files changed, 140 insertions(+) diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 3b4ebe141..2d1b52967 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -82,6 +82,15 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ queueMedicationIntakeSync: vi.fn(), })); +// The route dispatches the PWA clear fire-and-forget after a recorded dose; +// both halves are mocked so the test asserts the dispatch, not web-push. +vi.mock("@/lib/notifications/web-push-clear", () => ({ + dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/medications/outstanding-doses", () => ({ + countOutstandingDosesToday: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -110,6 +119,8 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveBearerToken } from "@/lib/auth/bearer"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; const USER = { id: "user-1", @@ -191,6 +202,32 @@ describe("POST intake — transport-derived source (iOS #64)", () => { expect(createdSource()).toBe("API"); }); + it("closes the pending dose-due web reminder after a recorded intake", async () => { + // Before this route dispatched the clear, an iOS offline drain (which + // replays free intakes through exactly this endpoint) left the web + // dose-due reminder and the app badge standing after the dose landed. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(countOutstandingDosesToday).mockResolvedValue(3); + + const res = await POST(postReq({}), ROUTE_PARAMS); + expect(res.status).toBe(201); + + // Fire-and-forget: the dispatch runs detached from the response. + await expect + .poll( + () => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length, + { timeout: 2_000, interval: 25 }, + ) + .toBe(1); + expect( + vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0], + ).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + badgeCount: 3, + }); + }); + it("ignores a client-supplied `source` in the body (no mass assignment)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0164660c2..6d684b667 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -29,6 +29,8 @@ 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 { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { @@ -616,6 +618,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { originDeviceToken: request.headers.get("x-device-id"), }); + // PWA counterpart of the sync wake above: a dose resolved here (taken or + // skipped) closes the still-pending dose-due Web Push reminder for the + // slot and refreshes the app badge. The other intake routes have carried + // this since v1.18.4; this route is the replay target of the iOS offline + // queue, so a drained dose must clear the web reminder the same way. + // Best-effort, fire-and-forget — the canonical row is already persisted. + void (async () => { + const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); + await dispatchMedicationIntakeWebClear({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor.toISOString(), + badgeCount, + }); + })(); + // v1.36.x — "somebody else marked your dose". This route has no snooze arm, // so the state is whichever of the two markings the payload carried. The // helper refuses on self, so a person marking their own dose is unaffected. diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index b8b61c992..25765da23 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), // v1.8.2 — the slot resolver loads the med via findFirst. findFirst: vi.fn(), + // A landed non-skipped dose clears the medication's snooze. + updateMany: vi.fn(), }, medicationIntakeEvent: { create: vi.fn(), @@ -324,6 +326,74 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { }); }); + it("a landed taken write clears the medication's active snooze", async () => { + // The single-intake routes null `snoozedUntil` on every recorded take; + // before this parity fix a dose drained through the bulk route (the iOS + // offline-queue path) left the snooze standing and the reminder ringing. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.medication.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["med-1"] }, userId: "user-1" }, + data: { snoozedUntil: null }, + }); + }); + + it("a landed skip leaves the snooze untouched", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.medication.updateMany).not.toHaveBeenCalled(); + }); + it("queues exactly ONE intake-sync fan-out for a multi-entry batch", async () => { // Two pending rows at the day's two slots; the batch resolves both. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 9feded721..5064c0699 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -351,6 +351,10 @@ async function postBulk(request: NextRequest): Promise { string, { medicationId: string; scheduledFor: string } >(); + // A landed non-skipped dose quiets an active snooze, exactly like the + // single-intake routes (they null `snoozedUntil` on every recorded take). + // Collected per medication here, cleared once after the loop. + const snoozeClearIds = new Set(); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -786,6 +790,7 @@ async function postBulk(request: NextRequest): Promise { medicationId: entry.medicationId, scheduledFor: scheduledForIso, }); + if (!entry.skipped) snoozeClearIds.add(entry.medicationId); } } catch (err: unknown) { // P2002 = unique-constraint violation. Two shapes reach here: @@ -909,6 +914,16 @@ async function postBulk(request: NextRequest): Promise { // 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. + // The single-intake routes reset a medication's snooze on every recorded + // take; a drained offline batch must quiet the snooze the same way, or a + // dose synced in later keeps counting as snoozed although it was taken. + if (snoozeClearIds.size > 0) { + await prisma.medication.updateMany({ + where: { id: { in: [...snoozeClearIds] }, userId: user.id }, + data: { snoozedUntil: null }, + }); + } + if (syncSlots.size > 0) { queueMedicationIntakeSync({ userId: user.id,