Files
HealthLog/src/app/api/insights/derived/route.ts
T
Marc-André Bombeck fb13759f45 feat(modules): gate insights status routes behind module flags
The derived-score, metric-status and mood-status insight routes serve a mix
of core-vital and toggleable-module signals. Map the module-owned metric ids
(sleep/recovery/glucose) to their module key and refuse with the shared 403
module.disabled envelope when the account has that module disabled; mood-status
gates wholesale on the mood module, mirroring the cycle gate. Metrics absent
from the map are core vitals and stay open.
2026-06-15 21:13:16 +02:00

150 lines
6.0 KiB
TypeScript

/**
* 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 { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import {
computeDerivedMetric,
loadBaselineProfile,
DERIVED_METRIC_IDS,
type DerivedMetricId,
} from "@/lib/insights/derived";
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
/**
* Derived scores that are NATIVE to a toggleable module rather than a core
* vital. SLEEP_SCORE is the sleep module's; the strain / recovery / stress
* trio belongs to the WHOOP/Polar recovery module. The composite baselines
* (VITALS_BASELINE, READINESS, HRV_BALANCE, …) read off core vitals and
* stay open — a metric absent from this map carries no module gate.
*/
const DERIVED_MODULE: Partial<Record<DerivedMetricId, ModuleKey>> = {
SLEEP_SCORE: "sleep",
RECOVERY_SCORE: "recovery",
STRAIN_SCORE: "recovery",
STRESS_SCORE: "recovery",
};
// 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();
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
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;
// Per-domain gate: a derived score owned by a toggleable module
// (sleep / recovery) is refused with a 403 module.disabled envelope when
// the account has that module turned off. Core-vital composites stay open.
const moduleKey = DERIVED_MODULE[metric];
if (moduleKey) {
const gate = await requireModuleEnabled(user.id, moduleKey);
if (!gate.enabled) return gate.response;
}
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
profile,
type: parsed.data.type ?? null,
});
annotate({
action: { name: "insights.derived" },
meta: { metric, status: derived.status },
});
// v1.13.2 — additive per-score assessment: a short "why is this score what
// it is" explanation, keyed to the SAME id the caller passed (READINESS,
// SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any
// other id, and null when status !== "ok". Always a non-empty deterministic
// text when present (the demo + provider-less accounts fill); warmer AI
// prose overrides it once cached. The warm enqueue is fire-and-forget — this
// never blocks on an LLM round-trip.
const localeParam = request.nextUrl.searchParams.get("locale");
const resolvedLocale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const assessment = await resolveDerivedAssessment({
metric,
userId: user.id,
derived,
locale: resolvedLocale,
});
// 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,
assessment,
});
});