From 135d5baf2857d42917a78a2919e2df381abb7ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 19:09:27 +0100 Subject: [PATCH] fix(medications): return a medication's schedules in one order everywhere The list reads ordered schedules by their window and id, but the detail, create, update, compliance, dose-history, GLP-1 and efficacy reads took them in whatever order Postgres found them, so two schedules created in the same millisecond could swap places between two answers. Every read now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every schedules include, not only the list reads. --- .../medication-read-order-guard.test.ts | 23 ++++++++-- .../api/medications/[id]/compliance/route.ts | 3 +- .../medications/[id]/dose-history/route.ts | 3 +- src/app/api/medications/[id]/glp1/route.ts | 3 +- src/lib/medications/create-medication.ts | 2 +- .../efficacy/load-segmented-effects.ts | 3 +- src/lib/medications/read-medication.ts | 3 +- .../update-medication/update-medication.ts | 3 +- .../medications/update-medication/write.ts | 3 +- ...ications.13c-prn-and-legacy-days.json.snap | 46 +++++++++---------- 10 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src/__tests__/medication-read-order-guard.test.ts b/src/__tests__/medication-read-order-guard.test.ts index 32bd34331..076027a23 100644 --- a/src/__tests__/medication-read-order-guard.test.ts +++ b/src/__tests__/medication-read-order-guard.test.ts @@ -57,7 +57,6 @@ interface Read { model: string; orders: string[]; variableOrder: boolean; - unorderedSchedules: boolean; } function reads(): Read[] { @@ -83,7 +82,6 @@ function reads(): Read[] { model: match[1]!, orders, variableOrder: /\borderBy\s*(?:,|\})/.test(call), - unorderedSchedules: /\bschedules\s*:\s*true\b/.test(call), }); } } @@ -121,9 +119,24 @@ describe("medication reads order their ties", () => { }); it("never includes a medication's schedules unordered", () => { - expect( - ALL.filter((read) => read.unorderedSchedules).map((read) => read.where), - ).toEqual([]); + // Any read or write that returns rows, not only the list reads above: a + // `findUnique` or an `update` that includes the schedules hands them to a + // client in whatever order Postgres found them. + const unordered: string[] = []; + for (const rel of walkSourceFiles(SRC, { floor: 2000 }).filter( + (file) => !file.startsWith("generated/") && !file.includes("__tests__"), + )) { + const text = stripComments(readSourceText(join(SRC, rel)), rel); + for (const match of text.matchAll(/\bschedules\s*:\s*true\b/g)) { + const before = text.slice(Math.max(0, match.index! - 200), match.index); + // `_count: { select: { schedules: true } }` counts; it returns no rows. + if (/_count\s*:\s*\{\s*select\s*:\s*\{[^}]*$/.test(before)) continue; + unordered.push( + `${rel}:${text.slice(0, match.index).split("\n").length}`, + ); + } + } + expect(unordered).toEqual([]); }); it("names every order held in a variable, and nothing else", () => { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 7d9eb4c0f..3cf54bad2 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -12,6 +12,7 @@ import { import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; type RouteParams = { params: Promise<{ id: string }> }; @@ -71,7 +72,7 @@ export const GET = apiHandler( const medication = await prisma.medication.findUnique({ where: { id }, include: { - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 — pause eras so paused days drop out of the denominator. diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 900a7cded..7d9f7cf5b 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -48,6 +48,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { isoDateTime } from "@/lib/validations/datetime"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; type RouteParams = { params: Promise<{ id: string }> }; @@ -132,7 +133,7 @@ export const GET = apiHandler( const medication = await prisma.medication.findUnique({ where: { id }, include: { - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, // v1.16.3 — archived schedule eras: past days mint against the // schedule that was live THEN. scheduleRevisions: { orderBy: { validFrom: "asc" } }, diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index a50bb44ad..11c2eb6f9 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -43,6 +43,7 @@ import { import { NextRequest } from "next/server"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { floorWhole } from "@/lib/medications/units-per-dose"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; type RouteParams = { params: Promise<{ id: string }> }; @@ -90,7 +91,7 @@ export const GET = apiHandler( take: 12, select: { takenAt: true, injectionSite: true }, }, - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, }, }); diff --git a/src/lib/medications/create-medication.ts b/src/lib/medications/create-medication.ts index 74ca7b7fc..9cea9e2be 100644 --- a/src/lib/medications/create-medication.ts +++ b/src/lib/medications/create-medication.ts @@ -406,7 +406,7 @@ function insertMedication( create: scheduleInputs.map((s) => scheduleCreateData(s, oneShot)), }, }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); } diff --git a/src/lib/medications/efficacy/load-segmented-effects.ts b/src/lib/medications/efficacy/load-segmented-effects.ts index 20cb7783b..75ae92b4a 100644 --- a/src/lib/medications/efficacy/load-segmented-effects.ts +++ b/src/lib/medications/efficacy/load-segmented-effects.ts @@ -27,12 +27,13 @@ import { type EfficacySegmented, type SegmentedEffectInput, } from "./segmented-effect"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; const DAY_MS = 24 * 60 * 60 * 1000; /** The medication shape the effect needs: the efficacy builder's include. */ export const EFFECT_MEDICATION_INCLUDE = { - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, scheduleRevisions: { orderBy: { validFrom: "asc" } }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, courses: COURSES_COMPLIANCE_SELECT, diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts index b78074058..276346cfa 100644 --- a/src/lib/medications/read-medication.ts +++ b/src/lib/medications/read-medication.ts @@ -40,6 +40,7 @@ import { courseActionAuthority, withCourseActionAuthority, } from "@/lib/medications/course-authority"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export type ReadUser = Pick; type MedicationWithSchedules = Medication & { @@ -193,7 +194,7 @@ export async function readMedication(input: { const { user, grantId, id } = input; const medication = await prisma.medication.findUnique({ where: { id }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); if (!medication || medication.userId !== user.id) { diff --git a/src/lib/medications/update-medication/update-medication.ts b/src/lib/medications/update-medication/update-medication.ts index 5cc341b57..009db80a8 100644 --- a/src/lib/medications/update-medication/update-medication.ts +++ b/src/lib/medications/update-medication/update-medication.ts @@ -48,6 +48,7 @@ import { type EraTrail, type MedicationEditWrite, } from "./write"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; type EditUser = ReadUser; type EditedMedication = Awaited>; @@ -204,7 +205,7 @@ async function settleAfterCommit( }); const refreshed = await prisma.medication.findUnique({ where: { id }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); if (refreshed) { return refreshed; diff --git a/src/lib/medications/update-medication/write.ts b/src/lib/medications/update-medication/write.ts index f210ddb0d..3a06a04bb 100644 --- a/src/lib/medications/update-medication/write.ts +++ b/src/lib/medications/update-medication/write.ts @@ -24,6 +24,7 @@ import { getUserTodayBounds } from "@/lib/tz/local-day"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import type { EditableMedication } from "./preflight"; import type { MedicationEdit, NormalisedSchedule } from "./schedule-normalise"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; /** * C7. What a schedule replacement destroys, captured for the audit row: the @@ -431,7 +432,7 @@ export async function writeMedicationEdit(edit: MedicationEditWrite) { const updated = await tx.medication.update({ where: { id }, data: { ...baseUpdateData, ...edit.pausedAtPatch }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); await recordPauseTransition(tx, edit); diff --git a/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap b/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap index 63fb80837..69fc57f70 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap @@ -61,27 +61,6 @@ { "id": "", "medicationId": "", - "windowStart": "21:00", - "windowEnd": "22:00", - "label": null, - "dose": null, - "unitsPerDose": null, - "daysOfWeek": "i2;1,3,5", - "timesOfDay": [ - "21:00" - ], - "reminderGraceMinutes": null, - "rrule": null, - "rollingIntervalDays": null, - "scheduleType": "SCHEDULED", - "cyclicOnWeeks": null, - "cyclicOffWeeks": null, - "doseWindows": null, - "resolvedUnitsPerDose": 1 - }, - { - "id": "", - "medicationId": "", "windowStart": "12:00", "windowEnd": "13:00", "label": null, @@ -99,6 +78,27 @@ "cyclicOffWeeks": null, "doseWindows": null, "resolvedUnitsPerDose": 1 + }, + { + "id": "", + "medicationId": "", + "windowStart": "21:00", + "windowEnd": "22:00", + "label": null, + "dose": null, + "unitsPerDose": null, + "daysOfWeek": "i2;1,3,5", + "timesOfDay": [ + "21:00" + ], + "reminderGraceMinutes": null, + "rrule": null, + "rollingIntervalDays": null, + "scheduleType": "SCHEDULED", + "cyclicOnWeeks": null, + "cyclicOffWeeks": null, + "doseWindows": null, + "resolvedUnitsPerDose": 1 } ], "category": "OTHER", @@ -433,7 +433,7 @@ "doseWindows": null }, { - "id": "", + "id": "", "medicationId": "", "windowStart": "12:00", "windowEnd": "13:00", @@ -479,7 +479,7 @@ ] }, { - "id": "", + "id": "", "medicationId": "", "windowStart": "21:00", "windowEnd": "22:00",