feat(medications): server-authoritative medication-efficacy view + API

Add the migration (0232), Prisma model MedicationEfficacyTarget (persists ONLY
the user's explicit target override; everything else derives each read), and
the server builder buildMedicationEfficacy that composes the target's series,
a before/after-start comparison, the cadence-aware adherence lane (never
recomputed), an optional conservative level-shift note, honest data-floor
states and the retarget options into one strictly-descriptive DTO — no
verdict/score field by construction. Expose GET /api/medications/[id]/efficacy
and PUT /api/medications/[id]/efficacy/target, regenerate the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 12:45:50 +02:00
1 parent f3762cf0f9
commit 725f69097f
9 files changed
+1629 -2

No files matched your search

+505
View File
@@ -3389,6 +3389,163 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/efficacy:
get:
tags:
- Medications
summary: Efficacy view for a medication ("Wirkung")
description: "Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's
class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the
cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget
options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot
or no-target medication whose tab is hidden."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Efficacy view.
content:
application/json:
schema:
$ref: "#/components/schemas/GetMedicationEfficacyResponse"
"401": *a1
"404":
description: Medication not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/efficacy/target:
put:
tags:
- Medications
summary: Set or clear the efficacy-target override
description: Persists the user's explicit efficacy target for a medication (the only thing the view stores; everything
else is derived each read). Pin exactly one of `measurementType` / `biomarkerId`, or pass `clear:true` to revert
to the derived target.
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
clear:
type: boolean
measurementType:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
- RESILIENCE
- PHQ9_SCORE
- GAD7_SCORE
- GRIP_STRENGTH
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
biomarkerId:
type: string
minLength: 1
maxLength: 64
primary:
type: boolean
responses:
"200":
description: Override set or cleared.
content:
application/json:
schema:
$ref: "#/components/schemas/SetMedicationEfficacyTargetResponse"
"401": *a1
"404":
description: Medication or biomarker not found.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/schedule-revisions:
get:
tags:
@@ -17712,6 +17869,354 @@ components:
- veryLate
additionalProperties: false
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
GetMedicationEfficacyResponse:
type: object
properties:
data:
type: object
properties:
medicationId:
type: string
medicationName:
type: string
eligible:
type: boolean
reason:
type: string
enum:
- one_shot
- no_target
startsOn:
anyOf:
- type: string
- type: "null"
resolution:
type: object
properties:
tier:
type: string
enum:
- override
- atc
- name
- none
cls:
anyOf:
- type: string
- type: "null"
required:
- tier
- cls
additionalProperties: false
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
minWeeksPerSide:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
markers:
type: object
properties:
start:
anyOf:
- type: string
- type: "null"
startSource:
anyOf:
- type: string
enum:
- startsOn
- firstReading
- type: "null"
doseChanges:
type: array
items:
type: object
properties:
at:
type: string
label:
type: string
required:
- at
- label
additionalProperties: false
pauses:
type: array
items:
type: object
properties:
from:
type: string
to:
anyOf:
- type: string
- type: "null"
required:
- from
- to
additionalProperties: false
required:
- start
- startSource
- doseChanges
- pauses
additionalProperties: false
targets:
type: array
items:
type: object
properties:
kind:
type: string
enum:
- metric
- lab
key:
type: string
label:
type: string
unit:
anyOf:
- type: string
- type: "null"
primary:
type: boolean
referenceBand:
anyOf:
- type: object
properties:
low:
type: number
high:
type: number
required:
- low
- high
additionalProperties: false
- type: "null"
series:
type: array
items:
type: object
properties:
t:
type: string
value:
type: number
status:
type: string
enum:
- in-range
- below
- above
- unknown
required:
- t
- value
additionalProperties: false
beforeAfter:
type: object
properties:
present:
type: boolean
reason:
type: string
enum:
- insufficient_before
- insufficient_after
- no_start
- no_data
before:
type: object
properties:
mean:
type: number
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
from:
type: string
to:
type: string
required:
- mean
- count
- from
- to
additionalProperties: false
after:
type: object
properties:
mean:
type: number
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
from:
type: string
to:
type: string
required:
- mean
- count
- from
- to
additionalProperties: false
delta:
anyOf:
- type: object
properties:
mean:
type: number
pct:
anyOf:
- type: number
- type: "null"
required:
- mean
- pct
additionalProperties: false
- type: "null"
required:
- present
additionalProperties: false
levelShift:
anyOf:
- type: object
properties:
present:
type: boolean
at:
type: string
nearStart:
type: boolean
required:
- present
additionalProperties: false
- type: "null"
required:
- kind
- key
- label
- unit
- primary
- referenceBand
- series
- beforeAfter
- levelShift
additionalProperties: false
adherence:
type: array
items:
type: object
properties:
date:
type: string
rate:
type: number
taken:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
missed:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- date
- rate
- taken
- missed
additionalProperties: false
overrideOptions:
type: object
properties:
metrics:
type: array
items:
type: object
properties:
key:
type: string
label:
type: string
required:
- key
- label
additionalProperties: false
biomarkers:
type: array
items:
type: object
properties:
id:
type: string
label:
type: string
unit:
type: string
required:
- id
- label
- unit
additionalProperties: false
required:
- metrics
- biomarkers
additionalProperties: false
required:
- medicationId
- medicationName
- eligible
- startsOn
- resolution
- windowDays
- minWeeksPerSide
- markers
- targets
- adherence
- overrideOptions
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SetMedicationEfficacyTargetResponse:
type: object
properties:
data:
type: object
properties:
ok:
type: boolean
cleared:
type: boolean
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ListScheduleRevisionsResponse:
type: object
properties:
@@ -0,0 +1,49 @@
-- Medication efficacy tracking ("Wirkung"): persist ONLY the user's explicit
-- target override.
--
-- `medication_efficacy_targets` — a lean child of `medications`. The
-- drug-class → outcome resolution (WHO ATC-prefix → whole-word name
-- inference) is computed each read from the closed in-repo lookup table, so
-- nothing about a class→outcome association is denormalised (it cannot drift
-- as the map improves). The ONE thing that must survive is the user's own
-- pick when they retarget a medication or choose a target for a med the
-- resolver could not classify — that is this table.
--
-- Exactly one of `measurement_type` (a first-class metric series) /
-- `biomarker_id` (a lab analyte) is set per row; `primary` marks the
-- storyline-leading target when several are pinned. `medication_id`
-- cascades (the override dies with the medication); `biomarker_id` sets
-- null (a deleted biomarker leaves an orphan row the resolver treats as no
-- override). `user_id` is never a column here — ownership is narrowed
-- through the parent medication, never a body field.
--
-- Additive; applies clean on a 0231 database and on a fresh database as part
-- of the ordered migration chain.
CREATE TABLE "medication_efficacy_targets" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"measurement_type" "measurement_type",
"biomarker_id" TEXT,
"primary" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "medication_efficacy_targets_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "medication_efficacy_targets_medication_id_idx"
ON "medication_efficacy_targets" ("medication_id");
CREATE INDEX "medication_efficacy_targets_biomarker_id_idx"
ON "medication_efficacy_targets" ("biomarker_id");
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_biomarker_id_fkey"
FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers" ("id")
ON DELETE SET NULL ON UPDATE CASCADE;
+41 -2
View File
@@ -2244,6 +2244,11 @@ model Medication {
/// single-pause marker the reminder/today surfaces read; the era table
/// is the additive durable record the rate paths need.
pauseEras MedicationPauseEra[]
/// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows
/// exist ONLY when the user explicitly overrides the derived (ATC / name)
/// target resolution; absence means the resolver derives the target each
/// read. Nothing about a class→outcome association is denormalised here.
efficacyTargets MedicationEfficacyTarget[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2252,6 +2257,37 @@ model Medication {
@@map("medications")
}
/// v1.28 — the user's explicit override of a medication's efficacy target,
/// the ONE thing the "Wirkung" view persists. Class→outcome resolution
/// (ATC-prefix → name inference) is computed each read from the closed
/// `med-target-map` table, so nothing drifts as that map improves; the only
/// durable state is the user's own pick when they retarget a medication or
/// choose a target for a med the resolver could not classify. Exactly one of
/// `measurementType` (a first-class metric series) / `biomarkerId` (a lab
/// analyte) is set per row. Cascade-deletes with the medication.
model MedicationEfficacyTarget {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Metric target — one of the closed `MeasurementType` series. NULL when
/// this row targets a lab analyte instead.
measurementType MeasurementType? @map("measurement_type")
/// Lab target — a user-scoped Biomarker. NULL when this row targets a
/// metric series instead. SetNull on biomarker delete so the row survives
/// as an orphan the resolver then treats as "no override".
biomarkerId String? @map("biomarker_id")
/// The storyline-leading target when several are pinned. Default true.
primary Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
@@index([medicationId])
@@index([biomarkerId])
@@map("medication_efficacy_targets")
}
model MedicationSchedule {
id String @id @default(cuid())
medicationId String @map("medication_id")
@@ -4268,8 +4304,11 @@ model Biomarker {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
/// v1.28 — medications the user has pinned to this biomarker as their
/// efficacy target. SetNull on delete keeps the override rows as orphans.
efficacyTargets MedicationEfficacyTarget[]
@@unique([userId, name])
@@index([userId])
@@ -0,0 +1,42 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* GET /api/medications/[id]/efficacy — the resolved, server-authoritative
* efficacy DTO for one medication: target(s), the target's series with
* start/dose/pause markers, the before/after-start comparison, the adherence
* lane, the optional level-shift note, the data-floor state and the retarget
* options. Cookie- or Bearer-authenticated (the iOS client reads it). The DTO
* is strictly descriptive — no verdict / score field by construction.
*
* The builder's `findFirst({ where: { id, userId } })` is the ownership gate;
* a missing or foreign id yields the sealed "Medication not found" 404, the
* same shape `assertMedicationOwnership` mints.
*/
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const rl = await checkRateLimit(
`medication-efficacy:${user.id}`,
60,
60_000,
);
if (!rl.allowed) {
return apiError("Too many efficacy requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const dto = await buildMedicationEfficacy(user.id, id, userTz);
if (!dto) {
return apiError("Medication not found", 404);
}
return apiSuccess(dto);
},
);
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { efficacyTargetOverrideSchema } from "@/lib/validations/medication-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* PUT /api/medications/[id]/efficacy/target — set or clear the user's explicit
* efficacy-target override for a medication. `clear:true` removes the override
* rows so the resolver reverts to the derived (ATC / name) target; otherwise
* exactly one of `measurementType` / `biomarkerId` pins the new target. The
* override is the ONLY thing the "Wirkung" view persists — everything else is
* derived each read.
*
* `userId` is never a body field: ownership is narrowed through the parent
* medication (and the biomarker, when a lab target is pinned). The write
* builds its `data` object field-by-field (no mass assignment).
*/
export const PUT = apiHandler(
async (request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = efficacyTargetOverrideSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { clear, measurementType, biomarkerId, primary } = parsed.data;
// Clearing reverts to the derived resolution — drop every override row.
if (clear) {
await prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
});
annotate({
action: {
name: "medication.efficacy.target.clear",
entity_type: "medication",
entity_id: id,
},
meta: {},
});
return apiSuccess({ cleared: true });
}
// A lab target must reference a biomarker the caller owns.
if (biomarkerId) {
const biomarker = await prisma.biomarker.findFirst({
where: { id: biomarkerId, userId: user.id },
select: { id: true },
});
if (!biomarker) {
return apiError("Biomarker not found", 404);
}
}
// Replace the override with the single pinned target (field-by-field data).
await prisma.$transaction([
prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
}),
prisma.medicationEfficacyTarget.create({
data: {
medicationId: id,
measurementType: measurementType ?? null,
biomarkerId: biomarkerId ?? null,
primary: primary ?? true,
},
select: { id: true },
}),
]);
annotate({
action: {
name: "medication.efficacy.target.set",
entity_type: "medication",
entity_id: id,
},
meta: { kind: measurementType ? "metric" : "lab" },
});
return apiSuccess({ ok: true });
},
);
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { beforeAfterFromSeries } from "@/lib/medications/efficacy/build-efficacy";
const DAY = 24 * 60 * 60 * 1000;
const pivot = Date.parse("2026-03-01T12:00:00Z");
/** Build a run of daily points at a fixed value ending `endOffsetDays` from pivot. */
function run(
value: number,
count: number,
startOffsetDays: number,
): { at: number; value: number }[] {
return Array.from({ length: count }, (_, i) => ({
at: pivot + (startOffsetDays + i) * DAY,
value,
}));
}
describe("beforeAfterFromSeries — honest before/after card", () => {
it("returns no_start when there is no pivot", () => {
expect(beforeAfterFromSeries(run(120, 20, -10), null)).toEqual({
present: false,
reason: "no_start",
});
});
it("flags insufficient_before below the per-side floor", () => {
// Only 2 readings before the pivot (floor is 5).
const before = run(150, 2, -3);
const after = run(132, 10, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_before");
});
it("flags insufficient_after below the per-side floor", () => {
const before = run(150, 10, -20);
const after = run(132, 2, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_after");
});
it("computes a neutral before/after delta when both sides clear the floor", () => {
const before = run(150, 8, -20); // within the 56-day before window
const after = run(132, 8, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(true);
expect(r.before?.mean).toBe(150);
expect(r.after?.mean).toBe(132);
expect(r.delta?.mean).toBe(-18);
expect(r.delta?.pct).toBe(-12);
});
it("excludes readings older than the 56-day before window", () => {
// 8 readings but far in the past (beyond the 56-day reach): the before
// side falls under the floor, so no fabricated delta.
const before = run(150, 8, -120);
const after = run(132, 8, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_before");
});
});
@@ -0,0 +1,608 @@
/**
* v1.28 — server-authoritative medication-efficacy builder.
*
* Composes ONE resolved DTO relating a medication to the outcome metric(s) /
* lab(s) its class is prescribed to move, over the span around its start:
*
* target resolution (override → ATC-prefix → name, `med-target-map`)
* → the target's DAY-mean series (rollup tier) or lab trajectory
* → a before / after-start comparison over the SAME series
* → an adherence lane from `dailyComplianceRatesFromLedger` (never recomputed)
* → an optional conservative level-shift note (`detectChangepoints`)
*
* Strictly descriptive. There is NO verdict / score / "working" field on the
* DTO by construction — the view can only render numbers + neutral connective
* phrasing. Honest empty states below the data floor (need N readings before
* AND after the start). Mirrors the `adherence-storyline` safety posture:
* association-only, never causal, never dose advice. iOS renders the DTO; it
* never recomputes a delta or a mean (parity rule).
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import {
resolveRichMetric,
getLabHistory,
detectChangepoints,
} from "@/lib/mcp/rich-reads";
import {
resolveMedicationTargets,
type MedTarget,
type MedTargetTier,
} from "@/lib/medications/med-target-map";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Weeks of target data required on EACH side of the start for the delta. */
const MIN_WEEKS_PER_SIDE = 4;
/** Distinct readings required on each side before a before/after delta shows. */
const MIN_READINGS_PER_SIDE = 5;
/** The before-window the comparison reaches back over from the start. */
const BEFORE_WINDOW_DAYS = 56;
/** Series ceiling — the rollup tier caps timelines at one year. */
const MAX_WINDOW_DAYS = 365;
/** A detected level shift within this many days of the start reads "near". */
const CHANGEPOINT_NEAR_START_DAYS = 21;
export interface EfficacySeriesPoint {
/** ISO date (YYYY-MM-DD for a metric day-bucket, full instant for a lab). */
t: string;
value: number;
/** Lab-only per-point placement against the reference range. */
status?: "in-range" | "below" | "above" | "unknown";
}
export interface EfficacyBeforeAfter {
present: boolean;
/** Why the delta is absent — mirrors the tools' honest `{present:false}`. */
reason?: "insufficient_before" | "insufficient_after" | "no_start" | "no_data";
before?: { mean: number; count: number; from: string; to: string };
after?: { mean: number; count: number; from: string; to: string };
/** after − before; `pct` relative to before, null when before is 0. */
delta?: { mean: number; pct: number | null } | null;
}
export interface EfficacyLevelShift {
present: boolean;
/** Bucket-start ISO of the detected shift, when one fired. */
at?: string;
/** True when the shift sits within tolerance of the start (coincidence). */
nearStart?: boolean;
}
export interface EfficacyTargetView {
kind: "metric" | "lab";
/** MeasurementType (metric) or the analyte needle (lab). */
key: string;
label: string;
unit: string | null;
primary: boolean;
referenceBand: { low: number; high: number } | null;
series: EfficacySeriesPoint[];
beforeAfter: EfficacyBeforeAfter;
/** Metric targets only; null for lab targets (changepoint reads metrics). */
levelShift: EfficacyLevelShift | null;
}
export interface EfficacyMarkers {
/** The pivot instant (ISO date) — `startsOn`, else the first-reading fallback. */
start: string | null;
startSource: "startsOn" | "firstReading" | null;
doseChanges: { at: string; label: string }[];
pauses: { from: string; to: string | null }[];
}
export interface EfficacyAdherencePoint {
date: string;
rate: number;
taken: number;
missed: number;
}
export interface MedicationEfficacyDTO {
medicationId: string;
medicationName: string;
/** False → the "Wirkung" tab is hidden (oneShot / no target & no override). */
eligible: boolean;
/** Why the view is not eligible, when it is not. */
reason?: "one_shot" | "no_target";
startsOn: string | null;
resolution: {
tier: MedTargetTier | "override" | "none";
cls: string | null;
};
windowDays: number;
minWeeksPerSide: number;
markers: EfficacyMarkers;
targets: EfficacyTargetView[];
adherence: EfficacyAdherencePoint[];
/** The "not right? pick another" chooser source (user's own signals). */
overrideOptions: {
metrics: { key: string; label: string }[];
biomarkers: { id: string; label: string; unit: string }[];
};
}
/** Curated retarget metrics the fallback chooser offers (client localises). */
const OVERRIDE_METRIC_OPTIONS: { key: MeasurementType; label: string }[] = [
{ key: "BLOOD_PRESSURE_SYS", label: "Systolic blood pressure" },
{ key: "BLOOD_PRESSURE_DIA", label: "Diastolic blood pressure" },
{ key: "BLOOD_GLUCOSE", label: "Blood glucose" },
{ key: "WEIGHT", label: "Weight" },
{ key: "PULSE", label: "Pulse" },
{ key: "BODY_MASS_INDEX", label: "Body-mass index" },
];
/** Fixed reference bands for the metric targets `resolveRichMetric` omits. */
const METRIC_META_FALLBACK: Partial<
Record<MeasurementType, { label: string; unit: string; band: { low: number; high: number } | null }>
> = {
BLOOD_PRESSURE_SYS: {
label: "Systolic blood pressure",
unit: "mmHg",
band: { low: 90, high: 120 },
},
BLOOD_PRESSURE_DIA: {
label: "Diastolic blood pressure",
unit: "mmHg",
band: { low: 60, high: 80 },
},
};
function resolveMetricMeta(mt: MeasurementType): {
label: string;
unit: string | null;
band: { low: number; high: number } | null;
} {
const rich = resolveRichMetric(mt);
if (rich) return { label: rich.label, unit: rich.unit, band: rich.band };
const fb = METRIC_META_FALLBACK[mt];
if (fb) return fb;
return { label: mt, unit: null, band: null };
}
const dayToInstant = (day: string): number =>
new Date(`${day}T12:00:00Z`).getTime();
function meanOf(xs: number[]): number {
if (xs.length === 0) return 0;
return xs.reduce((s, x) => s + x, 0) / xs.length;
}
const round1 = (n: number): number => Math.round(n * 10) / 10;
/**
* Split a chronological value+instant series into before/after the pivot and
* emit the honest before/after card. Below the per-side floor the card carries
* `{present:false}` with the reason — never a fabricated delta.
*/
export function beforeAfterFromSeries(
points: { at: number; value: number }[],
pivotMs: number | null,
): EfficacyBeforeAfter {
if (pivotMs === null) return { present: false, reason: "no_start" };
const beforeFloor = pivotMs - BEFORE_WINDOW_DAYS * DAY_MS;
const before = points.filter((p) => p.at >= beforeFloor && p.at < pivotMs);
const after = points.filter((p) => p.at >= pivotMs);
if (before.length < MIN_READINGS_PER_SIDE) {
return { present: false, reason: "insufficient_before" };
}
if (after.length < MIN_READINGS_PER_SIDE) {
return { present: false, reason: "insufficient_after" };
}
const beforeMean = meanOf(before.map((p) => p.value));
const afterMean = meanOf(after.map((p) => p.value));
const diff = afterMean - beforeMean;
return {
present: true,
before: {
mean: round1(beforeMean),
count: before.length,
from: new Date(before[0].at).toISOString(),
to: new Date(before[before.length - 1].at).toISOString(),
},
after: {
mean: round1(afterMean),
count: after.length,
from: new Date(after[0].at).toISOString(),
to: new Date(after[after.length - 1].at).toISOString(),
},
delta: {
mean: round1(diff),
pct: beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null,
},
};
}
/** Build the metric-target view (day-mean series + before/after + changepoint). */
async function buildMetricTarget(
userId: string,
mt: MeasurementType,
primary: boolean,
pivotMs: number | null,
windowDays: number,
now: Date,
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
): Promise<EfficacyTargetView> {
const meta = resolveMetricMeta(mt);
const { points } = await readDayMeanSeries(userId, mt, windowDays, now, coverage);
const series: EfficacySeriesPoint[] = points.map((p) => ({
t: p.day,
value: round1(p.mean),
}));
const straddle = points.map((p) => ({ at: dayToInstant(p.day), value: p.mean }));
// Level-shift note — metric targets only, and only when the metric resolves
// through the rich-read resolver (blood pressure is multi-series and does
// not, so it carries no changepoint note by construction).
let levelShift: EfficacyLevelShift | null = null;
if (resolveRichMetric(mt)) {
const cp = await detectChangepoints(userId, { metric: mt, window: "lastYear" });
if (cp.present && cp.changepoints && cp.changepoints.length > 0 && pivotMs !== null) {
const nearest = cp.changepoints
.map((c) => ({ at: c.at, dist: Math.abs(Date.parse(c.at) - pivotMs) }))
.sort((a, b) => a.dist - b.dist)[0];
levelShift = {
present: true,
at: nearest.at,
nearStart: nearest.dist <= CHANGEPOINT_NEAR_START_DAYS * DAY_MS,
};
} else {
levelShift = { present: false };
}
}
return {
kind: "metric",
key: mt,
label: meta.label,
unit: meta.unit,
primary,
referenceBand: meta.band,
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift,
};
}
/** Build the lab-target view (analyte trajectory + before/after over readings). */
async function buildLabTarget(
userId: string,
analyte: string,
label: string,
primary: boolean,
pivotMs: number | null,
): Promise<EfficacyTargetView> {
const hist = await getLabHistory(userId, { analyte, limit: 50 });
const readings = (hist.present ? hist.readings ?? [] : [])
.filter((r) => r.value !== null)
.slice()
// getLabHistory returns newest-first; the view + before/after want oldest-first.
.sort((a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt));
const unit = readings[0]?.unit ?? null;
const withBounds = readings.find(
(r) => r.referenceLow !== null || r.referenceHigh !== null,
);
const referenceBand =
withBounds && withBounds.referenceLow !== null && withBounds.referenceHigh !== null
? { low: withBounds.referenceLow, high: withBounds.referenceHigh }
: null;
const series: EfficacySeriesPoint[] = readings.map((r) => ({
t: r.takenAt,
value: r.value as number,
status: r.rangeStatus,
}));
const straddle = readings.map((r) => ({
at: Date.parse(r.takenAt),
value: r.value as number,
}));
return {
kind: "lab",
key: analyte,
label,
unit,
primary,
referenceBand,
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift: null,
};
}
/**
* Resolve the effective targets for a medication: the user's persisted
* override wins (tier "override"); otherwise the derived ATC/name resolution.
* Returns the tier + class provenance for the DTO alongside the target list.
*/
async function resolveEffectiveTargets(med: {
id: string;
name: string;
treatmentClass: string;
atcCode: string | null;
}): Promise<{
tier: MedTargetTier | "override" | "none";
cls: string | null;
targets: MedTarget[];
}> {
const overrides = await prisma.medicationEfficacyTarget.findMany({
where: { medicationId: med.id },
orderBy: [{ primary: "desc" }, { createdAt: "asc" }],
select: {
measurementType: true,
primary: true,
biomarker: { select: { name: true } },
},
});
const overrideTargets: MedTarget[] = [];
for (const row of overrides) {
if (row.measurementType) {
overrideTargets.push({
kind: "metric",
measurementType: row.measurementType,
});
} else if (row.biomarker) {
overrideTargets.push({
kind: "lab",
analyte: row.biomarker.name,
label: row.biomarker.name,
});
}
}
if (overrideTargets.length > 0) {
return { tier: "override", cls: null, targets: overrideTargets };
}
const derived = resolveMedicationTargets({
name: med.name,
treatmentClass: med.treatmentClass,
atcCode: med.atcCode,
});
if (derived) {
return { tier: derived.tier, cls: derived.cls, targets: [...derived.targets] };
}
return { tier: "none", cls: null, targets: [] };
}
/**
* Build the full efficacy DTO for one medication the caller owns. The caller
* (route / server component) is responsible for the ownership gate; this
* builder reads only rows scoped to `userId` / the owned medication.
*/
export async function buildMedicationEfficacy(
userId: string,
medicationId: string,
timezone: string,
now: Date = new Date(),
): Promise<MedicationEfficacyDTO | null> {
const med = await prisma.medication.findFirst({
where: { id: medicationId, userId },
include: {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) return null;
const startsOnIso = med.startsOn ? med.startsOn.toISOString() : null;
const baseDto = {
medicationId: med.id,
medicationName: med.name,
startsOn: startsOnIso,
minWeeksPerSide: MIN_WEEKS_PER_SIDE,
};
// One-shot meds have a single administration and no trend to relate.
if (med.oneShot) {
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
meta: { eligible: false, reason: "one_shot" },
});
return {
...baseDto,
eligible: false,
reason: "one_shot",
resolution: { tier: "none", cls: null },
windowDays: 0,
markers: { start: null, startSource: null, doseChanges: [], pauses: [] },
targets: [],
adherence: [],
overrideOptions: { metrics: [], biomarkers: [] },
};
}
const resolved = await resolveEffectiveTargets({
id: med.id,
name: med.name,
treatmentClass: med.treatmentClass,
atcCode: med.atcCode ?? null,
});
// The user's own retarget options (fallback chooser) — always attached.
const biomarkerRows = await prisma.biomarker.findMany({
where: { userId, hidden: false },
orderBy: { name: "asc" },
select: { id: true, name: true, unit: true },
});
const overrideOptions = {
metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({ key: m.key, label: m.label })),
biomarkers: biomarkerRows.map((b) => ({
id: b.id,
label: b.name,
unit: b.unit,
})),
};
if (resolved.targets.length === 0) {
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
meta: { eligible: false, reason: "no_target" },
});
return {
...baseDto,
eligible: false,
reason: "no_target",
resolution: { tier: "none", cls: null },
windowDays: 0,
markers: { start: null, startSource: null, doseChanges: [], pauses: [] },
targets: [],
adherence: [],
overrideOptions,
};
}
// Pivot: `startsOn` when present; else the first reading of the primary
// target (captioned as a fallback); else null (the before/after card then
// renders the honest "set a start date" empty state).
const coverage = await probeRollupCoverage(userId);
let pivotMs = med.startsOn ? med.startsOn.getTime() : null;
let startSource: "startsOn" | "firstReading" | null = med.startsOn
? "startsOn"
: null;
// Establish a window that spans well before the pivot through today.
const spanFromPivot = pivotMs !== null ? now.getTime() - pivotMs : 0;
const windowDays = Math.min(
MAX_WINDOW_DAYS,
Math.max(90, Math.ceil((spanFromPivot + BEFORE_WINDOW_DAYS * DAY_MS) / DAY_MS)),
);
// Build each target view. Fallback pivot uses the primary metric's first day.
const targets: EfficacyTargetView[] = [];
for (let i = 0; i < resolved.targets.length; i++) {
const target = resolved.targets[i];
const primary = i === 0;
if (target.kind === "metric") {
const view = await buildMetricTarget(
userId,
target.measurementType,
primary,
pivotMs,
windowDays,
now,
coverage,
);
// Legacy no-start med: fall back to the primary metric's first reading.
if (primary && pivotMs === null && view.series.length > 0) {
pivotMs = dayToInstant(view.series[0].t);
startSource = "firstReading";
view.beforeAfter = beforeAfterFromSeries(
view.series.map((p) => ({ at: dayToInstant(p.t), value: p.value })),
pivotMs,
);
}
targets.push(view);
} else {
const view = await buildLabTarget(
userId,
target.analyte,
target.label,
primary,
pivotMs,
);
if (primary && pivotMs === null && view.series.length > 0) {
pivotMs = Date.parse(view.series[0].t);
startSource = "firstReading";
view.beforeAfter = beforeAfterFromSeries(
view.series.map((p) => ({ at: Date.parse(p.t), value: p.value })),
pivotMs,
);
}
targets.push(view);
}
}
// Adherence lane — the cadence-aware per-day rate from the SAME ledger the
// compliance surfaces read; never recomputed here.
const fetchFrom = new Date(
Math.max(med.createdAt.getTime(), now.getTime() - windowDays * DAY_MS),
);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
deletedAt: null,
scheduledFor: { gte: fetchFrom },
},
orderBy: { scheduledFor: "desc" },
select: {
takenAt: true,
skipped: true,
scheduledFor: true,
autoMissed: true,
attributionSource: true,
},
});
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
timezone,
);
const bundle = buildMedicationComplianceBundle(events, med.schedules, ctx, now);
const windowFloorMs = pivotMs !== null ? pivotMs - BEFORE_WINDOW_DAYS * DAY_MS : fetchFrom.getTime();
const adherence: EfficacyAdherencePoint[] = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
timezone,
)
.filter((d) => d.date.getTime() >= windowFloorMs)
.map((d) => ({
date: d.date.toISOString(),
rate: d.rate,
taken: d.taken,
missed: d.missed,
}));
// Markers — dose changes + pause bands intersecting the window.
const doseChanges = med.doseChanges
.filter((dc) => dc.effectiveFrom.getTime() >= windowFloorMs)
.map((dc) => ({
at: dc.effectiveFrom.toISOString(),
label: `${dc.doseValue} ${dc.doseUnit}`.trim(),
}));
const pauses = med.pauseEras
.filter((p) => (p.resumedAt?.getTime() ?? now.getTime()) >= windowFloorMs)
.map((p) => ({
from: p.pausedAt.toISOString(),
to: p.resumedAt ? p.resumedAt.toISOString() : null,
}));
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
meta: {
eligible: true,
tier: resolved.tier,
cls: resolved.cls,
targetCount: targets.length,
startSource: startSource ?? "none",
windowDays,
},
});
return {
...baseDto,
eligible: true,
resolution: { tier: resolved.tier, cls: resolved.cls },
windowDays,
markers: {
start: pivotMs !== null ? new Date(pivotMs).toISOString() : null,
startSource,
doseChanges,
pauses,
},
targets,
adherence,
overrideOptions,
};
}
@@ -17,7 +17,23 @@ import {
scheduleRevisionCreateSchema,
scheduleRevisionUpdateSchema,
} from "@/lib/validations/schedule-revision";
import {
efficacyTargetOverrideSchema,
medicationEfficacyResponseSchema,
} from "@/lib/validations/medication-efficacy";
import { dataEnvelope, errorEnvelope, stdResponses } from "../shared";
efficacyTargetOverrideSchema.meta({
id: "SetMedicationEfficacyTargetRequest",
description:
"Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target).",
});
medicationEfficacyResponseSchema.meta({
id: "MedicationEfficacyResponse",
description:
"Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim.",
});
import {
medicationListEntry,
medicationDetailEntry,
@@ -539,6 +555,73 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/{id}/efficacy": {
get: {
tags: ["Medications"],
summary: "Efficacy view for a medication (\"Wirkung\")",
description:
"Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot or no-target medication whose tab is hidden.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
"200": {
description: "Efficacy view.",
content: {
"application/json": {
schema: dataEnvelope(
medicationEfficacyResponseSchema,
"GetMedicationEfficacyResponse",
),
},
},
},
"404": {
description: "Medication not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/medications/{id}/efficacy/target": {
put: {
tags: ["Medications"],
summary: "Set or clear the efficacy-target override",
description:
"Persists the user's explicit efficacy target for a medication (the only thing the view stores; everything else is derived each read). Pin exactly one of `measurementType` / `biomarkerId`, or pass `clear:true` to revert to the derived target.",
requestParams: {
path: z.object({ id: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": { schema: efficacyTargetOverrideSchema },
},
},
responses: {
"200": {
description: "Override set or cleared.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ok: z.boolean().optional(),
cleared: z.boolean().optional(),
}),
"SetMedicationEfficacyTargetResponse",
),
},
},
},
"404": {
description: "Medication or biomarker not found.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/medications/{id}/schedule-revisions": {
get: {
tags: ["Medications"],
+137
View File
@@ -0,0 +1,137 @@
/**
* v1.28 — request + response contracts for the medication-efficacy view
* ("Wirkung"). The response schema mirrors the DTO the server builder emits
* (`src/lib/medications/efficacy/build-efficacy.ts`) so the OpenAPI wire
* contract the iOS client consumes stays single-source. Strictly descriptive
* by construction — there is no verdict / score / assessment field on the DTO.
*/
import { z } from "zod/v4";
import { measurementTypeEnum } from "@/lib/validations/measurement";
/**
* Set or clear the user's explicit efficacy-target override for a medication.
* When `clear` is true the override rows are removed and the resolver reverts
* to the derived (ATC / name) target. Otherwise exactly ONE of
* `measurementType` (a metric series) / `biomarkerId` (a lab analyte) sets the
* new target. `userId` is never a body field — it is narrowed from the parent
* medication's ownership.
*/
export const efficacyTargetOverrideSchema = z
.object({
clear: z.boolean().optional(),
measurementType: measurementTypeEnum.optional(),
biomarkerId: z.string().trim().min(1).max(64).optional(),
primary: z.boolean().optional(),
})
.refine(
(v) =>
v.clear === true ||
(v.measurementType !== undefined) !== (v.biomarkerId !== undefined),
{
message:
"Set exactly one of measurementType / biomarkerId, or pass clear:true to revert to the derived target.",
path: ["measurementType"],
},
);
export type EfficacyTargetOverrideInput = z.infer<
typeof efficacyTargetOverrideSchema
>;
// ── Response DTO schema (OpenAPI mirror of the builder output) ──────────
const seriesPointSchema = z.object({
t: z.string(),
value: z.number(),
status: z.enum(["in-range", "below", "above", "unknown"]).optional(),
});
const beforeAfterSchema = z.object({
present: z.boolean(),
reason: z
.enum(["insufficient_before", "insufficient_after", "no_start", "no_data"])
.optional(),
before: z
.object({
mean: z.number(),
count: z.number().int(),
from: z.string(),
to: z.string(),
})
.optional(),
after: z
.object({
mean: z.number(),
count: z.number().int(),
from: z.string(),
to: z.string(),
})
.optional(),
delta: z
.object({ mean: z.number(), pct: z.number().nullable() })
.nullable()
.optional(),
});
const levelShiftSchema = z
.object({
present: z.boolean(),
at: z.string().optional(),
nearStart: z.boolean().optional(),
})
.nullable();
const targetViewSchema = z.object({
kind: z.enum(["metric", "lab"]),
key: z.string(),
label: z.string(),
unit: z.string().nullable(),
primary: z.boolean(),
referenceBand: z
.object({ low: z.number(), high: z.number() })
.nullable(),
series: z.array(seriesPointSchema),
beforeAfter: beforeAfterSchema,
levelShift: levelShiftSchema,
});
export const medicationEfficacyResponseSchema = z.object({
medicationId: z.string(),
medicationName: z.string(),
eligible: z.boolean(),
reason: z.enum(["one_shot", "no_target"]).optional(),
startsOn: z.string().nullable(),
resolution: z.object({
tier: z.enum(["override", "atc", "name", "none"]),
cls: z.string().nullable(),
}),
windowDays: z.number().int(),
minWeeksPerSide: z.number().int(),
markers: z.object({
start: z.string().nullable(),
startSource: z.enum(["startsOn", "firstReading"]).nullable(),
doseChanges: z.array(z.object({ at: z.string(), label: z.string() })),
pauses: z.array(
z.object({ from: z.string(), to: z.string().nullable() }),
),
}),
targets: z.array(targetViewSchema),
adherence: z.array(
z.object({
date: z.string(),
rate: z.number(),
taken: z.number().int(),
missed: z.number().int(),
}),
),
overrideOptions: z.object({
metrics: z.array(z.object({ key: z.string(), label: z.string() })),
biomarkers: z.array(
z.object({ id: z.string(), label: z.string(), unit: z.string() }),
),
}),
});
export type MedicationEfficacyResponse = z.infer<
typeof medicationEfficacyResponseSchema
>;