mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge(reconcile): b2-insightsai audit findings
This commit is contained in:
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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user