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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 01:39:18 +02:00
1 parent 00d16ea275
commit ca4952b2c8
3 files changed
+41 -7

No files matched your search

@@ -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<typeof import("@/lib/modules/gate")>();
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",
+23 -3
View File
@@ -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 });
});
/**
+8 -4
View File
@@ -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<Record<DerivedMetricId, ModuleKey>> = {
export const DERIVED_MODULE: Partial<Record<DerivedMetricId, ModuleKey>> = {
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