fix(insights): leave switched-off modules out of the correlation statistics

The correlation scan still paired mood, sleep, glucose, adherence,
symptoms and weather with everything else after their modules were
switched off. The discovery matrix now drops every channel whose module
is off, read through the surface map, and the correlations route passes
the record's module map, skips the lab draws with labs off, and keys its
cache on the switched-off set.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:54:25 +02:00
1 parent 9f98636a43
commit 8ae0f75ecd
4 files changed
+223 -7

No files matched your search

@@ -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<ReturnType<typeof resolveModuleMap>>,
);
// 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<ReturnType<typeof resolveModuleMap>>);
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<ReturnType<typeof resolveModuleMap>>);
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);
+24 -5
View File
@@ -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<ReturnType<typeof buildCorrelationsResponse>>
>,
`${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<ReturnType<typeof resolveServerLocale>>,
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;