diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index cede31394..91447908d 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -82,6 +82,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // which gates on the `doctorReport` module directly AND through the // `collectDoctorReportData` builder. "src/app/api/fhir", + // v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards / + // correlations / derived / narrative / pregenerate / rhythm-events route + // gates on the `insights` module; the Coach sub-tree delegates to the + // coach assistant surface; the AI-settings / tile-layout / therapy-timeline + // infra-and-config routes are EXEMPT below. Walking the tree means a NEW + // ungated insights AI route fails this test BY NAME rather than leaking the + // surface over a Bearer token when the account turned insights off. + "src/app/api/insights", ]; /** @@ -124,6 +132,37 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // ── INFRA / UI-ONLY ─────────────────────────────────────────────── // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", + // ── INFRA / CONFIG (insights) ───────────────────────────────────── + // v1.18.0 (B2) — the insights tree's non-narrative routes. The + // `insights` module gates the AI-narrative SURFACES (status cards, + // correlations, derived scores, period narrative, the rhythm-event + // timeline); these six carry no narrative payload, so gating them would + // only break configuration / settings reads while the module is off. + // + // AI provider + privacy settings and the read-only chain summary — pure + // configuration of the assistant, surfaced under Settings → AI, not an + // insights surface. Editing the AI config while insights is off must work + // (e.g. to set up a provider before re-enabling the module). + "src/app/api/insights/settings/route.ts", + "src/app/api/insights/provider-chain/route.ts", + // Insights tile-order + visibility layout (GET/PUT/DELETE) — the user's + // own persisted preference blob, the insights peer of + // `/api/dashboard/widgets`. Pure UI configuration, no module data; the + // /insights page itself is nav-gated on the module. + "src/app/api/insights/layout/route.ts", + // Insight feedback write (👍/👎 on a generated card) — a feedback row, + // not an insights READ. Harmless while the module is off and never + // surfaces module data. + "src/app/api/insights/feedback/route.ts", + // Target-range reference values (BMI / BP / sleep / steps classifiers + + // compliance context) — deterministic threshold config consumed across + // surfaces, not an AI-narrative insights read. + "src/app/api/insights/targets/route.ts", + // GLP-1 therapy-timeline aggregator backing the /insights/medications + // component — a MEDICATIONS-domain data merge (dose / injection / + // inventory / side-effect events), and medications is a core, always-on + // domain with no module gate. It is not an insights-module surface. + "src/app/api/insights/glp1-timeline/route.ts", ]; const MODULE_GATE_NEEDLE = "requireModuleEnabled("; diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 8c56d6382..178b312e0 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -42,6 +52,8 @@ vi.mock("@/lib/insights/blood-pressure-status", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -58,6 +70,7 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => { @@ -89,3 +102,33 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () = expect(res.status).toBe(401); }); }); + +// v1.18.0 (B2) — the route now also requires the `insights` module. +// Representative coverage that a disabled module short-circuits with the +// 403 module.disabled envelope before any provider / cache work; the +// inventory test guards that EVERY insights AI route carries this gate. +describe("GET /api/insights/blood-pressure-status — insights module gate", () => { + it("returns 200 when insights is enabled (default)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + }); + + it("returns 403 + module.disabled when insights is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(body.meta?.module).toBe("insights"); + }); +}); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index cef396472..25e3af1da 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -8,11 +8,14 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index ac3620072..adfc00242 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -8,11 +8,14 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 0f6b2abd0..4b6b12eac 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -14,6 +14,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// (data shape + assistant-flag gating) ride through; the dedicated +// off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -37,6 +47,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { getSession } from "@/lib/auth/session"; const SESSION_OK = { @@ -46,6 +57,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 3e91d9748..3d83207cc 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -8,6 +8,7 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -57,6 +58,8 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // v1.4.31 — the iOS cards adapter feeds the same per-metric // insight surfaces the web `` mounts on each // /insights/ sub-page. Both share the operator gate. diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 3899fa947..04677474c 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -11,6 +11,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate +// inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -41,6 +51,8 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -50,6 +62,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ allowed: true, @@ -147,4 +160,25 @@ describe("GET /api/insights/correlations", () => { // The limited request never reaches the series reads. expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); + + // v1.18.0 (B2) — the route now also requires the `insights` module. + it("returns 403 + module.disabled when the insights module is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(body.meta?.module).toBe("insights"); + // The disabled-module request never reaches the series reads. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index c7b1513cb..3ff9b0da6 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -20,6 +20,7 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -73,6 +74,9 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index f6f8802a1..8d75f48d5 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -13,6 +13,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()` (in addition to the existing per-metric sleep/recovery +// gate). Mock it default-enabled so the existing assertions ride through; +// the off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index 3bfb6cf2e..c3469b575 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -66,11 +66,19 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => { // every module enabled) and the profile-read count isn't perturbed. vi.mock("@/lib/modules/gate", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, resolveModuleMap: vi.fn() }; + return { + ...actual, + resolveModuleMap: vi.fn(), + // v1.18.0 — the route gates on the `insights` module after + // `requireAuth()`. Default-enabled so the existing per-metric + // resolveModuleMap assertions ride through; the off → 403 coverage + // lives in the route-gate inventory test. + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + }; }); import { GET } from "../route"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { resolveModuleMap, requireModuleEnabled } from "@/lib/modules/gate"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -106,6 +114,7 @@ beforeEach(() => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: new Date("1986-01-01"), gender: "MALE", diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 42b76a802..ae7f56332 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,7 +39,7 @@ import { } from "@/lib/insights/derived"; import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { DERIVED_MODULE } from "../route"; export const dynamic = "force-dynamic"; @@ -115,6 +115,8 @@ function parseBatchItems(csv: string): { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); // Per-user limiter, same posture as the compliance routes: the cold diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 65ca0f72d..5a008ceca 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -71,6 +71,9 @@ const derivedQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + 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) { diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 32f54214c..c4922b321 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -8,11 +8,14 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index 37aab757f..208be55a6 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()` (in addition to the existing per-metric sleep/glucose/ +// recovery gate). Mock it default-enabled so the existing assertions ride +// through; the off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -43,6 +53,7 @@ vi.mock("@/lib/insights/metric-status", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateMetricStatus } from "@/lib/insights/metric-status"; const SESSION_OK = { @@ -60,6 +71,7 @@ function makeReq(metric?: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("GET /api/insights/metric-status", () => { diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index 3f6f88141..e6b62286e 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -62,6 +62,8 @@ const metricQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const parsed = metricQuerySchema.safeParse({ diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index adc0cae74..fcf72828d 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -15,6 +15,9 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + // Per-domain gate: the mood module must be enabled for this account // before the mood AI-status surface is served (mirrors the cycle gate). const gate = await requireModuleEnabled(user.id, "mood"); diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index ff7909ed4..266dfdf04 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -18,6 +18,7 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; @@ -39,6 +40,8 @@ function narrowLocale(locale: string): "de" | "en" { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const parsed = narrativeQuerySchema.safeParse({ diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index a4852bc19..9e5c76c98 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -42,6 +52,7 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ import { POST } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; const SESSION_OK = { @@ -60,6 +71,7 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("POST /api/insights/pregenerate", () => { diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index ec5e41380..411803cc4 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -26,6 +26,7 @@ import { NextRequest } from "next/server"; import { apiSuccess, apiError } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -38,6 +39,8 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // Gate on the same surface as the read-only status routes: this warms // the assessment cards (the `insightStatus` surface), not the Coach. // A user with assessments enabled but Coach disabled can still warm. diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 72f67afec..dd22e87f8 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -8,11 +8,14 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/rhythm-events/__tests__/route.test.ts b/src/app/api/insights/rhythm-events/__tests__/route.test.ts index fbc29ca93..e2d0860b0 100644 --- a/src/app/api/insights/rhythm-events/__tests__/route.test.ts +++ b/src/app/api/insights/rhythm-events/__tests__/route.test.ts @@ -18,6 +18,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 9d45cb7d0..ca98cc69d 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -28,6 +28,7 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } 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"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -42,6 +43,8 @@ const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[] export const GET = apiHandler(async () => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const rows = await prisma.measurement.findMany({ diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 511ade38c..124eaff0f 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -8,11 +8,14 @@ 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 { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index dd902081a..b1de1dbd8 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -150,6 +150,27 @@ describe(" iOS-parity layout", () => { mockUserRef.value = { id: "u1", modules: {} }; }); + it("drops the Insights primary slot when the insights module is off (v1.18.0)", () => { + // The Insights slot is a fixed primary anchor, but it is now + // module-gated: a disabled insights module must remove it from the + // strip (and it is excluded from the More hub by the primary-slot + // filter, so the module is hidden everywhere, not relocated). + mockUserRef.value = { id: "u1", modules: { insights: false } }; + const html = render(); + expect(html).not.toContain('href="/insights"'); + // Sibling primary slots are unaffected. + expect(html).toContain('href="/"'); + expect(html).toContain('href="/medications"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("keeps the Insights primary slot when the insights module is on", () => { + mockUserRef.value = { id: "u1", modules: { insights: true } }; + const html = render(); + expect(html).toContain('href="/insights"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + it("gating on a disabled module never throws (v1.18.0)", () => { mockUserRef.value = { id: "u1", diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 44ff3ce31..7b43cc980 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -115,17 +115,21 @@ describe("visibleNavDestinations module gate", () => { expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); - it("drops a module-gated entry (mood / labs / coach / achievements) when its module is disabled", () => { + it("drops a module-gated entry (mood / labs / coach / achievements / insights) when its module is disabled", () => { const disabled = visibleNavDestinations({ mood: false, labs: false, coach: false, achievements: false, + // v1.18.0 — Insights is now `requiresModule: "insights"`, so the + // top-level /insights entry drops when the module is off. + insights: false, }).map((d) => d.href); expect(disabled).not.toContain("/mood"); expect(disabled).not.toContain("/labs"); expect(disabled).not.toContain("/coach"); expect(disabled).not.toContain("/achievements"); + expect(disabled).not.toContain("/insights"); }); it("keeps every module-gated entry when its module is enabled", () => { @@ -134,11 +138,13 @@ describe("visibleNavDestinations module gate", () => { labs: true, coach: true, achievements: true, + insights: true, }).map((d) => d.href); expect(enabled).toContain("/mood"); expect(enabled).toContain("/labs"); expect(enabled).toContain("/coach"); expect(enabled).toContain("/achievements"); + expect(enabled).toContain("/insights"); }); it("fails open: a missing key or an undefined map keeps the gated entry", () => { diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index d4391bf4c..2ffd8c6a1 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -16,6 +16,7 @@ import { isNavDestinationActive, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; +import type { ModuleKey } from "@/lib/modules/registry"; import { useMemo, useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; import { useAuth } from "@/hooks/use-auth"; @@ -57,8 +58,21 @@ const PRIMARY_LEFT: ReadonlyArray = [ { href: "/medications", tKey: "nav.medications", icon: Pill }, ]; -const PRIMARY_RIGHT: ReadonlyArray = [ - { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, +// v1.18.0 — the Insights primary slot is module-gated. Insights is now a +// toggleable module (`requiresModule: "insights"` in the shared nav model), +// so the fixed slot must respect the same per-user map the More hub already +// honours rather than pinning a destination the account turned off. When +// insights is disabled the slot is dropped (it is excluded from the More hub +// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden +// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps +// the slot, mirroring the gate's default-on contract. +const PRIMARY_RIGHT: ReadonlyArray = [ + { + href: "/insights", + tKey: "nav.insights", + icon: Lightbulb, + requiresModule: "insights", + }, ]; export function BottomNav() { @@ -86,6 +100,18 @@ export function BottomNav() { [user?.modules, bugReportEnabled], ); + // v1.18.0 — drop a module-gated primary slot (Insights) when the account + // has that module disabled. Fail-open: a missing key / unloaded map keeps + // the slot, mirroring the gate + More-hub default-on contract. + const primaryRight = useMemo( + () => + PRIMARY_RIGHT.filter( + (item) => + !item.requiresModule || user?.modules?.[item.requiresModule] !== false, + ), + [user?.modules], + ); + function isActiveLink(href: string) { return isNavDestinationActive(href, pathname); } @@ -160,7 +186,7 @@ export function BottomNav() { - {PRIMARY_RIGHT.map(renderPrimary)} + {primaryRight.map(renderPrimary)}