feat(insights): add GET /api/insights/derived + OpenAPI contract

Generic route mirroring the v1.8.7.1 metric-status precedent:
GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<vital>].
apiHandler + Zod safeParse on the query, closed registry enum (unknown
id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed
from the session (never a query field). The profile is read once and
passed into the pure compute function. Returns the flat Derived<T> union
in the standard { data, error, meta } envelope so the native client
decodes one stable shape and combines values across metrics.

Register the query + response schemas (DerivedMetricQuery,
DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the
path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml.
Add the route to the insights gate inventory (insightStatus-gated, no
Coach prose).

Route tests cover the 200 flat-envelope shape, session-narrowed userId,
422 on an unknown / missing metric, 401 unauthenticated, and the
insufficient flattening (null value + confidence, populated reason).
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 20:01:42 +02:00
1 parent 6dac56b659
commit 4ad65a5b7d
5 files changed
+603

No files matched your search

+132
View File
@@ -47,6 +47,10 @@ import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-pr
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
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";
/**
@@ -1278,6 +1282,109 @@ const metricStatusResponse = z
"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, READINESS). Closed enum: an unknown id 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics 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" });
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 } for VITALS_BASELINE); 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'."),
})
.meta({
id: "DerivedMetricResponse",
description:
"Flat `Derived<T>` 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.",
});
// 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).
@@ -2996,6 +3103,31 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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,
},
},
},
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {