From 4cf923647ddb606f989b02c63c59d7e60f694c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:40:38 +0200 Subject: [PATCH 1/3] feat(insights): gate every AI-narrative insights route on the insights module Require the `insights` module (in addition to the existing assistant-surface gate and any per-metric data-module gate) on all 14 narrative insights routes: blood-pressure / bmi / pulse / weight / medication-compliance / mood / metric / derived / derived-batch status, cards, correlations, narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope now precedes any provider or cache work when the account has insights off. The per-metric sleep/glucose/recovery/mood gates and the requireAssistantSurface("insightStatus"|"correlations") checks are kept; this adds the module layer on top of them. Route tests that now resolve the gate mock @/lib/modules/gate default-enabled so their existing data-shape and assistant-flag assertions ride through unchanged. --- .../blood-pressure-status/__tests__/route.test.ts | 12 ++++++++++++ src/app/api/insights/blood-pressure-status/route.ts | 3 +++ src/app/api/insights/bmi-status/route.ts | 3 +++ src/app/api/insights/cards/__tests__/route.test.ts | 12 ++++++++++++ src/app/api/insights/cards/route.ts | 3 +++ .../insights/correlations/__tests__/route.test.ts | 12 ++++++++++++ src/app/api/insights/correlations/route.ts | 4 ++++ .../api/insights/derived/__tests__/route.test.ts | 10 ++++++++++ .../insights/derived/batch/__tests__/route.test.ts | 13 +++++++++++-- src/app/api/insights/derived/batch/route.ts | 4 +++- src/app/api/insights/derived/route.ts | 3 +++ .../insights/medication-compliance-status/route.ts | 3 +++ .../insights/metric-status/__tests__/route.test.ts | 12 ++++++++++++ src/app/api/insights/metric-status/route.ts | 2 ++ src/app/api/insights/mood-status/route.ts | 3 +++ src/app/api/insights/narrative/route.ts | 3 +++ .../insights/pregenerate/__tests__/route.test.ts | 12 ++++++++++++ src/app/api/insights/pregenerate/route.ts | 3 +++ src/app/api/insights/pulse-status/route.ts | 3 +++ .../insights/rhythm-events/__tests__/route.test.ts | 10 ++++++++++ src/app/api/insights/rhythm-events/route.ts | 3 +++ src/app/api/insights/weight-status/route.ts | 3 +++ 22 files changed, 133 insertions(+), 3 deletions(-) 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..864d274b8 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,7 @@ 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"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -58,6 +69,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", () => { 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..736fc6925 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,7 @@ 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 { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -50,6 +61,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, 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"); From b2684a2e21edf8367cadb4c28d5021c9441e49e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:42:43 +0200 Subject: [PATCH 2/3] feat(nav): gate the Insights destination on the insights module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the top-level `/insights` nav destination `requiresModule: "insights"` so it drops from both bars when the account has the insights module off, mirroring mood / labs / coach / achievements. The bottom-nav pinned `/insights` as a fixed primary slot regardless of the module map; it now filters that slot through the same per-user map the More hub honours (fail-open on a missing key / unloaded /me payload). A disabled insights module is hidden everywhere — the primary-slot filter keeps it out of the More hub too, so it is hidden, not relocated. Extend the nav-model + bottom-nav tests with insights on/off coverage. --- .../layout/__tests__/bottom-nav.test.tsx | 21 ++++++++++++ .../layout/__tests__/nav-model.test.ts | 8 ++++- src/components/layout/bottom-nav.tsx | 32 +++++++++++++++++-- src/components/layout/nav-model.ts | 1 + 4 files changed, 58 insertions(+), 4 deletions(-) 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)}