From 4ced89553ddceb413bdd2c0b18deb0ac15f54ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 23:47:43 +0200 Subject: [PATCH] fix(medications): the schedule flip cannot rewrite history and the doctor report stops inventing a rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching an as-needed medication onto a fixed schedule archived no revision (there were no prior rows), so the era segmentation attributed the entire schedule-less stretch to the new schedules — every as-needed day would have read as missed across the era-aware consumers. The flip now archives an empty-payload era covering the stretch, which expects nothing. The doctor report's compliance table excludes as-needed medications: with no schedule every logged intake is taken by definition, and a fabricated 100% has no place on a clinical document; the medication list itself still carries them. --- .../medications/[id]/__tests__/route.test.ts | 55 +++++++++++++++++++ src/app/api/medications/[id]/route.ts | 36 ++++++++++++ src/lib/doctor-report-data.ts | 7 ++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 121e368da..4c79e1b06 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -700,6 +700,61 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => { expect(updateData.data.asNeeded).toBe(false); }); + it("archives an EMPTY era for the as-needed stretch on the flip back", async () => { + // Without the empty revision the live era would start at the previous + // revision boundary (or createdAt) and the NEW schedule's slots would + // retro-paint every schedule-less as-needed day as missed. + mockExisting({ asNeeded: true, scheduleCount: 0 }); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + const previousBoundary = new Date("2026-05-01T08:00:00Z"); + vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({ + validUntil: previousBoundary, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: false, + schedules: [{ id: "s1" }], + } as never); + const res = await PUT( + putReq({ + asNeeded: false, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.medicationScheduleRevision.create).toHaveBeenCalledTimes(1); + const createArg = vi.mocked(prisma.medicationScheduleRevision.create).mock + .calls[0][0] as { + data: { payload: unknown; validFrom: Date; validUntil: Date }; + }; + expect(createArg.data.payload).toEqual([]); + expect(createArg.data.validFrom).toEqual(previousBoundary); + expect(createArg.data.validUntil).toBeInstanceOf(Date); + }); + + it("does NOT archive an era when a scheduled medication replaces schedules from zero rows", async () => { + // A non-as-needed medication with no previous rows (fresh create path) + // keeps the v1.16.3 behaviour: nothing to archive. + mockExisting({ asNeeded: false, scheduleCount: 0 }); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: false, + schedules: [{ id: "s1" }], + } as never); + const res = await PUT( + putReq({ + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + it("422s an empty schedules array WITHOUT asNeeded:true (Zod)", async () => { mockExisting({ scheduleCount: 1 }); const res = await PUT(putReq({ schedules: [] }), ROUTE_CTX); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 060779662..596a358ee 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -494,6 +494,42 @@ export const PUT = apiHandler( }, meta: { schedule_revision_rows: previousEntries.length }, }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + asNeeded === false && + normalisedSchedules.length > 0 + ) { + // v1.16.11 — flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + const lastRevision = await prisma.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: [] as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: 0 }, + }); } // A schedule replace invalidates the open slot anchors the projector / // reminder worker minted for the OLD times: a pending 08:00 row for a diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 330b1a0cb..6a4d835f2 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -441,6 +441,7 @@ export async function collectDoctorReportData( atcCode: true, rxNormCode: true, deliveryForm: true, + asNeeded: true, }, }, }, @@ -502,9 +503,13 @@ export async function collectDoctorReportData( }; } - // Medication compliance. + // Medication compliance. As-needed medications are excluded: they + // carry no schedule, so every logged intake is "taken" by definition + // and a 100% rate would be a fabricated number on a clinical report. + // The medication itself stays on the report's medication list. const compliance: Record = {}; for (const event of intakeEvents) { + if (event.medication.asNeeded) continue; const name = event.medication.name; if (!compliance[name]) { compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };