diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 8d166dd6a..7691b4e7e 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -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 = { + "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, + ); + }); +}); diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index b2a412044..1779e4df9 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -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, 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, ]); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index ed398a136..f506cae52 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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: { diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 52c8b698e..9b008ed54 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -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 }, });