merge(reconcile): b2-insightsai audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:46:34 +02:00
commit 3be34ceb11
27 files changed
+283 -7

No files matched your search

@@ -82,6 +82,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// 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<string> = [
// ── 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(";
@@ -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<typeof import("@/lib/modules/gate")>()),
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");
});
});
@@ -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");
+3
View File
@@ -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");
@@ -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<typeof import("@/lib/modules/gate")>()),
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(
+3
View File
@@ -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 `<InsightStatusCard>` mounts on each
// /insights/<metric> sub-page. Both share the operator gate.
@@ -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<typeof import("@/lib/modules/gate")>()),
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();
});
});
@@ -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");
@@ -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<typeof import("@/lib/modules/gate")>()),
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),
@@ -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<typeof import("@/lib/modules/gate")>();
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",
+3 -1
View File
@@ -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
+3
View File
@@ -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) {
@@ -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");
@@ -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<typeof import("@/lib/modules/gate")>()),
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", () => {
@@ -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({
@@ -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");
+3
View File
@@ -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({
@@ -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<typeof import("@/lib/modules/gate")>()),
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", () => {
@@ -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.
@@ -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");
@@ -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<typeof import("@/lib/modules/gate")>()),
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),
@@ -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({
@@ -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");
@@ -150,6 +150,27 @@ describe("<BottomNav> 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",
@@ -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", () => {
+29 -3
View File
@@ -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<NavLink> = [
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ 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<NavLink & { requiresModule?: ModuleKey }> = [
{
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() {
</button>
</div>
{PRIMARY_RIGHT.map(renderPrimary)}
{primaryRight.map(renderPrimary)}
<button
type="button"
+1
View File
@@ -118,6 +118,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
requiresModule: "insights",
},
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty