From ca4952b2c8ce65fd6bf199ded333e46a27a85f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:39:18 +0200 Subject: [PATCH] fix(insights): gate the batch derived route on the module map The batch derived endpoint (the one the dashboard wellness strip reads) computed Recovery/Strain/Stress unconditionally while the single derived route already gated them, so disabling the recovery module did not hide the rings. Reuse the single route's DERIVED_MODULE map (now exported, with READINESS added since it equals the computed recovery score) and drop disabled-module metrics from the batch payload per request, without mutating the cached map. --- .../derived/batch/__tests__/route.test.ts | 10 +++++++ src/app/api/insights/derived/batch/route.ts | 26 ++++++++++++++++--- src/app/api/insights/derived/route.ts | 12 ++++++--- 3 files changed, 41 insertions(+), 7 deletions(-) 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 9695e352b..3bfb6cf2e 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -61,7 +61,16 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => { }; }); +// v1.18.0 — the route resolves the per-user module map to drop disabled- +// module derived scores; stub it so the batch tests stay focused (default: +// 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() }; +}); + import { GET } from "../route"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -96,6 +105,7 @@ beforeEach(() => { __resetAllCachesForTests(); 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(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 271d88783..42b76a802 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,6 +39,8 @@ 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 { DERIVED_MODULE } from "../route"; export const dynamic = "force-dynamic"; @@ -201,17 +203,35 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate, ); + // v1.18.0 — module gate. A derived score native to a toggleable module + // (sleep / recovery, per DERIVED_MODULE — the SAME map the single route + // uses, so the two cannot drift) must not reach the client when that + // module is disabled for the user. Resolved per request (NOT folded into + // the SWR key) and applied to a NEW object so the cached map is never + // mutated; a module toggle takes effect on the next read. + const moduleMap = await resolveModuleMap(user.id); + const gatedMetrics = Object.fromEntries( + Object.entries(map).filter(([key]) => { + const metric = key.includes(":") ? key.slice(0, key.indexOf(":")) : key; + const mod = DERIVED_MODULE[metric as DerivedMetricId]; + return !(mod && moduleMap[mod] === false); + }), + ); + annotate({ action: { name: "insights.derived.batch" }, meta: { requested: items.length, - ok: Object.values(map).filter((entry) => entry.status === "ok").length, + ok: Object.values(gatedMetrics).filter((entry) => entry.status === "ok") + .length, + moduleGated: Object.keys(map).length - Object.keys(gatedMetrics).length, }, }); // `metrics` is a map keyed by the per-request token so the client reads - // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). - return apiSuccess({ metrics: map }); + // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`), + // minus any metric whose owning module the user disabled. + return apiSuccess({ metrics: gatedMetrics }); }); /** diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index c06c5eaae..65ca0f72d 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -41,15 +41,19 @@ export const dynamic = "force-dynamic"; /** * Derived scores that are NATIVE to a toggleable module rather than a core * vital. SLEEP_SCORE is the sleep module's; the strain / recovery / stress - * trio belongs to the WHOOP/Polar recovery module. The composite baselines - * (VITALS_BASELINE, READINESS, HRV_BALANCE, …) read off core vitals and - * stay open — a metric absent from this map carries no module gate. + * trio belongs to the WHOOP/Polar recovery module. READINESS equals the + * computed recovery score (same number, recovery-native), so it is gated on + * the recovery module too. The remaining composite baselines + * (VITALS_BASELINE, HRV_BALANCE, …) read off core vitals and stay open — a + * metric absent from this map carries no module gate. Exported so the batch + * route gates on the SAME map and the two routes cannot drift. */ -const DERIVED_MODULE: Partial> = { +export const DERIVED_MODULE: Partial> = { SLEEP_SCORE: "sleep", RECOVERY_SCORE: "recovery", STRAIN_SCORE: "recovery", STRESS_SCORE: "recovery", + READINESS: "recovery", }; // Closed enum derived from the registry so the route + registry cannot