mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user