diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts deleted file mode 100644 index 66106b94c..000000000 --- a/src/lib/openapi/routes/insights.ts +++ /dev/null @@ -1,1059 +0,0 @@ -/** - * OpenAPI route table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations. - * - * Part of the OpenAPI route table; aggregated in `./index.ts`. - * Schemas come from `src/lib/validations/*` where shared with the - * runtime request parsing, so the wire contract stays single-source. - */ -import { z } from "zod/v4"; -import type { ZodOpenApiObject } from "zod-openapi"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; -import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; -import { - DERIVED_METRIC_IDS, - VITALS_BASELINE_TYPES, -} from "@/lib/insights/derived/registry"; -import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; -import { consentRequiredResponse, dataEnvelope, stdResponses } from "./shared"; - -const insightsComprehensiveResponse = z - .object({ - summary: z.string(), - recommendations: z.array(z.record(z.string(), z.unknown())), - citations: z.array(z.record(z.string(), z.unknown())), - warnings: z.array(z.record(z.string(), z.unknown())), - dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), - trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), - storyboardAnnotations: z - .array(z.record(z.string(), z.unknown())) - .optional(), - metricSource: z.record(z.string(), z.unknown()).optional(), - revalidating: z - .boolean() - .optional() - .describe( - "True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.", - ), - }) - .meta({ - id: "InsightsComprehensiveResponse", - description: - "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", - }); - -// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is -// derived from the same registry the route validates against, so the -// spec, the route, and the cache scope cannot drift. The seven -// specialised metrics (weight / blood-pressure / pulse / bmi / mood / -// medication-compliance) keep their own routes and are NOT accepted here. -const metricStatusQuery = z - .object({ - metric: z - .enum(METRIC_STATUS_IDS as [string, ...string[]]) - .describe( - "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", - ), - locale: z - .enum(["de", "en"]) - .optional() - .describe("Optional UI-locale override; defaults to the session locale."), - }) - .meta({ id: "MetricStatusQuery" }); - -const metricStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", - ), - text: z - .string() - .nullable() - .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", - ), - cached: z - .boolean() - .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", - ), - insufficient: z - .boolean() - .optional() - .describe( - "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", - ), - }) - .meta({ - id: "MetricStatusResponse", - description: - "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", - }); - -// v1.10.0 — generic derived-wellness-metric route. The query enum is -// derived from the same registry the route validates against, so spec + -// route + cache scope cannot drift. `type` sub-targets the single vital -// a baseline metric (VITALS_BASELINE) bands over. -const derivedMetricQuery = z - .object({ - metric: z - .enum(DERIVED_METRIC_IDS as [string, ...string[]]) - .describe( - "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", - ), - type: z - .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) - .optional() - .describe( - "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", - ), - }) - .meta({ id: "DerivedMetricQuery" }); - -const derivedCoverage = z - .object({ - requiredInputs: z - .number() - .int() - .describe("Inputs the metric wants (its full input set)."), - presentInputs: z - .number() - .int() - .describe("Inputs actually present in the user's data."), - historyDays: z - .number() - .int() - .describe( - "Distinct days of history backing the value (the gating floor).", - ), - missing: z - .array(z.string()) - .describe( - "Named inputs still missing — drives the 'track N more' nudge.", - ), - }) - .meta({ id: "DerivedCoverage" }); - -const derivedConfidence = z - .object({ - score: z - .number() - .describe( - "0..100 confidence; feeds the shared coverage meter unchanged.", - ), - band: z - .enum(["high", "medium", "low", "draft"]) - .describe("Confidence band the meter renders."), - }) - .meta({ id: "DerivedConfidence" }); - -const derivedProvenance = z - .object({ - inputs: z - .array(z.string()) - .describe("Named inputs that actually backed the value."), - source: z - .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) - .describe( - "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", - ), - windowDays: z - .number() - .int() - .describe("Trailing window the value summarises, in days."), - computedAt: z.iso - .datetime({ offset: true }) - .describe("Compute time (for cache-staleness + the 'as of' chip)."), - }) - .meta({ id: "DerivedProvenance" }); - -// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field -// on the derived response; the iOS field-name contract is LOCKED. -const derivedAssessment = z - .object({ - text: z - .string() - .describe( - "Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.", - ), - source: z - .string() - .describe( - "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.", - ), - updatedAt: z.iso - .datetime({ offset: true }) - .describe("When the text was produced / last warmed."), - }) - .meta({ id: "DerivedAssessment" }); - -const derivedMetricResponse = z - .object({ - metric: z - .enum(DERIVED_METRIC_IDS as [string, ...string[]]) - .describe("Echoes the requested derived-metric id (tags the union)."), - status: z - .enum(["ok", "insufficient"]) - .describe( - "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", - ), - value: z - .record(z.string(), z.unknown()) - .nullable() - .describe( - "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", - ), - coverage: derivedCoverage, - confidence: derivedConfidence - .nullable() - .describe("Present when status is 'ok'; null when 'insufficient'."), - provenance: derivedProvenance, - reason: z - .string() - .nullable() - .describe( - "Why the value could not be produced; null when status is 'ok'.", - ), - assessment: derivedAssessment - .nullable() - .describe( - "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.", - ), - }) - .meta({ - id: "DerivedMetricResponse", - description: - "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", - }); - -// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one -// or more `metric` / `metric:type` tokens; the route fans out server-side -// under a bounded limiter with the profile loaded once, collapsing the -// dashboard's cold-mount fan-out of N single-metric requests into one. -const derivedBatchQuery = z - .object({ - metrics: z - .string() - .min(1) - .max(1024) - .describe( - "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.", - ), - }) - .meta({ id: "DerivedBatchQuery" }); - -const derivedBatchResponse = z - .object({ - metrics: z - .record(z.string(), derivedMetricResponse) - .describe( - "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", - ), - }) - .meta({ - id: "DerivedBatchResponse", - description: - "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", - }); - -// v1.10.0 — FDR-controlled correlation discovery result. One discovered, -// statistically-defensible behaviour → next-day-outcome pair. -const discoveredCorrelation = z - .object({ - behaviour: z - .string() - .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), - outcome: z - .string() - .describe( - "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.", - ), - n: z - .number() - .int() - .describe("Paired-day count after the day+1 lag join (≥ 20)."), - r: z.number().describe("Pearson r over the lag-joined daily series."), - pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), - qValue: z - .number() - .describe( - "Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).", - ), - interpretation: z - .string() - .describe("Conservative, descriptive interpretation — never causal."), - lagDays: z.number().int().describe("Lag in days applied (1)."), - }) - .meta({ id: "DiscoveredCorrelation" }); - -const correlationDiscoveryResponse = z - .object({ - discovered: z - .array(discoveredCorrelation) - .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), - pairsTested: z - .number() - .int() - .describe("Behaviour × outcome pairs assessed (for the honest footer)."), - fdrQ: z.number().describe("The FDR target the surface used."), - minPairs: z - .number() - .int() - .describe("Minimum paired-day count enforced per pair."), - }) - .meta({ - id: "CorrelationDiscoveryResponse", - description: - "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", - }); - -// The seven specialised `*-status` routes accept an optional locale -// override (the metric is fixed by the route path, unlike the generic -// metric-status route which carries it as a query field). -const insightStatusQuery = z - .object({ - locale: z - .enum(["de", "en"]) - .optional() - .describe("Optional UI-locale override; defaults to the session locale."), - }) - .meta({ id: "InsightStatusQuery" }); - -// Shared response shape for the five text-bearing specialised status -// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as -// the generic metric-status card minus the `insufficient` flag, which is -// metric-status-only. Read-only + stale-while-revalidate. -const insightStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", - ), - text: z - .string() - .nullable() - .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", - ), - cached: z - .boolean() - .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", - ), - }) - .meta({ - id: "InsightStatusResponse", - description: - "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", - }); - -// The medication-compliance route carries a richer envelope than the -// other six: a `summary` narrative plus a per-medication `text` array, -// instead of a single `text` field. -const medicationComplianceStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", - ), - summary: z - .string() - .nullable() - .describe( - "The overall compliance narrative (plain text). Null while a first generation is preparing.", - ), - medications: z - .array( - z - .object({ - medicationId: z - .string() - .describe("The medication this note belongs to."), - text: z - .string() - .describe("Per-medication compliance note (plain text)."), - }) - .meta({ id: "MedicationComplianceStatusItem" }), - ) - .describe( - "Per-medication compliance notes. Empty while preparing or when no medication qualifies.", - ), - cached: z - .boolean() - .describe( - "True when the envelope is served from cache (incl. last-good).", - ), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", - ), - }) - .meta({ - id: "MedicationComplianceStatusResponse", - description: - "Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.", - }); - -const analyticsRangeQuery = z - .object({ - type: measurementTypeEnum.describe( - "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.", - ), - range: z - .enum(ANALYTICS_RANGES) - .describe( - "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.", - ), - }) - .meta({ id: "AnalyticsRangeQuery" }); - -const analyticsWindowAggregate = z - .object({ - count: z.number().int().describe("Reading count composed across buckets."), - min: z.number().nullable().describe("Window minimum; null when empty."), - max: z.number().nullable().describe("Window maximum; null when empty."), - mean: z - .number() - .nullable() - .describe("Count-weighted mean across buckets; null when empty."), - sum: z - .number() - .nullable() - .describe( - "Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.", - ), - }) - .meta({ id: "AnalyticsWindowAggregate" }); - -const analyticsRangeResponse = z - .object({ - range: z - .enum(ANALYTICS_RANGES) - .describe("The range that was read (echoes the request)."), - windowDays: z - .number() - .int() - .describe("Trailing-window length in days for the chosen range."), - granularity: z - .string() - .describe( - "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).", - ), - current: analyticsWindowAggregate.describe( - "Aggregate over the current window `[now-N, now)`.", - ), - previous: analyticsWindowAggregate.describe( - "Aggregate over the previous comparable window `[now-2N, now-N)`.", - ), - delta: z - .number() - .nullable() - .describe( - "`current.mean - previous.mean`; null when either window has no data (never a misleading 0).", - ), - deltaPct: z - .number() - .nullable() - .describe( - "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case.", - ), - }) - .meta({ - id: "AnalyticsRangeResponse", - description: - "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", - }); - -const insightsPregenerateRequest = z.object({}).meta({ - id: "InsightsPregenerateRequest", - description: - "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.", -}); - -const insightsPregenerateResponse = z - .object({ - queued: z - .boolean() - .describe("True when the full warm was accepted and enqueued."), - locale: z - .enum(["de", "en"]) - .describe("The locale the assessments are being warmed in."), - }) - .meta({ - id: "InsightsPregenerateResponse", - description: - "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", - }); - -// v1.7.0 — unified dashboard first-paint snapshot. One GET that -// assembles every above-the-fold tile field in a single round-trip. -// Two-phase shape: `tiles` (fast, always present) + `extras` (thick, -// nullable on a rollup-coverage miss). The nested AI / DataSummary -// blocks are typed loosely (`z.record`) to match the comprehensive -// response style above — the strict shapes live in their own Zod -// modules and the iOS client does not consume this web-only route. -const dataSummaryRecord = z.record(z.string(), z.unknown()); - -// v1.17.0 — server-authoritative glucose clinical panel. Mirrors -// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the -// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced -// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin -// spot-data window is never asserted as a clinical AGP. The iOS client -// renders these numbers verbatim and never re-derives them. -const glucoseClinicalSchema = z - .object({ - stillLearning: z.boolean(), - stillLearningReason: z.string().nullable(), - windowDays: z.number().int(), - actualSpanDays: z.number(), - readingCount: z.number().int(), - meanMgdl: z.number().nullable(), - distribution: z - .object({ - tir: z.number(), - tbrLevel1: z.number(), - tbrLevel2: z.number(), - tarLevel1: z.number(), - tarLevel2: z.number(), - minutesEquivalent: z.object({ - tir: z.number(), - tbrLevel1: z.number(), - tbrLevel2: z.number(), - tarLevel1: z.number(), - tarLevel2: z.number(), - }), - }) - .nullable(), - gmi: z.number().nullable(), - estimatedA1c: z.number().nullable(), - variability: z - .object({ - sd: z.number(), - cv: z.number(), - unstable: z.boolean(), - }) - .nullable(), - advanced: z - .object({ - jIndex: z.number().nullable(), - lbgi: z.number(), - hbgi: z.number(), - }) - .nullable(), - isSpotEstimate: z.boolean(), - }) - .meta({ - id: "GlucoseClinicalMetrics", - description: - "Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.", - }); - -const dashboardSnapshotResponse = z - .object({ - user: z.object({ - username: z.string(), - timezone: z.string(), - heightCm: z.number().nullable(), - dateOfBirth: z.string().nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), - glucoseUnit: z.string().nullable(), - onboardingTourCompleted: z.boolean(), - greetingHour: z.number().int(), - }), - layout: z.record(z.string(), z.unknown()), - // v1.7.0 — full 27-id widget catalogue (16 server-known + 11 - // iOS-only) so a cold-launch first-paint seeds every tile and the - // layout round-trips in one key. Additive alongside the web - // `layout` block, which stays byte-identical. - layoutCatalogue: z - .array( - z.object({ - id: z.string(), - visible: z.boolean(), - order: z.number().int(), - }), - ) - .describe( - "Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client.", - ), - // v1.7.0 — per-chartable-metric latest reading keyed by iOS - // `MetricKind` raw value (e.g. `oxygenSaturation`, - // `heartRateVariability`, `bodyMassIndex`). Derived in-process from - // the slim summaries slice — no extra DB read. - metricStates: z - .record( - z.string(), - z.object({ - value: z.number(), - measuredAt: z.string(), - unit: z.string(), - }), - ) - .describe( - "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.", - ), - tiles: z.object({ - summaries: dataSummaryRecord, - lastSeenByType: z.record(z.string(), z.unknown()), - mood: z.object({ - summary: dataSummaryRecord.nullable(), - entries: z.array( - z.object({ - date: z.string(), - score: z.number(), - samples: z.number().int(), - }), - ), - }), - }), - extras: z - .object({ - bpInTargetPct: z.number().nullable(), - bpInTargetPct7d: z.number().nullable(), - bpInTargetPct30d: z.number().nullable(), - bpInTargetPctAllTime: z.number().nullable(), - bpInTargetPctPriorMonth: z.number().nullable(), - bpInTargetPctPriorYear: z.number().nullable(), - bpInTargetCount90: z.number().int().nullable(), - bpInTargetSpanDays90: z.number().int().nullable(), - glucoseByContext: dataSummaryRecord, - glucoseClinical: glucoseClinicalSchema, - }) - .nullable(), - // Dashboard hero — today's medication block (fast phase, always - // present). Projection-backed tally + earliest next-due across - // active medications. - medsToday: z - .object({ - activeCount: z.number().int(), - scheduledToday: z.number().int(), - takenToday: z.number().int(), - skippedToday: z.number().int(), - nextDueAt: z.string().nullable(), - nextDueOverdue: z.boolean(), - nextDueMedicationName: z.string().nullable(), - }) - .describe( - "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", - ), - // Dashboard hero — health score (warm phase, nullable on a - // rollup-coverage miss). Score + band + delta only; the per-pillar - // component breakdown stays on the analytics route. - healthScore: z - .object({ - score: z.number().int(), - band: z.enum(["green", "yellow", "red"]), - delta: z.number().nullable(), - }) - .nullable() - .describe( - "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", - ), - briefing: z.record(z.string(), z.unknown()).nullable(), - briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), - briefingUpdatedAt: z.string().nullable(), - briefingStale: z - .boolean() - .describe( - "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", - ), - generatedAt: z.string(), - }) - .meta({ - id: "DashboardSnapshotResponse", - description: - "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", - }); - -export const insightsPaths: NonNullable = { - "/api/dashboard/snapshot": { - get: { - tags: ["Dashboard"], - summary: "Unified dashboard first-paint snapshot", - description: - "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", - responses: { - "200": { - description: "Dashboard snapshot.", - content: { - "application/json": { - schema: dataEnvelope( - dashboardSnapshotResponse, - "DashboardSnapshotResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/comprehensive": { - get: { - tags: ["Insights"], - summary: "Comprehensive AI insights bundle", - description: - "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).", - responses: { - "200": { - description: "Insights bundle.", - content: { - "application/json": { - schema: dataEnvelope( - insightsComprehensiveResponse, - "InsightsComprehensiveResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - ...consentRequiredResponse, - }, - }, - }, - "/api/insights/pregenerate": { - post: { - tags: ["Insights"], - summary: "Warm all AI assessments for the calling user", - description: - "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", - requestBody: { - required: false, - content: { - "application/json": { schema: insightsPregenerateRequest }, - }, - }, - responses: { - "200": { - description: - "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", - content: { - "application/json": { - schema: dataEnvelope( - insightsPregenerateResponse, - "InsightsPregenerateResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/analytics/range": { - get: { - tags: ["Analytics"], - summary: "Single-metric period-over-period range delta", - description: - "v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.", - requestParams: { - query: analyticsRangeQuery, - }, - responses: { - "200": { - description: "Current + previous window aggregates and the delta.", - content: { - "application/json": { - schema: dataEnvelope( - analyticsRangeResponse, - "AnalyticsRangeResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/blood-pressure-status": { - get: { - tags: ["Insights"], - summary: "Blood-pressure assessment", - description: - "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "BloodPressureStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/pulse-status": { - get: { - tags: ["Insights"], - summary: "Pulse assessment", - description: - "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "PulseStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/weight-status": { - get: { - tags: ["Insights"], - summary: "Weight assessment", - description: - "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "WeightStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/bmi-status": { - get: { - tags: ["Insights"], - summary: "BMI assessment", - description: - "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "BmiStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/mood-status": { - get: { - tags: ["Insights"], - summary: "Mood assessment", - description: - "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "MoodStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/medication-compliance-status": { - get: { - tags: ["Insights"], - summary: "Medication-compliance assessment", - description: - "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: - "Compliance assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - medicationComplianceStatusResponse, - "MedicationComplianceStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/metric-status": { - get: { - tags: ["Insights"], - summary: "Generic per-HealthKit-metric assessment", - description: - "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", - requestParams: { - query: metricStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - metricStatusResponse, - "MetricStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/derived": { - get: { - tags: ["Insights"], - summary: "Derived wellness metric (compute-once)", - description: - "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", - requestParams: { - query: derivedMetricQuery, - }, - responses: { - "200": { - description: "The flat derived-metric value (ok or insufficient).", - content: { - "application/json": { - schema: dataEnvelope( - derivedMetricResponse, - "DerivedMetricResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/derived/batch": { - get: { - tags: ["Insights"], - summary: "Derived wellness metrics (batched compute-once)", - description: - "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", - requestParams: { - query: derivedBatchQuery, - }, - responses: { - "200": { - description: "The map of derived-metric values, keyed by token.", - content: { - "application/json": { - schema: dataEnvelope( - derivedBatchResponse, - "DerivedBatchResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/correlations": { - get: { - tags: ["Insights"], - summary: "Correlation discovery (FDR-controlled)", - description: - "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", - responses: { - "200": { - description: "The discovered correlations + the tested-pair count.", - content: { - "application/json": { - schema: dataEnvelope( - correlationDiscoveryResponse, - "CorrelationDiscoveryResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, -}; diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts deleted file mode 100644 index 792dda0be..000000000 --- a/src/lib/openapi/routes/medications.ts +++ /dev/null @@ -1,1379 +0,0 @@ -/** - * OpenAPI route table — medications CRUD, intake, cadence, compliance, AI extraction. - * - * Part of the OpenAPI route table; aggregated in `./index.ts`. - * Schemas come from `src/lib/validations/*` where shared with the - * runtime request parsing, so the wire contract stays single-source. - */ -import { z } from "zod/v4"; -import type { ZodOpenApiObject } from "zod-openapi"; -import { - createMedicationSchema, - updateMedicationSchema, - intakeSchema, - createInventoryItemSchema, - updateInventoryItemSchema, - MEDICATION_CATEGORY_VALUES, - MEDICATION_CONTAINER_TYPE_VALUES, - MEDICATION_TREATMENT_CLASS_VALUES, -} from "@/lib/validations/medication"; -import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; -import { - MEDICATION_LIST_VIEWS, - MEDICATION_ORDER_ID_MAX_LENGTH, - MEDICATION_ORDER_MAX_ENTRIES, -} from "@/lib/medication-list-layout"; -import { - scheduleRevisionCreateSchema, - scheduleRevisionUpdateSchema, -} from "@/lib/validations/schedule-revision"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; - -// ── Medications (v1.5 scheduling) ──────────────────────────────────── -// -// The Medication + MedicationSchedule resource shapes documented below -// follow the wire envelope the seven routes registered at the bottom of -// this file emit. `windowStart` / `windowEnd` / `daysOfWeek` / -// `intervalWeeks` are the legacy primitives kept for backwards -// compatibility through the v1.5.x line; `timesOfDay`, `rrule`, -// `rollingIntervalDays`, and `reminderGraceMinutes` are the v1.5 -// first-class primitives the wizard + iOS cadence picker write. The -// XOR between `rrule` and `rollingIntervalDays` is documented at the -// schema description AND enforced by the route + a DB CHECK constraint -// so iOS code-gen surfaces the mutual exclusion. - -const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ - id: "MedicationCategory", - description: - "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", -}); - -const medicationTreatmentClassEnum = z - .enum(MEDICATION_TREATMENT_CLASS_VALUES) - .meta({ - id: "MedicationTreatmentClass", - description: - "Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).", - }); - -const medicationScheduleResource = z - .object({ - id: z.string(), - medicationId: z.string(), - windowStart: z - .string() - .describe( - "Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it.", - ), - windowEnd: z - .string() - .describe( - "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", - ), - label: z.string().nullable(), - dose: z - .string() - .nullable() - .describe( - "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", - ), - daysOfWeek: z - .string() - .nullable() - .describe( - "Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column.", - ), - timesOfDay: z - .array(z.string()) - .describe( - "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row.", - ), - reminderGraceMinutes: z - .number() - .int() - .nullable() - .describe( - "Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span.", - ), - rrule: z - .string() - .nullable() - .describe( - "RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration).", - ), - rollingIntervalDays: z - .number() - .int() - .nullable() - .describe( - "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.**", - ), - scheduleType: z - .enum(["SCHEDULED", "PRN", "CYCLIC"]) - .describe( - "v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", - ), - cyclicOnWeeks: z - .number() - .int() - .nullable() - .describe( - 'v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', - ), - cyclicOffWeeks: z - .number() - .int() - .nullable() - .describe( - 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', - ), - }) - .meta({ - id: "MedicationSchedule", - description: - "Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields.", - }); - -export const medicationResource = z - .object({ - id: z.string(), - name: z.string(), - dose: z.string(), - treatmentClass: medicationTreatmentClassEnum, - dosesPerUnit: z - .number() - .int() - .nullable() - .describe( - "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", - ), - unitsPerDose: z - .number() - .describe( - "v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", - ), - reorderLeadDays: z - .number() - .int() - .nullable() - .describe( - "v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10).", - ), - active: z.boolean(), - notificationsEnabled: z.boolean(), - liveActivityEnabled: z - .boolean() - .describe( - "v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", - ), - criticalAlarmEnabled: z - .boolean() - .describe( - "v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", - ), - atcCode: z - .string() - .nullable() - .describe( - "v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.", - ), - rxNormCode: z - .string() - .nullable() - .describe( - "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", - ), - pausedAt: z.iso.datetime({ offset: true }).nullable(), - snoozedUntil: z.iso.datetime({ offset: true }).nullable(), - nextDueAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", - ), - nextDueOverdue: z - .boolean() - .describe( - "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", - ), - startsOn: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.", - ), - endsOn: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", - ), - oneShot: z - .boolean() - .describe( - "v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.", - ), - asNeeded: z - .boolean() - .describe( - "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.", - ), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - schedules: z.array(medicationScheduleResource), - }) - .meta({ - id: "Medication", - description: - "Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array.", - }); - -const medicationListEntry = medicationResource - .extend({ - category: medicationCategoryEnum, - lastTakenAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface.", - ), - todayEventCount: z - .number() - .int() - .nonnegative() - .describe( - "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", - ), - stockUnitsRemaining: z - .number() - .int() - .nullable() - .describe( - "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", - ), - stockDosesRemaining: z - .number() - .int() - .nullable() - .describe( - "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", - ), - }) - .meta({ - id: "MedicationListEntry", - description: - "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", - }); - -const medicationDetailEntry = medicationResource - .extend({ - category: medicationCategoryEnum, - }) - .meta({ - id: "MedicationDetail", - description: - "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", - }); - -// v1.16.10 — per-container inventory entity (pen / blister pack / -// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to -// doses. The intake consumption hook decrements `unitsRemaining` per -// taken dose and stamps the intake event with what it consumed. -const medicationInventoryItemResource = z - .object({ - id: z.string(), - userId: z.string(), - medicationId: z.string(), - state: z - .enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"]) - .describe( - "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).", - ), - containerType: z - .enum(MEDICATION_CONTAINER_TYPE_VALUES) - .describe( - "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.", - ), - unitsTotal: z - .number() - .nullable() - .describe( - 'Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication\'s `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0.', - ), - unitsRemaining: z - .number() - .nullable() - .describe( - 'Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0 it could decrement into negatives.', - ), - firstUseAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.", - ), - expiresAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.", - ), - printedExpiry: z.iso.datetime({ offset: true }).nullable(), - purchasedAt: z.iso.datetime({ offset: true }).nullable(), - notes: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "MedicationInventoryItem", - description: - "One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.", - }); - -const medicationIntakeEventResource = z - .object({ - id: z.string(), - userId: z.string(), - medicationId: z.string(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), - skipped: z.boolean(), - source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), - idempotencyKey: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "MedicationIntakeEvent", - description: - "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", - }); - -const medicationCadenceTimelinePoint = z - .object({ - day: z.iso.datetime({ offset: true }), - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), - scheduleIndex: z.number().int().nonnegative(), - status: z.string(), - }) - .meta({ - id: "MedicationCadenceTimelinePoint", - description: - "One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour.", - }); - -const medicationCadenceChips = z - .object({ - adherenceRate: z.number(), - streakDays: z.number().int().nonnegative(), - expectedSlots: z.number().int().nonnegative(), - actualDoses: z.number().int().nonnegative(), - }) - .meta({ - id: "MedicationCadenceChips", - description: - "Four compliance summary values for the medication detail page chip row.", - }); - -const medicationCadenceResponse = z - .object({ - windowDays: z.number().int().positive(), - anchorIso: z.iso.datetime({ offset: true }), - next: z - .object({ - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), - scheduleIndex: z.number().int().nonnegative(), - }) - .nullable(), - chips: medicationCadenceChips, - timeline: z.array(medicationCadenceTimelinePoint), - }) - .meta({ - id: "MedicationCadenceResponse", - description: - "Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.", - }); - -const complianceResult = z - .object({ - totalExpected: z - .number() - .int() - .nonnegative() - .describe( - "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.", - ), - taken: z.number().int().nonnegative(), - skipped: z - .number() - .int() - .nonnegative() - .describe( - "Doses the user explicitly skipped — excluded from the `rate` denominator.", - ), - missed: z.number().int().nonnegative(), - rate: z - .number() - .int() - .min(0) - .max(100) - .describe( - "Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.", - ), - streak: z - .number() - .int() - .nonnegative() - .describe("Consecutive days with every due dose taken."), - }) - .meta({ - id: "ComplianceResult", - description: - "Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.", - }); - -const dailyComplianceEntry = z - .object({ - expected: z - .number() - .int() - .nonnegative() - .describe( - "Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.", - ), - expectedCount: z - .number() - .int() - .nonnegative() - .describe( - "True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`).", - ), - due: z - .boolean() - .describe( - "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses.", - ), - taken: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), - onTime: z - .number() - .int() - .nonnegative() - .describe( - "Doses taken in the on-time band, including the `early` bucket (early counts as compliant).", - ), - late: z.number().int().nonnegative(), - veryLate: z.number().int().nonnegative(), - early: z - .number() - .int() - .nonnegative() - .optional() - .describe( - "Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate.", - ), - }) - .meta({ - id: "DailyComplianceEntry", - description: - "Per-day compliance cell with the timing breakdown that drives the history glyph track.", - }); - -const complianceDisplay = z - .object({ - shortDays: z.number().int().positive(), - longDays: z.number().int().positive(), - expectedShort: z.number().int().nonnegative(), - expectedLong: z.number().int().nonnegative(), - minStableDoses: z.number().int().nonnegative(), - short: z.object({ - rate: z.number().int().min(0).max(100), - taken: z.number().int().nonnegative(), - expected: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.", - ), - missed: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.", - ), - streak: z.number().int().nonnegative(), - }), - long: z.object({ - rate: z.number().int().min(0).max(100), - taken: z.number().int().nonnegative(), - expected: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 rate denominator over the long window (`taken + missed`).", - ), - missed: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 doses counted against the rate over the long window.", - ), - }), - currentCycle: z - .object({ - state: z - .enum(["on_track", "due", "missed", "none"]) - .describe( - "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", - ), - nextDueAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "The open cycle's due instant. Null when `state` is `none`.", - ), - graceUntil: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "End of the due slot's grace window. Null when `state` is `none`.", - ), - hasClosedCycles: z - .boolean() - .describe( - "False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.", - ), - }) - .describe( - "v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.", - ), - currentDose: z - .object({ - status: z - .enum([ - "upcoming", - "on_time_window", - "overdue", - "missed", - "taken_on_time", - "taken_late", - "skipped", - ]) - .describe( - "Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).", - ), - targetAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "Target instant of the open dose. Null when no dose is open.", - ), - }) - .describe( - "v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side.", - ), - }) - .meta({ - id: "ComplianceDisplay", - description: - "The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.", - }); - -const medicationComplianceResponse = z - .object({ - compliance7: complianceResult, - compliance30: complianceResult, - dailyCompliance: z - .record(z.string(), dailyComplianceEntry) - .describe( - "Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.", - ), - complianceDisplay, - }) - .meta({ - id: "MedicationComplianceResponse", - description: - "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", - }); - -const medicationComplianceSummaryEntry = z - .object({ - medicationId: z.string(), - compliance7: complianceResult, - compliance30: complianceResult, - complianceDisplay, - }) - .meta({ - id: "MedicationComplianceSummaryEntry", - description: - "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.", - }); - -// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). -// Archived eras come from two provenances: the wholesale-replace write -// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow -// (`MANUAL`, deletable through the `[revisionId]` DELETE). -const scheduleRevisionEntrySummary = z - .object({ - timesOfDay: z - .array(z.string()) - .describe("Daily dose times (HH:mm, user local) the era ran at."), - label: z.string().nullable(), - dose: z.string().nullable(), - scheduleType: z - .string() - .describe( - "Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC).", - ), - }) - .meta({ - id: "ScheduleRevisionEntry", - description: - "Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders.", - }); - -const scheduleRevisionResource = z - .object({ - id: z.string(), - validFrom: z.iso.datetime().describe("Inclusive start instant of the era."), - validUntil: z.iso - .datetime() - .describe( - "Exclusive end instant of the era — the moment the next plan took over.", - ), - source: z - .enum(["ARCHIVED", "MANUAL"]) - .describe( - "Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered pre-tracking era (deletable).", - ), - entries: z.array(scheduleRevisionEntrySummary), - }) - .meta({ - id: "MedicationScheduleRevision", - description: - "One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then.", - }); - -const scheduleRevisionListResponse = z.object({ - currentSince: z.iso - .datetime() - .describe( - "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", - ), - revisions: z - .array(scheduleRevisionResource) - .describe("Archived eras, newest first."), -}); - -// v1.5.0 — natural-language medication extraction route. The wizard's -// optional "Beschreiben" overlay POSTs a free-text description and -// receives a partial structured payload the form merges onto whatever -// the user already typed. Citation-guarded (`name` and `dose` are -// dropped when not substring-matched in the original text) and -// closed-enum-validated. -const medicationExtractRequest = z - .object({ - text: z - .string() - .min(1) - .max(2000) - .describe( - "Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.", - ), - locale: z - .enum(["en", "de", "es", "fr", "it", "pl"]) - .optional() - .describe("Optional UI locale hint for the model."), - today: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional() - .describe( - 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', - ), - }) - .meta({ - id: "MedicationExtractRequest", - description: - "Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.", - }); - -medicationExtractionSchema.meta({ - id: "MedicationExtractionResult", - description: - "Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.", -}); - -// v1.16.10 — medications list presentation (cards/table view + manual -// order), persisted per user in its own `User` column following the -// dashboard-widgets / insights-layout per-surface convention. -const medicationListLayoutSchema = z - .object({ - version: z.literal(1), - view: z - .enum(MEDICATION_LIST_VIEWS) - .optional() - .describe( - 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.', - ), - order: z - .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) - .max(MEDICATION_ORDER_MAX_ENTRIES) - .optional() - .describe( - "User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses.", - ), - }) - .meta({ - id: "MedicationListLayout", - description: - "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.", - }); - -export const medicationPaths: NonNullable = { - "/api/medications": { - get: { - tags: ["Medications"], - summary: "List medications for the calling user", - description: - "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", - responses: { - "200": { - description: "Medication list.", - content: { - "application/json": { - schema: dataEnvelope( - z.array(medicationListEntry), - "ListMedicationsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Medications"], - summary: "Create a medication with at least one schedule", - description: - "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.", - requestBody: { - required: true, - content: { "application/json": { schema: createMedicationSchema } }, - }, - responses: { - "201": { - description: "Created medication with its schedules.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "CreateMedicationResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/layout": { - get: { - tags: ["Medications"], - summary: "Read the calling user's medications list presentation", - description: - "Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.", - responses: { - "200": { - description: "The resolved presentation (custom or default).", - content: { - "application/json": { - schema: dataEnvelope( - medicationListLayoutSchema, - "MedicationListLayoutResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Medications"], - summary: "Update the calling user's medications list presentation", - description: - "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.", - requestBody: { - required: true, - content: { - "application/json": { schema: medicationListLayoutSchema }, - }, - }, - responses: { - "200": { - description: - "Presentation saved; the normalised blob is echoed back.", - content: { - "application/json": { - schema: dataEnvelope( - medicationListLayoutSchema, - "MedicationListLayoutSaved", - ), - }, - }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Medications"], - summary: "Reset the calling user's medications list presentation", - description: - "Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.", - responses: { - "200": { - description: "Presentation reset; the defaults are returned.", - content: { - "application/json": { - schema: dataEnvelope( - medicationListLayoutSchema, - "MedicationListLayoutReset", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}": { - get: { - tags: ["Medications"], - summary: "Fetch a single medication", - description: - "Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Medication detail.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "GetMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Medications"], - summary: "Replace a medication (partial fields)", - description: - "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { "application/json": { schema: updateMedicationSchema } }, - }, - responses: { - "200": { - description: "Updated medication.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "UpdateMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Medications"], - summary: "Delete a medication", - description: - "Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication::ingest`. Audits as `medication.delete`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Deletion succeeded.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ deleted: z.boolean() }), - "DeleteMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/intake": { - post: { - tags: ["Medications"], - summary: "Log an intake event for a medication", - description: - "Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { "application/json": { schema: intakeSchema } }, - }, - responses: { - "201": { - description: "Intake event created.", - content: { - "application/json": { - schema: dataEnvelope( - medicationIntakeEventResource, - "CreateMedicationIntakeResponse", - ), - }, - }, - }, - "200": { - description: - "Idempotent replay — the original event is returned without creating a new row.", - content: { - "application/json": { - schema: dataEnvelope( - medicationIntakeEventResource, - "ReplayMedicationIntakeResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/inventory": { - get: { - tags: ["Medications"], - summary: "List a medication's supply containers", - description: - "Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Inventory item list.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - items: z.array(medicationInventoryItemResource), - meta: z.object({ total: z.number().int().nonnegative() }), - }), - "ListMedicationInventoryResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Medications"], - summary: "Register a new supply container", - description: - "Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { - "application/json": { schema: createInventoryItemSchema }, - }, - }, - responses: { - "201": { - description: "Created inventory item.", - content: { - "application/json": { - schema: dataEnvelope( - medicationInventoryItemResource, - "CreateMedicationInventoryItemResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/inventory/{itemId}": { - patch: { - tags: ["Medications"], - summary: "Mutate a supply container", - description: - "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.", - requestParams: { - path: z.object({ id: z.string(), itemId: z.string() }), - }, - requestBody: { - required: true, - content: { - "application/json": { schema: updateInventoryItemSchema }, - }, - }, - responses: { - "200": { - description: "Updated inventory item.", - content: { - "application/json": { - schema: dataEnvelope( - medicationInventoryItemResource, - "UpdateMedicationInventoryItemResponse", - ), - }, - }, - }, - "404": { - description: - "Inventory item not found (or owned by another user / medication).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Medications"], - summary: "Delete a supply container", - description: - "Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.", - requestParams: { - path: z.object({ id: z.string(), itemId: z.string() }), - }, - responses: { - "200": { - description: "Deletion succeeded.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ id: z.string(), deleted: z.boolean() }), - "DeleteMedicationInventoryItemResponse", - ), - }, - }, - }, - "404": { - description: - "Inventory item not found (or owned by another user / medication).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/extract": { - post: { - tags: ["Medications"], - summary: - "Extract scheduling fields from a free-text medication description", - description: - "Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.", - requestBody: { - required: true, - content: { - "application/json": { schema: medicationExtractRequest }, - }, - }, - responses: { - "200": { - description: "Citation-guarded partial extraction.", - content: { - "application/json": { - schema: dataEnvelope( - medicationExtractionSchema, - "MedicationExtractResponse", - ), - }, - }, - }, - "502": { - description: - "Upstream provider returned an empty, unparseable, or off-schema reply.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "503": { - description: - "No AI provider configured for the calling user (or operator).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/cadence": { - get: { - tags: ["Medications"], - summary: "Cadence + compliance read for a medication", - description: - "Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.", - requestParams: { - path: z.object({ id: z.string() }), - query: z.object({ - days: z.coerce - .number() - .int() - .min(1) - .max(180) - .optional() - .describe("Window size in days (default 30, max 180)."), - }), - }, - responses: { - "200": { - description: "Cadence response.", - content: { - "application/json": { - schema: dataEnvelope( - medicationCadenceResponse, - "GetMedicationCadenceResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/compliance": { - get: { - tags: ["Medications"], - summary: "Batched adherence read for every medication of the caller", - description: - "Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.", - responses: { - "200": { - description: "One adherence row per medication.", - content: { - "application/json": { - schema: dataEnvelope( - z.array(medicationComplianceSummaryEntry), - "ListMedicationComplianceResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/compliance": { - get: { - tags: ["Medications"], - summary: "Adherence read for a medication", - description: - "Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Compliance response.", - content: { - "application/json": { - schema: dataEnvelope( - medicationComplianceResponse, - "GetMedicationComplianceResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/schedule-revisions": { - get: { - tags: ["Medications"], - summary: "List a medication's archived schedule eras", - description: - "Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Era list.", - content: { - "application/json": { - schema: dataEnvelope( - scheduleRevisionListResponse, - "ListScheduleRevisionsResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Medications"], - summary: "Append a manual schedule era (pre-tracking history)", - description: - "Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { - "application/json": { schema: scheduleRevisionCreateSchema }, - }, - }, - responses: { - "201": { - description: "Manual era created.", - content: { - "application/json": { - schema: dataEnvelope( - scheduleRevisionResource, - "CreateScheduleRevisionResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/schedule-revisions/{revisionId}": { - patch: { - tags: ["Medications"], - summary: "Correct a recorded schedule era", - description: - "Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the era must end at or before the start of the live plan and must not overlap another active era; violations return 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`.", - requestParams: { - path: z.object({ id: z.string(), revisionId: z.string() }), - }, - requestBody: { - required: true, - content: { - "application/json": { schema: scheduleRevisionUpdateSchema }, - }, - }, - responses: { - "200": { - description: "Era corrected.", - content: { - "application/json": { - schema: dataEnvelope( - scheduleRevisionResource, - "UpdateScheduleRevisionResponse", - ), - }, - }, - }, - "404": { - description: - "Medication or revision not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: - "The revision has already been superseded by a correction.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Medications"], - summary: "Delete a manually added schedule era", - description: - "Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.", - requestParams: { - path: z.object({ id: z.string(), revisionId: z.string() }), - }, - responses: { - "200": { - description: "Deletion succeeded.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ deleted: z.boolean() }), - "DeleteScheduleRevisionResponse", - ), - }, - }, - }, - "404": { - description: - "Medication or revision not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: - "The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, -};