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

@@ -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");
});
});
+103
View File
@@ -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,
});
});