fix(medications): leave record-only doses out of the nudge, the status model and the BP correlation

Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 10:03:08 +02:00
1 parent e03cf7c2c8
commit 1d1570733e
4 files changed
+111 -2

No files matched your search

@@ -40,7 +40,50 @@ const SCHEDULE_READ =
/** The vocabulary that honours the switch. */
const HONOURS =
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/;
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
/**
* A bulk read of intake rows. Rate readers that never touch a schedule (a
* nudge that divides taken by resolved rows, a model series that buckets
* rows per day) count a record-only medication's rows unless they filter.
* Whitespace-tolerant: the delegate and the method may sit on two lines.
*/
const INTAKE_READ =
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
/**
* Intake readers that may see every medication's rows, and why: nothing in
* them is an adherence rate, an expected-dose count or a due dose.
*/
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"app/api/admin/status/route.ts": "instance row counts",
"app/api/dashboard/summary/route.ts":
"engagement streak of days with any logged action, no rate",
"app/api/export/medications/route.ts": "export of the recorded history",
"app/api/export/route.ts": "export of the recorded history",
"lib/export/full-backup-payload.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
"lib/ai/coach/tools/availability.ts":
"whether any intake history exists, and its date range",
"app/api/medications/[id]/intake/route.ts":
"one medication's recorded history, listed or written",
"app/api/medications/[id]/intake/bulk-delete/route.ts":
"deletes rows the person chose",
"app/api/medications/[id]/route.ts":
"one medication's slots; tombstones on the tracking switch",
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/scheduling/worker-helpers.ts":
"slot lookups for one medication the caller already selected",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
"lib/doctor-report/collect.ts":
"administration ledger lists recorded doses; rates gate via expectsDoses",
};
/**
* Readers that may see every stored schedule row, and why. Each reason says
@@ -168,3 +211,55 @@ describe("medication intake tracking — schedule readers honour the switch", ()
).toBe(false);
});
});
const intakeReaders = sourceFiles().filter((rel) =>
INTAKE_READ.test(code(rel)),
);
describe("medication intake tracking — intake-row readers honour the switch", () => {
it("finds the intake readers it exists to police", () => {
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/coach-nudge.ts",
"lib/insights/general-status.ts",
"lib/analytics/correlations-fast-path.ts",
"lib/medications/outstanding-doses.ts",
]) {
expect(intakeReaders).toContain(known);
}
});
it("every intake reader uses the vocabulary or is allowlisted", () => {
const offenders = intakeReaders.filter(
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read intake rows without honouring Medication.trackIntake. " +
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
).toEqual([]);
});
it("the intake allowlist names no file that stopped reading intake rows", () => {
expect(
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
(rel) => !intakeReaders.includes(rel),
),
).toEqual([]);
});
it("the intake matcher catches the shapes it claims to", () => {
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
true,
);
expect(
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
).toBe(true);
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
false,
);
});
});
+8 -1
View File
@@ -65,6 +65,7 @@ import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -279,7 +280,13 @@ export async function computeCorrelationHypothesesFastPath(
}) as Promise<MoodRow[]>,
prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, scheduledFor: { gte: since } },
where: {
userId,
deletedAt: null,
scheduledFor: { gte: since },
// v1.39.1 (#1033) — compliance counts tracked medications only.
...TRACKED_INTAKE_EVENT_WHERE,
},
select: { scheduledFor: true, takenAt: true, skipped: true },
}) as Promise<IntakeRow[]>,
]);
+3
View File
@@ -44,6 +44,7 @@ import {
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
@@ -243,6 +244,8 @@ export async function prepareGeneralStatusForUser(
userId,
deletedAt: null,
scheduledFor: { gte: intakeWindowStart },
// v1.39.1 (#1033) — adherence counts tracked medications only.
...TRACKED_INTAKE_EVENT_WHERE,
},
orderBy: { scheduledFor: "asc" },
select: {
+4
View File
@@ -111,6 +111,7 @@ import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
type HealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const SELF_CONTEXT_AI_SECTIONS = [
@@ -556,6 +557,9 @@ export async function findTriggerForUser(
userId: user.id,
deletedAt: null,
scheduledFor: { gte: sevenDaysAgo, lte: now },
// v1.39.1 (#1033) — a medication kept as a record expects no dose;
// its rows must not raise a compliance nudge.
...TRACKED_INTAKE_EVENT_WHERE,
},
select: { takenAt: true, skipped: true, autoMissed: true },
});