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] 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");