mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(insights): generic per-metric assessment coverage for HealthKit metrics
Extend the assessment-card system beyond the seven specialised scopes to the ~30 HealthKit metric pages, without hand-writing a prompt per metric. - Add a metric-metadata registry keyed by HealthKit id (display name, unit, direction, optional normal range, archetype, scope), covering the data-bearing HealthKit metrics. The seven specialised metrics keep their own scopes and are excluded from the generic set; an unregistered id gets no card. - Add a generic data-driven generator that reuses the existing graded snapshot, provider runner, stale-while-revalidate cache, and persist path. One prompt template per archetype (physiological-vital, activity-fitness, body-composition, mobility-gait, environmental-exposure) plus a dedicated sleep template, parameterised by metric metadata. Empty-data metrics return an insufficient marker with no provider call. Normal ranges use age + sex where the profile stores them; no ancestry/region ranges. - Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped, Zod-validated against the closed registry id set (422 on unknown), read-only with stale-while-revalidate. First insights route with OpenAPI coverage; regenerate docs/api/openapi.yaml. - Route the generic metric:<id> scope through the on-demand status queue worker dispatch. - Extend the nightly warm pass to the generic metric caches for a user's data-bearing metrics only (one distinct read gates the set), keeping de+en warm and never calling a provider for an empty metric. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
This commit is contained in:
1 parent
2717297dae
commit
e33911e2d4
14 files changed
+1799
-4
No files matched your search
@@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/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.
|
||||
"src/app/api/insights/metric-status/route.ts",
|
||||
"src/app/api/insights/mood-status/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
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 heavy generator so the route test stays isolated from the
|
||||
// real status pipeline.
|
||||
vi.mock("@/lib/insights/metric-status", () => ({
|
||||
generateMetricStatus: vi.fn(async () => ({
|
||||
hasProvider: true,
|
||||
text: "ok",
|
||||
cached: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
resolveMetricStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
|
||||
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): NextRequest {
|
||||
const url = new URL("http://localhost/api/insights/metric-status");
|
||||
if (metric !== undefined) url.searchParams.set("metric", metric);
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/metric-status", () => {
|
||||
it("returns 200 + envelope for a valid registered metric", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { text: string; hasProvider: boolean } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.text).toBe("ok");
|
||||
expect(generateMetricStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metric: "RESTING_HEART_RATE",
|
||||
userId: "user-1",
|
||||
readOnly: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on an unknown metric without calling the generator", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("NOT_A_METRIC"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(generateMetricStatus).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(generateMetricStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when a specialised metric is requested through the generic route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("WEIGHT"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(generateMetricStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("403s + errorCode when insightStatus is disabled", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* v1.8.7.1 — generic per-HealthKit-metric assessment route.
|
||||
*
|
||||
* `GET /api/insights/metric-status?metric=<METRIC_ID>` serves the
|
||||
* data-driven assessment for any metric registered in
|
||||
* `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …).
|
||||
* The seven specialised metrics keep their own `*-status` routes; this
|
||||
* single generic route covers the ~30 HealthKit pages without one route
|
||||
* per metric.
|
||||
*
|
||||
* Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an
|
||||
* out-of-band generation and serves the last-good (stale-while-revalidate)
|
||||
* text, never blocking on the provider. An unknown `metric` 422s via the
|
||||
* closed registry enum.
|
||||
*
|
||||
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
|
||||
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
|
||||
* establishing the pattern the legacy routes can be backfilled onto later.
|
||||
*/
|
||||
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 { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import {
|
||||
generateMetricStatus,
|
||||
resolveMetricStatusLocale,
|
||||
} from "@/lib/insights/metric-status";
|
||||
import {
|
||||
METRIC_STATUS_IDS,
|
||||
type MetricStatusMetricId,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
|
||||
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, and an
|
||||
// unknown id 422s rather than silently resolving to no card.
|
||||
const metricQuerySchema = z.object({
|
||||
metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = metricQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.metric-status.invalid-metric" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const metric = parsed.data.metric as MetricStatusMetricId;
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMetricStatusLocale(resolved);
|
||||
|
||||
const result = await generateMetricStatus({
|
||||
metric,
|
||||
userId: user.id,
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.metric-status" },
|
||||
meta: { metric },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
Reference in new issue
Block a user