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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 19:09:27 +01:00
1 parent a42b29a160
commit 135d5baf28
10 files changed
+56 -36

No files matched your search

@@ -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", () => {
@@ -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.
@@ -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" } },
+2 -1
View File
@@ -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 },
},
});
+1 -1
View File
@@ -406,7 +406,7 @@ function insertMedication(
create: scheduleInputs.map((s) => scheduleCreateData(s, oneShot)),
},
},
include: { schedules: true },
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
});
}
@@ -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,
+2 -1
View File
@@ -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<SessionUser, "id" | "timezone" | "locale">;
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) {
@@ -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<ReturnType<typeof writeMedicationEdit>>;
@@ -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;
@@ -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);
@@ -61,27 +61,6 @@
{
"id": "<id:4>",
"medicationId": "<id:1>",
"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": "<id:5>",
"medicationId": "<id:1>",
"windowStart": "12:00",
"windowEnd": "13:00",
"label": null,
@@ -99,6 +78,27 @@
"cyclicOffWeeks": null,
"doseWindows": null,
"resolvedUnitsPerDose": 1
},
{
"id": "<id:5>",
"medicationId": "<id:1>",
"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:5>",
"id": "<id:4>",
"medicationId": "<id:1>",
"windowStart": "12:00",
"windowEnd": "13:00",
@@ -479,7 +479,7 @@
]
},
{
"id": "<id:4>",
"id": "<id:5>",
"medicationId": "<id:1>",
"windowStart": "21:00",
"windowEnd": "22:00",