Files
HealthLog/src/lib/openapi/routes/insights/paths.ts
T
Marc-André Bombeck 9a5206ee5f docs(openapi): give rhythm-events its own six-value classification enum
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
2026-07-30 16:06:53 +02:00

599 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenAPI path table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations.
*
* Schema declarations live in `./schemas`; this module is the path orchestrator.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
consentRequiredResponse,
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
stdResponses,
} from "../shared";
import {
insightsCardsResponse,
insightsComprehensiveResponse,
metricStatusQuery,
metricStatusResponse,
biomarkerAssessmentQuery,
biomarkerAssessmentResponse,
derivedMetricQuery,
derivedMetricResponse,
derivedBatchQuery,
derivedBatchResponse,
correlationDiscoveryResponse,
correlationPatternListResponse,
updateCorrelationPatternRequest,
updateCorrelationPatternResponse,
glp1PlateauResponse,
insightStatusQuery,
insightStatusResponse,
medicationComplianceStatusResponse,
analyticsRangeQuery,
analyticsRangeResponse,
insightsPregenerateRequest,
insightsPregenerateResponse,
dashboardSnapshotResponse,
ecgListResponse,
ecgDetailQuery,
ecgDetailResponse,
rhythmEventsResponse,
} from "./schemas";
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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/cards": {
get: {
tags: ["Insights"],
summary: "Insight cards (iOS adapter)",
description:
"v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant surface. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The list of insight cards (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
insightsCardsResponse,
"InsightsCardsEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/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:<userId>`, 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/biomarker-assessment": {
get: {
tags: ["Insights"],
summary: "Per-biomarker assessment",
description:
"Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.",
requestParams: {
query: biomarkerAssessmentQuery,
},
responses: {
"200": {
description: "Assessment envelope (fresh, cached, or preparing).",
content: {
"application/json": {
schema: dataEnvelope(
biomarkerAssessmentResponse,
"BiomarkerAssessmentResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/derived": {
get: {
tags: ["Insights"],
summary: "Derived wellness metric (compute-once)",
description:
"v1.10.0 — the compute-once `Derived<T>` 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<T>` 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/glp1-plateau": {
get: {
tags: ["Insights"],
summary: "GLP-1 weight-plateau detection",
description:
"Deterministic (non-LLM) weight-plateau read for users on an active GLP-1 medication: flags a stable dose held for at least the trailing window with no weight loss beyond the threshold. `plateau` is null whenever the condition does not hold, so clients hide the note cleanly. Association only — no verdict, no dose advice. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Plateau context (or null) plus the window length.",
content: {
"application/json": {
schema: dataEnvelope(
glp1PlateauResponse,
"InsightsGlp1PlateauResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/ecg": {
get: {
tags: ["Insights"],
summary: "ECG recording list (metadata only)",
description:
"v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The ECG recording list (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(ecgListResponse, "EcgListResponseEnvelope"),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/insights/ecg/{id}": {
get: {
tags: ["Insights"],
summary: "One ECG recording with waveform",
description:
"v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.",
requestParams: {
path: z.object({
id: z.string().describe("The ECG recording id (cuid)."),
}),
query: ecgDetailQuery,
},
responses: {
"200": {
description:
"The recording's waveform + metadata + device classification.",
content: {
"application/json": {
schema: dataEnvelope(
ecgDetailResponse,
"EcgDetailResponseEnvelope",
),
},
},
},
"404": {
description:
"No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one).",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/insights/rhythm-events": {
get: {
tags: ["Insights"],
summary: "Device-flagged rhythm/HR/steadiness event timeline",
description:
"v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The device-flagged event timeline (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
rhythmEventsResponse,
"RhythmEventsResponseEnvelope",
),
},
},
},
...moduleDisabledResponse,
...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,
},
},
},
"/api/insights/patterns": {
get: {
tags: ["Insights"],
summary: "List current correlation patterns",
description:
"Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps.",
responses: {
"200": {
description: "Current persisted correlation patterns.",
content: {
"application/json": {
schema: dataEnvelope(
correlationPatternListResponse,
"CorrelationPatternListResponseEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/insights/patterns/{id}": {
patch: {
tags: ["Insights"],
summary: "Update correlation pattern dismissal",
description:
"Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: updateCorrelationPatternRequest },
},
},
responses: {
"200": {
description: "Updated dismissal decision.",
content: {
"application/json": {
schema: dataEnvelope(
updateCorrelationPatternResponse,
"UpdateCorrelationPatternResponseEnvelope",
),
},
},
},
"404": {
description: "No current pattern with that id for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
};