diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index c22a3e35b..a22642d60 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -83,7 +83,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 { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; @@ -100,6 +100,10 @@ beforeEach(() => { // prior test would serve a hit and skip the builder every assertion spies on. __resetAllCachesForTests(); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + // Every module on unless a test says otherwise. + vi.mocked(resolveModuleMap).mockResolvedValue( + {} as Awaited>, + ); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ allowed: true, @@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => { ); }); + it("skips the lab draws with the labs module off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(resolveModuleMap).mockResolvedValue({ + labs: false, + } as Awaited>); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + }); + + it("recomputes rather than serve a cached body after a module was switched off", async () => { + // The switched-off set is part of the cache key. Watched red with the + // suffix removed from the key: the second call is a cache hit and the + // measurement window is read once. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq()); + vi.mocked(resolveModuleMap).mockResolvedValue({ + mood: false, + } as Awaited>); + await callGet(makeReq()); + expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2); + }); + // 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); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 20eab84b8..784d37d2f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + MODULE_KEYS, + requireModuleEnabled, + resolveModuleMap, +} from "@/lib/modules/gate"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -89,12 +94,18 @@ export const GET = apiHandler(async () => { // `invalidateUserCorrelationPatterns`, which sweeps it too. The // `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder, // so a cache hit performs no write on the read path. + // Statistics serve only with their owning modules: a channel whose module + // is off never enters the scan. The switched-off set is part of the cache + // key, so toggling a module never serves a body computed under the other + // setting. + const modules = await resolveModuleMap(user.id); + const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false); const body = await cachedSwr( caches.analytics as ServerCache< Awaited> >, - `${user.id}|correlations|${locale}`, - () => buildCorrelationsResponse(user.id, locale), + `${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`, + () => buildCorrelationsResponse(user.id, locale, modules), annotate, ); @@ -104,6 +115,7 @@ export const GET = apiHandler(async () => { async function buildCorrelationsResponse( userId: string, locale: Awaited>, + modules: SurfaceModuleMap, ) { const profile = await prisma.user.findUnique({ where: { id: userId }, @@ -125,8 +137,15 @@ async function buildCorrelationsResponse( // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed // a different pass over a different grain, so they are not matrix channels. const [matrix, labDraws] = await Promise.all([ - assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }), - fetchLabDraws(userId, tz, since), + assembleDiscoveryMatrix(userId, { + tz, + since, + fetchMode: "tiered", + modules, + }), + isSurfaceVisible("correlation:LAB_DRAWS", modules) + ? fetchLabDraws(userId, tz, since) + : Promise.resolve([]), ]); const { series, diagnostics } = matrix; diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts new file mode 100644 index 000000000..882e89f0a --- /dev/null +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -0,0 +1,137 @@ +/** + * The discovery matrix leaves out every channel whose module the record + * switched off, through the surface map, so no correlation statistic is + * computed over a switched-off module. + */ +import { describe, expect, it, vi } from "vitest"; + +const pts = [{ day: "2026-09-01", value: 1 }]; + +vi.mock("@/lib/insights/correlation-channel-series", () => ({ + buildMeasurementDailySeries: vi.fn(), + fetchMeasurementWindowSeries: vi.fn(), + fetchMeasurementDailySeriesTiered: vi.fn( + async (_u: string, _tz: string, _s: Date, types: string[]) => ({ + byType: new Map(types.map((t) => [t, pts])), + measurementsCapped: false, + rollupTypes: [], + }), + ), + fetchMoodWindowSeries: vi.fn(async () => ({ + moodDaily: pts, + moodCapped: false, + })), + fetchMoodFactorWindowSeries: vi.fn(async () => ({ + moodDaily: pts, + moodCapped: false, + factorSeries: new Map([["FACTOR:work", pts]]), + })), + fetchComplianceSeries: vi.fn(async () => ({ + key: "MEDICATION_COMPLIANCE", + role: "behaviour", + points: pts, + })), + fetchSymptomSeries: vi.fn(async () => ({ + key: "SYMPTOM_SEVERITY", + role: "outcome", + points: pts, + })), + fetchEnvironmentSeries: vi.fn(async () => [ + { key: "ENV_TEMP_MEAN", role: "behaviour", points: pts }, + ]), + fetchCustomMetricBehaviourSeries: vi.fn(async () => [ + { key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts }, + ]), +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(), +})); + +import { + assembleDiscoveryMatrix, + maskSeriesByModules, +} from "@/lib/insights/discovery-matrix"; + +const OPTS = { + tz: "UTC", + since: new Date("2026-03-01T00:00:00Z"), + fetchMode: "tiered" as const, + includeMoodFactors: true, +}; + +const keysOf = (series: { key: string }[]) => [ + ...new Set(series.map((s) => s.key)), +]; + +describe("assembleDiscoveryMatrix — switched-off modules", () => { + it("keeps every channel without a module map", async () => { + const { series } = await assembleDiscoveryMatrix("u1", OPTS); + const keys = keysOf(series); + for (const key of [ + "MOOD", + "FACTOR:work", + "SLEEP_DURATION", + "BLOOD_GLUCOSE", + "MEDICATION_COMPLIANCE", + "SYMPTOM_SEVERITY", + "ENV_TEMP_MEAN", + "CUSTOM_METRIC:abc", + "WEIGHT", + ]) { + expect(keys).toContain(key); + } + }); + + it("drops each module's channels, and only those, when it is off", async () => { + const { series, byMetric } = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { + mood: false, + sleep: false, + glucose: false, + medications: false, + illness: false, + environment: false, + }, + }); + const keys = keysOf(series); + for (const gone of [ + "MOOD", + "FACTOR:work", + "SLEEP_DURATION", + "BLOOD_GLUCOSE", + "MEDICATION_COMPLIANCE", + "SYMPTOM_SEVERITY", + "ENV_TEMP_MEAN", + ]) { + expect(keys, gone).not.toContain(gone); + expect(byMetric.has(gone), gone).toBe(false); + } + // Core vitals and custom metrics belong to no module. + expect(keys).toContain("WEIGHT"); + expect(keys).toContain("BLOOD_PRESSURE_SYS"); + expect(keys).toContain("CUSTOM_METRIC:abc"); + }); + + it("drops nothing for AI analysis off", async () => { + const all = await assembleDiscoveryMatrix("u1", OPTS); + const off = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { insights: false }, + }); + expect(off.series.map((s) => s.key)).toEqual(all.series.map((s) => s.key)); + }); +}); + +describe("maskSeriesByModules", () => { + it("keeps the fold order of what remains", () => { + const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({ + key, + role: "behaviour" as const, + points: pts, + })); + expect( + maskSeriesByModules(series, { mood: false }).map((s) => s.key), + ).toEqual(["WEIGHT", "PULSE"]); + }); +}); diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index d5dc07993..49cde8d4a 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -57,6 +57,11 @@ import { type MoodFactorWindowFetch, } from "@/lib/insights/correlation-channel-series"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + correlationChannelSurfaceId, + isSurfaceVisible, + type SurfaceModuleMap, +} from "@/lib/modules/surface"; /** * How the measurement channels are read. @@ -117,6 +122,14 @@ export interface DiscoveryMatrixOptions { * measurement read instead of a second one. */ extraMeasurementTypes?: readonly MeasurementType[]; + /** + * The record's resolved module map. A channel whose module is off + * (`correlation:` in the surface map: mood and its rated factors, + * sleep, glucose, medication adherence, symptoms, weather) is left out of + * `series` and `byMetric`, so no statistic is computed over a module the + * person switched off. Omitted: every channel is kept. + */ + modules?: SurfaceModuleMap; } /** Per-channel reach, for the callers' wide-event annotations. */ @@ -150,6 +163,20 @@ export interface DiscoveryMatrix { diagnostics: DiscoveryMatrixDiagnostics; } +/** + * Leave out every channel whose owning module is off. Pure, and applied after + * the fold so the order of what remains is the order it was folded in (the + * ranking stability note on {@link assembleDiscoveryMatrix}). + */ +export function maskSeriesByModules( + series: readonly NamedSeries[], + modules: SurfaceModuleMap | null | undefined, +): NamedSeries[] { + return series.filter((s) => + isSurfaceVisible(correlationChannelSurfaceId(s.key), modules), + ); +} + /** * The mood read, normalised to the factor-carrying shape. With factors off the * factor map is empty, so the fold below has one code path rather than two. @@ -302,8 +329,14 @@ export async function assembleDiscoveryMatrix( byMetric.set(key, factorPoints); } + for (const key of [...byMetric.keys()]) { + if (!isSurfaceVisible(correlationChannelSurfaceId(key), opts.modules)) { + byMetric.delete(key); + } + } + return { - series, + series: maskSeriesByModules(series, opts.modules), byMetric, diagnostics: { measurementsCapped: measurements.measurementsCapped,