mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
f3762cf0f9
commit
725f69097f
9 files changed
+1629
-2
No files matched your search
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user