From 195fa28cc59c13d9e173730b5fc0f071e120228b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 14:09:22 +0200 Subject: [PATCH] feat(medications): publish the resolved reminder decision as remindersActive --- docs/api/openapi.yaml | 19 +++++ src/app/api/medications/[id]/route.ts | 3 + .../__tests__/auto-mark-taken.test.ts | 54 +++++++++++++ src/lib/medications/auto-mark-taken.ts | 41 ++++++---- src/lib/medications/list-read.ts | 3 + src/lib/openapi/routes/medications/schemas.ts | 17 ++++ .../medication-auto-mark-taken.test.ts | 81 +++++++++++++++++++ 7 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 src/lib/medications/__tests__/auto-mark-taken.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b80bf1f11..bc8e0d724 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -47710,6 +47710,13 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + remindersActive: + type: boolean + description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when + `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not + `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client + that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not + stored." nextDueAt: anyOf: - type: string @@ -47898,6 +47905,7 @@ components: - rxNormCode - pausedAt - snoozedUntil + - remindersActive - nextDueAt - nextDueOverdue - startsOn @@ -48071,6 +48079,9 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + remindersActive: + type: boolean + description: v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component. nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules @@ -48240,6 +48251,7 @@ components: - rxNormCode - pausedAt - snoozedUntil + - remindersActive - startsOn - endsOn - oneShot @@ -83016,6 +83028,13 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + remindersActive: + description: "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders + for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is + tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every + server-side reminder send. A client that arms local reminders arms them from this, not from + `notificationsEnabled`. Read-only — computed, not stored." + type: boolean nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 5082d4f56..dc5d768f8 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -264,6 +265,8 @@ export const GET = apiHandler( categoryLabel, ...courseFields, nextDueAt: display ? display.at.toISOString() : null, + // v1.42.2 — the server's own reminder decision, published resolved. + remindersActive: sendsMedicationReminders(medication), nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. ...resolveIntakeActionability( diff --git a/src/lib/medications/__tests__/auto-mark-taken.test.ts b/src/lib/medications/__tests__/auto-mark-taken.test.ts new file mode 100644 index 000000000..a77065d7f --- /dev/null +++ b/src/lib/medications/__tests__/auto-mark-taken.test.ts @@ -0,0 +1,54 @@ +/** + * v1.42.2 — `sendsMedicationReminders` is the one reminder decision: the + * worker and the admin check gate on it, the read paths publish it as + * `remindersActive`. Pins the truth table and that every site calls it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { sendsMedicationReminders } from "../auto-mark-taken"; + +describe("sendsMedicationReminders", () => { + it("reminds only for an active, tracked, scheduled medication with reminders on and no auto-recording", () => { + for (let bits = 0; bits < 32; bits++) { + const med = { + notificationsEnabled: (bits & 1) !== 0, + autoMarkTaken: (bits & 2) !== 0, + active: (bits & 4) !== 0, + trackIntake: (bits & 8) !== 0, + asNeeded: (bits & 16) !== 0, + }; + expect(sendsMedicationReminders(med), JSON.stringify(med)).toBe( + med.notificationsEnabled && + !med.autoMarkTaken && + med.active && + med.trackIntake && + !med.asNeeded, + ); + } + }); + + it("is the decision at every gate and on every published DTO", () => { + const src = (rel: string) => + readFileSync(join(process.cwd(), "src", rel), "utf8"); + for (const gate of [ + "lib/jobs/reminder/medication-reminder-check.ts", + "app/api/admin/notifications/reminder-check/route.ts", + ]) { + expect(src(gate), gate).toMatch(/sendsMedicationReminders\(med\)/); + // No send site falls back to the raw preference. + expect(src(gate), gate).not.toMatch( + /if \([^)]*med\.notificationsEnabled\)/, + ); + } + for (const dto of [ + "lib/medications/list-read.ts", + "app/api/medications/[id]/route.ts", + ]) { + expect(src(dto), dto).toMatch( + /remindersActive:\s*sendsMedicationReminders\(/, + ); + } + }); +}); diff --git a/src/lib/medications/auto-mark-taken.ts b/src/lib/medications/auto-mark-taken.ts index 28c0e1f6c..e1dfe55e2 100644 --- a/src/lib/medications/auto-mark-taken.ts +++ b/src/lib/medications/auto-mark-taken.ts @@ -1,21 +1,34 @@ /** - * v1.42.2 — "record doses automatically" (`Medication.autoMarkTaken`). + * v1.42.2 — whether a dose reminder may be sent for a medication. * - * A medication with the option on has every scheduled dose recorded as taken - * by the `intake-auto-take` job once its time has passed. There is nothing to - * remind about, so every reminder path asks {@link sendsMedicationReminders} - * instead of reading `notificationsEnabled` alone. The person's own reminder - * preference stays stored untouched: switching the option off restores + * THE one definition. The reminder worker and the admin reminder check gate + * every send on it, and the medication read paths publish its result as + * `remindersActive`, so a client that arms local reminders reads the server's + * decision instead of re-deriving it from the raw flags. + * + * A medication reminds only when the person wants reminders + * (`notificationsEnabled`), it is active (not paused or ended), it has doses + * that fall due (intake tracked, not as-needed), and its doses are not + * recorded automatically (`autoMarkTaken`). The person's own reminder + * preference stays stored untouched: switching auto-mark-taken off restores * whatever they had chosen. */ - -/** - * True when a dose reminder may be sent for this medication: reminders are - * switched on and its doses are not recorded automatically. - */ -export function sendsMedicationReminders(medication: { +export interface ReminderGateMedication { notificationsEnabled: boolean; autoMarkTaken: boolean; -}): boolean { - return medication.notificationsEnabled && !medication.autoMarkTaken; + active: boolean; + trackIntake: boolean; + asNeeded: boolean; +} + +export function sendsMedicationReminders( + medication: ReminderGateMedication, +): boolean { + return ( + medication.notificationsEnabled && + !medication.autoMarkTaken && + medication.active && + medication.trackIntake && + !medication.asNeeded + ); } diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index bfcf6f033..cbbfbd456 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -12,6 +12,7 @@ * write-invalidation semantics (`invalidateUserMedications`) cover both * readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard. */ +import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { resolveCourseFields } from "@/lib/medications/courses"; @@ -326,6 +327,8 @@ export async function buildMedicationsList( (todayEventCountByMedId[m.id] ?? 0) + (carriesPriorRollingOccurrence ? 1 : 0), nextDueAt: display ? display.at.toISOString() : null, + // v1.42.2 — the server's own reminder decision, published resolved. + remindersActive: sendsMedicationReminders(m), nextDueOverdue: display?.overdue ?? false, nextDueScheduleId: display?.scheduleId ?? null, // v1.39.4 (#1040) — whether the card offers taken / skip today, and diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 1e4147d6f..50b5defa7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -278,6 +278,12 @@ export const medicationResource = z ), pausedAt: z.iso.datetime({ offset: true }).nullable(), snoozedUntil: z.iso.datetime({ offset: true }).nullable(), + remindersActive: z + .boolean() + .optional() + .describe( + "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", + ), nextDueAt: z.iso .datetime({ offset: true }) .nullable() @@ -366,6 +372,11 @@ export const medicationListEntry = medicationResource // even though the shared base leaves them optional for the write paths. nextDueAt: z.iso.datetime({ offset: true }).nullable(), nextDueOverdue: z.boolean(), + remindersActive: z + .boolean() + .describe( + "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", + ), courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( @@ -418,6 +429,12 @@ export const medicationListEntry = medicationResource export const medicationDetailEntry = medicationResource .extend({ + // The single GET always computes it; required here like on the list. + remindersActive: z + .boolean() + .describe( + "v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component.", + ), category: medicationCategoryEnum, categoryLabel: medicationCategoryLabelField, ...medicationCourseFields, diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index 099db4fcc..d82a6ceb1 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -379,3 +379,84 @@ describe("auto-mark-taken pass", () => { expect(off).toEqual({ autoMarkTaken: false, autoMarkTakenSince: null }); }); }); + +describe("remindersActive — the published value is the gate", () => { + it("equals sendsMedicationReminders() on the list and detail reads, for every combination", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const { sendsMedicationReminders } = + await import("@/lib/medications/auto-mark-taken"); + at("2026-06-10T10:00:00.000Z"); + + const combos: Array<{ + notificationsEnabled: boolean; + autoMarkTaken: boolean; + active: boolean; + trackIntake: boolean; + asNeeded: boolean; + }> = []; + for (let bits = 0; bits < 32; bits++) { + combos.push({ + notificationsEnabled: (bits & 1) !== 0, + autoMarkTaken: (bits & 2) !== 0, + active: (bits & 4) !== 0, + trackIntake: (bits & 8) !== 0, + asNeeded: (bits & 16) !== 0, + }); + } + const ids: string[] = []; + for (const [i, combo] of combos.entries()) { + const med = await prisma.medication.create({ + data: { + userId, + name: `Combo ${i}`, + dose: "1", + ...combo, + autoMarkTakenSince: combo.autoMarkTaken ? new Date() : null, + ...(!combo.asNeeded && { + schedules: { + create: { + windowStart: "19:00", + windowEnd: "19:00", + timesOfDay: ["19:00"], + rrule: "FREQ=DAILY", + scheduleType: "SCHEDULED", + }, + }, + }), + }, + select: { id: true }, + }); + ids.push(med.id); + } + + const list = await import("@/app/api/medications/route"); + const listRes = await (list.GET as Handler)( + new NextRequest("http://localhost/api/medications"), + { params: Promise.resolve({}) }, + ); + expect(listRes.status).toBe(200); + const listBody = (await listRes.json()) as { + data: Array<{ id: string; remindersActive: boolean }>; + }; + const listed = new Map( + listBody.data.map((m) => [m.id, m.remindersActive] as const), + ); + + const detail = await import("@/app/api/medications/[id]/route"); + let trueCount = 0; + for (const [i, id] of ids.entries()) { + const expected = sendsMedicationReminders(combos[i]); + if (expected) trueCount++; + expect(listed.get(id), `list combo ${i}`).toBe(expected); + const res = await (detail.GET as Handler)( + new NextRequest(`http://localhost/api/medications/${id}`), + { params: Promise.resolve({ id }) }, + ); + const body = (await res.json()) as { data: { remindersActive: boolean } }; + expect(body.data.remindersActive, `detail combo ${i}`).toBe(expected); + } + // Exactly one combination reminds: on, not auto, active, tracked, scheduled. + expect(trueCount).toBe(1); + }); +});