Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10997,8 +10997,8 @@ paths:
NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET
/api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim;
HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns
`hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM
call. Auth via cookie or Bearer."
`hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie
or Bearer."
responses:
"200":
description: The ECG recording list (possibly empty).
Expand All @@ -11022,8 +11022,8 @@ paths:
or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field.
Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its
own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports.
Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the
operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer."
Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no
assistant-surface gate and no LLM call. Auth via cookie or Bearer."
requestBody:
required: true
content:
Expand Down Expand Up @@ -11058,8 +11058,8 @@ paths:
foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the
fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave
peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals,
annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus`
assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.
annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM
call. `no-store`. Auth via cookie or Bearer.
parameters:
- in: path
name: id
Expand Down Expand Up @@ -11107,8 +11107,8 @@ paths:
it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value
verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) —
a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account
with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus`
assistant surface; no LLM call. Auth via cookie or Bearer."
with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM
call. Auth via cookie or Bearer."
responses:
"200":
description: The device-flagged event timeline (possibly empty).
Expand Down
30 changes: 12 additions & 18 deletions src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,6 @@ 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",
// v1.10.0 — batched derived-metric route (the dashboard fan-out fix).
// Same pure compute + `insightStatus` sub-flag as the single route.
"src/app/api/insights/derived/batch/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.
Expand All @@ -62,17 +55,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// `coach`: a user with assessments enabled but Coach disabled can warm.
"src/app/api/insights/pregenerate/route.ts",
"src/app/api/insights/pulse-status/route.ts",
// v1.10.0 — device-flagged event awareness timeline (categorical
// events, WX-B). Pure DB read of the device's own verdicts; gates on
// the same `insightStatus` sub-flag as the assessment routes (no Coach
// prose).
"src/app/api/insights/rhythm-events/route.ts",
// v1.28.50 — ECG recording surface (list + per-recording waveform). Pure
// DB read of the device's own recordings + verdicts; gates on the same
// `insightStatus` sub-flag as the assessment routes (no Coach prose — the
// waveform is never interpreted).
"src/app/api/insights/ecg/route.ts",
"src/app/api/insights/ecg/[id]/route.ts",
"src/app/api/insights/weight-status/route.ts",
];

Expand Down Expand Up @@ -133,6 +115,18 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/chat/fenced/route.ts",
"src/app/api/insights/chat/[id]/attachments/route.ts",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts",
// Deterministic reads behind the Insights overview: the comprehensive
// overview query, the derived-metric tiles, the ECG list, strip and live
// ingest, and the device-flagged rhythm events. None of them carries
// assistant prose or calls a provider; they gate on the `insights` module
// only. Switching the assistant off, the master or a sub-flag, must not
// refuse them, or the overview fails to load.
"src/app/api/insights/comprehensive/route.ts",
"src/app/api/insights/derived/route.ts",
"src/app/api/insights/derived/batch/route.ts",
"src/app/api/insights/ecg/route.ts",
"src/app/api/insights/ecg/[id]/route.ts",
"src/app/api/insights/rhythm-events/route.ts",
];

const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
Expand Down
42 changes: 41 additions & 1 deletion src/app/api/insights/comprehensive/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,18 @@ vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));

// The route gates on the `insights` module. Mock it default-enabled so the
// envelope assertions ride through; the module-off coverage lives in the
// module route-gate inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));

import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
Expand All @@ -113,6 +123,8 @@ function makeReq(): NextRequest {

beforeEach(() => {
vi.resetAllMocks();
// resetAllMocks drops the module gate's default; restore it.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
__resetAllCachesForTests();
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
Expand All @@ -121,7 +133,7 @@ beforeEach(() => {
remaining: 119,
resetAt: Date.now() + 60_000,
});
// Default to assistant-on so the gate doesn't 403 every test.
// No stored settings row: the provider probe falls back to defaults.
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
Expand Down Expand Up @@ -210,6 +222,34 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.dataSpanDays).toBe(0);
});

it("answers with the assistant switched off on the server", async () => {
// The overview's main read carries no assistant prose, so an operator who
// switches the assistant off (master flag) must still get the overview.
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
});
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
},
);

const res = await callGet(makeReq());
expect(res.status).toBe(200);
});

it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
Expand Down
11 changes: 7 additions & 4 deletions src/app/api/insights/comprehensive/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import {
requireRecordAuth,
type AuthContext,
} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import {
cachedSwrWithMeta,
Expand All @@ -49,16 +49,19 @@ export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;

// 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);
}

// v1.4.31 — comprehensive feeds the hero strip narration and the
// recommendations grid that share the Coach gate.
await requireAssistantSurface("coach");
// No assistant-surface gate. Every field is computed from the record
// (the provider chain is only probed for `hasProvider`), and this is the
// main read of the Insights overview: an operator who switches the
// assistant off still gets the overview.

// v1.4.35 — read-through the analytics cache keyed on
// (userId, "comprehensive"). The /insights page mount routinely
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/insights/derived/batch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { MeasurementType } from "@/generated/prisma/client";
import {
Expand Down Expand Up @@ -120,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// Same deterministic compute as the single route, so no
// assistant-surface gate.

// Per-caller limiter, same posture as the compliance routes: the cold
// build fans out up to 24 rollup walks, so an unthrottled caller could
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/insights/derived/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } 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 {
Expand Down Expand Up @@ -98,7 +97,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Too many analytics requests. Please retry later.", 429);
}

await requireAssistantSurface("insightStatus");
// Pure compute over the rollup tier, so no assistant-surface gate:
// switching the assistant off does not blank the derived tiles.

const parsed = derivedQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
Expand Down
3 changes: 1 addition & 2 deletions src/app/api/insights/ecg/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { NextRequest } from "next/server";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
Expand All @@ -48,7 +47,7 @@ export const GET = apiHandler(
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// The waveform is never interpreted, so no assistant-surface gate.

const { id } = await params;
const full = request.nextUrl.searchParams.get("full") === "1";
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/insights/ecg/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import {
} from "@/lib/api-response";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
Expand Down Expand Up @@ -113,7 +112,8 @@ export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// A pure read of the device's own recordings, so no assistant-surface
// gate.

const rows = await prisma.ecgRecording.findMany({
where: { userId: user.id },
Expand Down Expand Up @@ -181,7 +181,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// A device ingest carries no assistant prose, so switching the assistant
// off must not refuse a recording.

const rl = await checkRateLimit(
`insights:ecg:ingest:${user.id}`,
Expand Down
3 changes: 1 addition & 2 deletions src/app/api/insights/rhythm-events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
Expand All @@ -49,7 +48,7 @@ export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// A pure read of the device's own verdicts, so no assistant-surface gate.

const rows = await prisma.measurement.findMany({
where: {
Expand Down
6 changes: 6 additions & 0 deletions src/app/insights/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ function BlockSkeleton({
);
}

/** Stable reference so the trends row does not re-select on every render. */
const HIDDEN_MOOD = ["mood"] as const;

const DailyBriefing = dynamic(
() =>
import("@/components/insights/daily-briefing").then((mod) => ({
Expand Down Expand Up @@ -513,6 +516,9 @@ export default function InsightsPageClient() {
trends: (
<TrendsRow
briefing={briefingPayload}
// Same module gate the tab strip applies to the Mood pill: with
// the mood module off, the trends row does not chart mood either.
hiddenMetrics={user?.modules?.mood === false ? HIDDEN_MOOD : undefined}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
Expand Down
9 changes: 8 additions & 1 deletion src/components/insights/trends-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
selectTrendCharts,
type TrendAnnotationKey,
type TrendChartConfig,
type SelectTrendChartsOptions,
} from "@/lib/insights/trend-chart-select";
import {
TrendAnnotation,
Expand Down Expand Up @@ -65,6 +66,11 @@ const MoodChart = dynamic(
);

interface TrendsRowProps {
/**
* Metrics to leave out because their module is switched off (for
* example `mood`). Passed straight to `selectTrendCharts`.
*/
hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"];
/**
* Daily briefing payload. Drives the chart set: the row charts the
* metrics the briefing flags, in order, deduped + capped. `null` /
Expand Down Expand Up @@ -132,13 +138,14 @@ export function TrendsRow({
annotations,
confidence,
loading = false,
hiddenMetrics,
}: TrendsRowProps) {
const { t } = useTranslations();

// v1.8.5 — derive the chart set from the briefing. No new fetch: the
// briefing payload is already on the page (advisor cache), so this is
// a pure read that respects the v1.8.3 anti-freeze contract.
const charts = selectTrendCharts(briefing);
const charts = selectTrendCharts(briefing, { hiddenMetrics });

// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
Expand Down
21 changes: 21 additions & 0 deletions src/lib/insights/__tests__/trend-chart-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,24 @@ describe("selectTrendCharts", () => {
expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps");
});
});

describe("selectTrendCharts — hidden metrics", () => {
it("drops mood from the fallback triple when the mood module is off", () => {
const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] });
expect(charts.map((c) => c.metric)).not.toContain("mood");
expect(charts).toHaveLength(2);
});

it("skips a hidden metric the briefing flags", () => {
const charts = selectTrendCharts(briefing(["mood", "weight"]), {
hiddenMetrics: ["mood"],
});
expect(charts.map((c) => c.metric)).not.toContain("mood");
expect(charts).toHaveLength(1);
});

it("keeps mood when nothing is hidden", () => {
const charts = selectTrendCharts(null, { hiddenMetrics: [] });
expect(charts.map((c) => c.metric)).toContain("mood");
});
});
Loading
Loading