mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(insights): keep the overview reads open with the assistant switched off
With the assistant switched off on the server, the Insights overview did not load. Its main read, /api/insights/comprehensive, was gated on the Coach surface, and the derived tiles, the ECG list, strip and live ingest, and the rhythm events were gated on the status-card surface. The master flag forces every surface off, so all of them answered 403 and the page replaced the whole overview with an error. None of these handlers calls a provider or returns assistant prose. They now gate on the insights module only, like health-status, labs-changes and patterns already do. comprehensive had no module gate of its own, only the Coach gate, so it gains the insights module gate the other five already call. Routes that serve generated text keep their gate. The route-gate inventory moves the six files onto the not-Coach-owned list, four OpenAPI descriptions drop the gate they no longer have, and a comprehensive test pins a 200 with the master flag off. Refs #975
This commit is contained in:
1 parent
114f8e9fa6
commit
c7f7a2925f
10 files changed
+82
-46
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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")';
|
||||
|
||||
@@ -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";
|
||||
@@ -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({
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
@@ -49,6 +49,8 @@ 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);
|
||||
@@ -56,9 +58,10 @@ export const GET = apiHandler(async () => {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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";
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user