Merge branch 'feat/medication-record-only' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 09:37:27 +02:00
commit e03cf7c2c8
81 files changed
+2283 -274

No files matched your search

@@ -0,0 +1,170 @@
/**
* Structural guard — every reader of medication schedules honours the
* per-medication intake-tracking switch (#1033).
*
* An as-needed medication is "never due, never reminded, never counted"
* structurally: it carries no schedule rows, so every reader that expands
* schedules into slots finds nothing to expand. A medication with intake
* tracking off (`Medication.trackIntake = false`) keeps its schedule rows as a
* record. Nothing about the rows themselves says "do not derive a due dose
* from me", so the promise holds only as long as every reader asks. A new
* reminder path, compliance surface or projection that reads `schedules` and
* forgets the flag would quietly start reminding a person who asked not to be
* reminded.
*
* So the set of schedule readers is frozen here. A file that reads medication
* schedules through Prisma either uses the shared vocabulary in
* `src/lib/medications/intake-tracking.ts` (or `expectsDoses`, whose required
* `trackIntake` field makes the type checker enforce the same thing), or it is
* on the allowlist below with the reason it may see every stored row.
*
* Limits, stated plainly. The matcher is textual: it finds a Prisma
* `schedules: true` / `schedules: { … }` include or select and the
* `medicationSchedule.find*` delegates. A schedule read spelt some other way
* (raw SQL on `medication_schedules`, a helper that returns rows it read
* elsewhere) is invisible to it. It proves a file names the vocabulary, not
* that the named helper is applied to the right query. The behavioural tests
* beside each consumer carry that half.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** A Prisma read (or write payload) that touches medication schedule rows. */
const SCHEDULE_READ =
/\bschedules\s*:\s*(?:true\b|\{)|\bmedicationSchedule\s*\.\s*find(?:Many|First|Unique)\b/;
/** The vocabulary that honours the switch. */
const HONOURS =
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/;
/**
* Readers that may see every stored schedule row, and why. Each reason says
* why no due dose, reminder or adherence figure can come out of the file.
*/
const MAY_READ_EVERY_ROW: Record<string, string> = {
// Record-keeping: a backup, a restore and an export carry the medication
// as stored, schedule and switch included.
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
"lib/export/restore-backup.ts": "restore writes the stored rows back",
"app/api/export/route.ts": "data export of the stored record",
"app/api/export/medications/route.ts": "medication CSV export of the record",
// Intake WRITE helpers: they run only when a dose is being recorded, bind
// it to the slot it belongs to and consume stock. They derive nothing due.
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
// Readers that hand the rows to a builder whose input TYPE requires
// `trackIntake` and which gates on it: the per-medication compliance
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
// report's ledger compliance (`expectsDoses`). The report's medication
// list shows the stored schedule as information.
"app/api/medications/[id]/compliance/route.ts":
"buildCompliancePayload gates on the type-required trackIntake",
"lib/doctor-report/collect.ts":
"buildLedgerCompliance gates via expectsDoses; list is information",
// The MCP medication resources list the stored schedule as information,
// with `intakeTracked` beside it; they carry no due time or rate.
"lib/mcp/resources.ts":
"schedule as information, intakeTracked flag beside it",
// The Coach's GLP-1 block reads the stored cadence as information and
// gates its next-injection prediction on the flag inline.
"lib/ai/coach/glp1-snapshot.ts":
"cadence as information; prediction gated on trackIntake",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
/** Drop line and block comments so a mention in prose never counts. */
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function code(rel: string): string {
return stripComments(readFileSync(join(SRC, rel), "utf8"));
}
/**
* The code without its import statements. An import of the vocabulary is
* not a use of it: a reader that still imports `TRACKED_INTAKE_WHERE` after
* its filter was deleted would otherwise pass.
*/
function usage(rel: string): string {
return code(rel).replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, "");
}
const readers = sourceFiles().filter((rel) => SCHEDULE_READ.test(code(rel)));
describe("medication intake tracking — schedule readers honour the switch", () => {
it("finds the schedule readers it exists to police", () => {
// An empty match set would agree with any allowlist. Pin the floor and
// the readers whose silence would matter most.
expect(readers.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/reminder/medication-reminder-check.ts",
"lib/medications/scheduling/project-today-intakes.ts",
"lib/dashboard/meds-today.ts",
"lib/medications/list-read.ts",
"lib/jobs/intake-auto-skip.ts",
]) {
expect(readers).toContain(known);
}
});
it("every schedule reader uses the vocabulary or is allowlisted", () => {
const offenders = readers.filter(
(rel) => !(rel in MAY_READ_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read medication schedules without honouring " +
"Medication.trackIntake. Filter with TRACKED_INTAKE_WHERE, derive " +
"from dueSchedules(), shape the wire with scheduleWireFields(), or " +
"add the file to MAY_READ_EVERY_ROW with the reason it derives " +
"nothing due.",
).toEqual([]);
});
it("the allowlist names no file that stopped reading schedules", () => {
const stale = Object.keys(MAY_READ_EVERY_ROW).filter(
(rel) => !readers.includes(rel),
);
expect(stale).toEqual([]);
});
it("the matchers catch the shapes they claim to", () => {
// Defeat check: the guard must be able to fail.
expect(SCHEDULE_READ.test("include: { schedules: true }")).toBe(true);
expect(SCHEDULE_READ.test("schedules: {\n select: X }")).toBe(true);
expect(
SCHEDULE_READ.test("prisma.medicationSchedule\n .findMany({})"),
).toBe(true);
expect(SCHEDULE_READ.test("const schedules = m.schedules;")).toBe(false);
expect(HONOURS.test(stripComments("// dueSchedules(m)"))).toBe(false);
expect(HONOURS.test("where: { ...TRACKED_INTAKE_WHERE }")).toBe(true);
// An import alone is not a use.
const importOnly =
'import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";\n' +
"const rows = await prisma.medication.findMany({ where: { active: true } });";
expect(
HONOURS.test(
importOnly.replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, ""),
),
).toBe(false);
});
});
@@ -248,6 +248,9 @@ const PINNED_AFFORDANCES: Record<
"src/components/medications/sections/inventory-dialogs.tsx": {
"toast.success": 3,
},
"src/components/medications/sections/intake-tracking-section.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/inventory-section.tsx": {
"toast.success": 1,
},