feat(medications): publish the resolved reminder decision as remindersActive

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 14:09:22 +02:00
1 parent 55bfb0a439
commit 195fa28cc5
7 files changed
+204 -14

No files matched your search

+19
View File
@@ -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
+3
View File
@@ -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(
@@ -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\(/,
);
}
});
});
+27 -14
View File
@@ -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
);
}
+3
View File
@@ -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
@@ -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,
@@ -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);
});
});