mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
6dac56b659
commit
4ad65a5b7d
5 files changed
+603
No files matched your search
@@ -2307,6 +2307,68 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/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.
|
||||
parameters:
|
||||
- in: query
|
||||
name: metric
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- VITALS_BASELINE
|
||||
- FITNESS_AGE
|
||||
- VASCULAR_AGE_DELTA
|
||||
- SLEEP_SCORE
|
||||
- READINESS
|
||||
- COINCIDENT_DEVIATION
|
||||
description: "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`."
|
||||
required: true
|
||||
description: "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`."
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
description: 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.
|
||||
type: string
|
||||
enum:
|
||||
- RESTING_HEART_RATE
|
||||
- HEART_RATE_VARIABILITY
|
||||
- RESPIRATORY_RATE
|
||||
- OXYGEN_SATURATION
|
||||
- BODY_TEMPERATURE
|
||||
- SKIN_TEMPERATURE
|
||||
- BLOOD_PRESSURE_SYS
|
||||
- BLOOD_PRESSURE_DIA
|
||||
- BLOOD_GLUCOSE
|
||||
- PULSE
|
||||
- WEIGHT
|
||||
description: 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.
|
||||
responses:
|
||||
"200":
|
||||
description: The flat derived-metric value (ok or insufficient).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DerivedMetricResponseEnvelope"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
components:
|
||||
schemas:
|
||||
PasskeyLoginVerifyRequest:
|
||||
@@ -7241,6 +7303,160 @@ components:
|
||||
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."
|
||||
DerivedMetricResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DerivedMetricResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DerivedMetricResponse:
|
||||
type: object
|
||||
properties:
|
||||
metric:
|
||||
type: string
|
||||
enum:
|
||||
- VITALS_BASELINE
|
||||
- FITNESS_AGE
|
||||
- VASCULAR_AGE_DELTA
|
||||
- SLEEP_SCORE
|
||||
- READINESS
|
||||
- COINCIDENT_DEVIATION
|
||||
description: Echoes the requested derived-metric id (tags the union).
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ok
|
||||
- insufficient
|
||||
description: "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries
|
||||
`coverage` + `provenance` so the surface renders the same gating UI."
|
||||
value:
|
||||
anyOf:
|
||||
- type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
- type: "null"
|
||||
description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k }
|
||||
for VITALS_BASELINE); null when 'insufficient'.
|
||||
coverage:
|
||||
$ref: "#/components/schemas/DerivedCoverage"
|
||||
confidence:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/DerivedConfidence"
|
||||
- type: "null"
|
||||
description: Present when status is 'ok'; null when 'insufficient'.
|
||||
provenance:
|
||||
$ref: "#/components/schemas/DerivedProvenance"
|
||||
reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Why the value could not be produced; null when status is 'ok'.
|
||||
required:
|
||||
- metric
|
||||
- status
|
||||
- value
|
||||
- coverage
|
||||
- confidence
|
||||
- provenance
|
||||
- reason
|
||||
additionalProperties: false
|
||||
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.
|
||||
DerivedCoverage:
|
||||
type: object
|
||||
properties:
|
||||
requiredInputs:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Inputs the metric wants (its full input set).
|
||||
presentInputs:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Inputs actually present in the user's data.
|
||||
historyDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Distinct days of history backing the value (the gating floor).
|
||||
missing:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Named inputs still missing — drives the 'track N more' nudge.
|
||||
required:
|
||||
- requiredInputs
|
||||
- presentInputs
|
||||
- historyDays
|
||||
- missing
|
||||
additionalProperties: false
|
||||
DerivedConfidence:
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
description: 0..100 confidence; feeds the shared coverage meter unchanged.
|
||||
band:
|
||||
type: string
|
||||
enum:
|
||||
- high
|
||||
- medium
|
||||
- low
|
||||
- draft
|
||||
description: Confidence band the meter renders.
|
||||
required:
|
||||
- score
|
||||
- band
|
||||
additionalProperties: false
|
||||
DerivedProvenance:
|
||||
type: object
|
||||
properties:
|
||||
inputs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Named inputs that actually backed the value.
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- DAY
|
||||
- WEEK
|
||||
- MONTH
|
||||
- YEAR
|
||||
- live
|
||||
- none
|
||||
description: Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no
|
||||
data backed the value.
|
||||
windowDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Trailing window the value summarises, in days.
|
||||
computedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Compute time (for cache-staleness + the 'as of' chip).
|
||||
required:
|
||||
- inputs
|
||||
- source
|
||||
- windowDays
|
||||
- computedAt
|
||||
additionalProperties: false
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -39,6 +39,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
// v1.10.0 — generic derived-wellness-metric route. Pure compute over
|
||||
// the rollup tier; gates on the same `insightStatus` sub-flag as the
|
||||
// assessment routes (no Coach prose).
|
||||
"src/app/api/insights/derived/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
|
||||
// same `insightStatus` sub-flag as the seven specialised status routes.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the compute layer so the route test stays isolated.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
computeDerivedMetric: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(metric?: string, type?: string): NextRequest {
|
||||
const url = new URL("http://localhost/api/insights/derived");
|
||||
if (metric !== undefined) url.searchParams.set("metric", metric);
|
||||
if (type !== undefined) url.searchParams.set("type", type);
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/derived", () => {
|
||||
it("returns 200 + a flat Derived envelope for a valid metric", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metric: string;
|
||||
status: string;
|
||||
value: { center: number } | null;
|
||||
coverage: { historyDays: number };
|
||||
confidence: { band: string } | null;
|
||||
provenance: { source: string };
|
||||
reason: string | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.metric).toBe("VITALS_BASELINE");
|
||||
expect(body.data?.status).toBe("ok");
|
||||
expect(body.data?.value?.center).toBe(55);
|
||||
expect(body.data?.confidence?.band).toBe("high");
|
||||
expect(body.data?.provenance.source).toBe("DAY");
|
||||
expect(body.data?.reason).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows userId from the session into the compute call (never a query field)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", metric: "VITALS_BASELINE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on an unknown metric without calling compute", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("NOT_A_METRIC"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing metric param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("flattens an insufficient result with a null value + reason", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
|
||||
status: "insufficient",
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
reason: "no_readings_in_window",
|
||||
} as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
|
||||
};
|
||||
expect(body.data?.status).toBe("insufficient");
|
||||
expect(body.data?.value).toBeNull();
|
||||
expect(body.data?.confidence).toBeNull();
|
||||
expect(body.data?.reason).toBe("no_readings_in_window");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* v1.10.0 — generic derived-wellness-metric route.
|
||||
*
|
||||
* `GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<MeasurementType>]`
|
||||
* serves the compute-once `Derived<T>` value for any metric registered
|
||||
* in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route
|
||||
* precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed
|
||||
* registry enum (unknown id → 422 via `returnAllZodIssues`), cookie OR
|
||||
* Bearer auth, `userId` narrowed from the session/Bearer (never a query
|
||||
* field).
|
||||
*
|
||||
* The `data` payload is the flat `Derived<T>` union so iOS can decode +
|
||||
* combine values across metrics: `{ metric, status, value?, coverage,
|
||||
* confidence?, provenance, reason? }`. Numbers are pure compute over the
|
||||
* rollup tier (Tier-1, no narrative) — no provider call, no cache table.
|
||||
*
|
||||
* Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids
|
||||
* return a `not_implemented` insufficient until their compute lands in a
|
||||
* later wave (never a 500, never a fabricated value).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
DERIVED_METRIC_IDS,
|
||||
type DerivedMetricId,
|
||||
} from "@/lib/insights/derived";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Closed enum derived from the registry so the route + registry cannot
|
||||
// drift — an id added to the registry is accepted automatically, an
|
||||
// unknown id 422s rather than silently resolving to no metric.
|
||||
const derivedQuerySchema = z.object({
|
||||
metric: z.enum(DERIVED_METRIC_IDS as [string, ...string[]]),
|
||||
// Optional sub-target for metrics that baseline a single chosen vital
|
||||
// (VITALS_BASELINE). Validated against the engine's supported set in
|
||||
// the dispatcher; an unsupported value yields an `insufficient`, not a
|
||||
// 422, so the contract stays forgiving for iOS combinations.
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
type: request.nextUrl.searchParams.get("type") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.derived.invalid-metric" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const metric = parsed.data.metric as DerivedMetricId;
|
||||
|
||||
// Profile read once, passed into the pure compute function (never
|
||||
// re-fetched per metric — the pool-contention mitigation).
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dateOfBirth: true, gender: true },
|
||||
});
|
||||
const sex =
|
||||
profile?.gender === "MALE" || profile?.gender === "FEMALE"
|
||||
? profile.gender
|
||||
: null;
|
||||
|
||||
const derived = await computeDerivedMetric({
|
||||
metric,
|
||||
userId: user.id,
|
||||
profile: {
|
||||
ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null),
|
||||
sex,
|
||||
},
|
||||
type: parsed.data.type ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.derived" },
|
||||
meta: { metric, status: derived.status },
|
||||
});
|
||||
|
||||
// Flatten the discriminated union for the wire: `metric` tags it,
|
||||
// `value`/`reason` are nullable so iOS decodes one stable shape.
|
||||
return apiSuccess({
|
||||
metric,
|
||||
status: derived.status,
|
||||
value: derived.status === "ok" ? derived.value : null,
|
||||
coverage: derived.coverage,
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
});
|
||||
});
|
||||
@@ -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"]> = {
|
||||
|
||||
Reference in new issue
Block a user