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