fix(medications): keep a record-only schedule unless the write names trackIntake

The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 09:24:38 +02:00
1 parent 94ec9ab932
commit e5236d2e6a
6 files changed
+263 -7

No files matched your search

@@ -24,6 +24,10 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
medicationPauseEra: {
create: vi.fn().mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
@@ -902,6 +906,8 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
arrange(false);
const res = await PUT(
putReq({
// The web editor names the flag on a record-only schedule save.
trackIntake: false,
schedules: [
{
windowStart: "08:00",
@@ -919,3 +925,191 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
expect(era.payload).toEqual([]);
});
});
/**
* #1033 — the shipped iPhone app (1.0.3 / 1.0.4) cannot see the schedule of a
* record-only medication (it is served with `schedules: []`). These are the
* exact bodies its edit sheet sends (`EditMedicationSheet.save()` →
* `MedicationPatch`, nil keys omitted): it never names `trackIntake`, always
* sends `active` and `notificationsEnabled`, and sends the schedule shape
* only when a schedule field was touched, rebuilt from the "daily at 08:00"
* default its form shows for a medication without schedule rows.
*/
describe("PUT /api/medications/[id] — shipped client on a record-only medication (#1033)", () => {
const STORED_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "21:00",
windowEnd: "21:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["21:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
// `name`/`dose`/`treatmentClass`/`category`/`active`/`notificationsEnabled`/
// `deliveryForm` ride every save; `dosesPerUnit` is nil for an empty field.
const IOS_RENAME = {
name: "Atorvastatin 20",
dose: "20 mg",
treatmentClass: "GENERIC",
category: "OTHER",
active: true,
notificationsEnabled: true,
deliveryForm: "ORAL",
};
// The same save after the dose time was moved from the shown 08:00 default
// to 09:00: `scheduleWrite(.daily)` + `oneShot` + `startsOn` + `asNeeded`.
const IOS_SCHEDULE_TOUCHED = {
...IOS_RENAME,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00"],
rrule: "FREQ=DAILY",
},
],
oneShot: false,
startsOn: "2026-06-01",
asNeeded: false,
};
// The same save after picking the "as needed" cadence.
const IOS_AS_NEEDED = {
...IOS_RENAME,
schedules: [],
oneShot: false,
asNeeded: true,
};
function arrange(trackIntake: boolean) {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
asNeeded: false,
trackIntake,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
_count: { schedules: 1 },
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
trackIntake,
unitsPerDose: 1,
schedules: [{ ...STORED_ROW, unitsPerDose: null }],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
STORED_ROW,
] as never);
vi.mocked(prisma.medication.update).mockClear();
vi.mocked(prisma.medicationSchedule.deleteMany).mockClear();
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
}
function updateData(): Record<string, unknown> {
return vi.mocked(prisma.medication.update).mock.calls[0][0].data as Record<
string,
unknown
>;
}
it("a rename applies and touches no schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_RENAME), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject({
name: "Atorvastatin 20",
notificationsEnabled: true,
});
expect(updateData()).not.toHaveProperty("schedules");
expect(updateData()).not.toHaveProperty("trackIntake");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("a schedule-touched save keeps the recorded schedule and applies the rest", async () => {
arrange(false);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
const data = updateData();
// The 09:00 default the person never chose is not written.
expect(data).not.toHaveProperty("schedules");
expect(data).not.toHaveProperty("asNeeded");
expect(data).not.toHaveProperty("oneShot");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
// Everything else in the save applies.
expect(data).toMatchObject({ name: "Atorvastatin 20" });
expect(data).toHaveProperty("startsOn");
// And it is still a record: the stored schedule comes back on record.
const body = (await res.json()) as {
data: {
schedules: unknown[];
recordedSchedules?: Array<{ timesOfDay: string[] }>;
};
};
expect(body.data.schedules).toEqual([]);
expect(body.data.recordedSchedules?.[0]?.timesOfDay).toEqual(["21:00"]);
});
it("picking 'as needed' neither 422s nor deletes the recorded schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_AS_NEEDED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).not.toHaveProperty("asNeeded");
expect(updateData()).not.toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("the same schedule write applies once it names trackIntake", async () => {
arrange(false);
const res = await PUT(
putReq({ ...IOS_SCHEDULE_TOUCHED, trackIntake: false }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it("a tracked medication takes the shipped client's schedule write as before", async () => {
arrange(true);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(updateData()).toMatchObject({ asNeeded: false, oneShot: false });
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it.each([
["pause", { active: false }],
["reactivate", { active: true }],
["end course", { endsOn: "2026-06-12" }],
])("the %s write applies unchanged", async (_label, body) => {
arrange(false);
const res = await PUT(putReq(body), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject(
"active" in body ? { active: body.active } : {},
);
expect(updateData()).not.toHaveProperty("schedules");
});
});
+50 -1
View File
@@ -299,6 +299,55 @@ export const PUT = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
// ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ──
//
// A medication with intake tracking off is served with `schedules: []`
// and its stored schedule in `recordedSchedules`, so a client that
// predates the field arms no reminder from it. The price is that such a
// client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
// 1.0.4) opens a medication without schedule rows in its editor as
// "daily at 08:00"; a rename or dose edit sends no `schedules` (its
// editor diffs against that same prefill), but any touch of a schedule
// field sends a full replacement built from the 08:00 default plus
// `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
// overwrite the schedule the person kept on record with one they never
// chose (or flip it to as-needed and delete the rows).
//
// Rule: on a medication whose intake tracking is off, a write that does
// not name `trackIntake` leaves the schedule shape alone. `schedules`,
// `asNeeded` and `oneShot` are dropped from it; every other field (name,
// dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
// as sent. A client that knows about the switch names it on every
// schedule save (the web editor and wizard do), and those writes apply.
const keepRecordedSchedule =
existing.trackIntake === false &&
parsed.data.trackIntake === undefined &&
(parsed.data.schedules !== undefined ||
parsed.data.asNeeded !== undefined ||
parsed.data.oneShot !== undefined);
const input = keepRecordedSchedule
? {
...parsed.data,
schedules: undefined,
asNeeded: undefined,
oneShot: undefined,
}
: parsed.data;
if (keepRecordedSchedule) {
annotate({
action: {
name: "medication.update.record_schedule_kept",
entity_type: "medication",
entity_id: id,
},
meta: {
dropped_schedules: parsed.data.schedules !== undefined,
dropped_as_needed: parsed.data.asNeeded !== undefined,
dropped_one_shot: parsed.data.oneShot !== undefined,
},
});
}
const {
name,
dose,
@@ -323,7 +372,7 @@ export const PUT = apiHandler(
asNeeded,
trackIntake,
reminderGraceMinutes: topLevelGraceMinutes,
} = parsed.data;
} = input;
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
// as a record; the live era from here on expects nothing.