fix(analytics): gate glucose clinical panel behind the glucose module

The default /api/analytics slice served the per-context glucose
summaries and the TIR / GMI / eA1C clinical panel unconditionally,
leaking a disabled-glucose account's readings to a surface the
account has turned off. The route also serves core weight / BP /
pulse metrics, so a 403 on the whole payload is wrong.

Resolve the glucose module once server-side and null out
glucoseByContext + glucoseClinical when it is off; the core metrics
keep flowing. Mirrors the mood-pillar gate already in this route.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 01:55:03 +02:00
1 parent 69fe67e2de
commit 8cc879f6dc
2 files changed
+70 -2

No files matched your search

@@ -363,6 +363,66 @@ describe("GET /api/analytics", () => {
expect(body.data.lastSeenByType.PULSE).toBeNull();
});
// v1.18.0 B1 — the default slice serves the glucose-only surfaces
// (per-context summaries + the TIR/GMI/eA1C clinical panel) only when
// the `glucose` module is enabled. A disabled-glucose account nulls
// both fields rather than 403-ing the whole core-metrics payload.
function seedGlucoseRows() {
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
vi.mocked(prisma.measurement.findMany).mockImplementation(
(async (args: { where?: { type?: string } }) => {
if (args?.where?.type === "BLOOD_GLUCOSE") {
return [
{ value: 95, measuredAt, glucoseContext: "FASTING" },
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
];
}
return [];
}) as never,
);
}
it("serves glucose clinical + per-context when glucose enabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
};
};
expect(body.data.glucoseClinical).not.toBeNull();
expect(body.data.glucoseByContext).not.toBeNull();
expect(body.data.glucoseByContext?.FASTING).toBeDefined();
});
it("nulls glucose clinical + per-context when glucose disabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockImplementation(
(async (_userId: string, key: string) => key !== "glucose") as never,
);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
summaries: Record<string, { count: number }>;
};
};
// Core metrics still served — the route did NOT 403.
expect(body.data.summaries.WEIGHT.count).toBe(0);
// Glucose-only surfaces are nulled.
expect(body.data.glucoseClinical).toBeNull();
expect(body.data.glucoseByContext).toBeNull();
});
// v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked`
// fan-out was removed entirely; the default slice now delegates to
// `computeSummariesSlice` which runs three rollup-tier `$queryRaw`
+10 -2
View File
@@ -427,6 +427,14 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
// route serves core metrics too, so a disabled-glucose account must NOT
// 403 the whole payload; instead the glucose-only surfaces (per-context
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
// never reach a hidden surface. Mirrors the mood-pillar gate above.
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
@@ -447,8 +455,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
bpInTargetPctPriorYear,
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
glucoseByContext: glucoseByContextOut,
glucoseClinical: glucoseClinicalOut,
correlations,
healthScore,
sleepStages,