mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Gate the dashboard summary payload on the module map
GET /api/dashboard/summary builds its own payload instead of going through readDashboardSnapshotCached, so it never picked up the module gate the shared snapshot builder applies. It emitted glucose and sleep cards on data presence alone while /api/dashboard/snapshot correctly withheld them for the same account. Lift the drop decision out of gateSummariesByModules into disabledSummaryTypes in the client-safe widget-modules module, so the two dashboard aggregates decide from one map. The snapshot keys its summaries by MeasurementType and the summary route emits cards keyed by an iOS MetricKind, so what is shared is the decision, not the filtering; SUMMARY_METRIC_TYPE_BY_KIND is the join between the two. Omit rather than refuse, matching the snapshot sibling: the payload spans ten cards across several domains, and blanking the dashboard because one module is off would be the wrong trade. Core vitals always pass through. The filter runs outside the analytics cache on purpose. That LRU is keyed by user with a 60 s fresh / 1 h stale window and a module toggle does not evict it, so gating inside the builder would keep serving a disabled tile for up to an hour after the user turned it off. Wire shape unchanged -- a gated card is absent, not null.
This commit is contained in:
1 parent
4548df84bf
commit
a83fb6f936
5 files changed
+333
-6
No files matched your search
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* `GET /api/dashboard/summary` — module gate.
|
||||
*
|
||||
* This route builds its own payload instead of going through
|
||||
* `readDashboardSnapshotCached`, so it never picked up the gate the shared
|
||||
* snapshot builder applies: it emitted glucose and sleep cards on data
|
||||
* presence alone while `/api/dashboard/snapshot` correctly withheld them for
|
||||
* the very same account.
|
||||
*
|
||||
* OMIT, not 403 — the payload spans ten metric cards across several domains,
|
||||
* and blanking an iOS dashboard because one module is off would be the wrong
|
||||
* trade. Core vitals must keep flowing.
|
||||
*
|
||||
* Behavioural: the assertions read the emitted card list, so reverting the
|
||||
* filter in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// The gate resolver runs for real; only its data sources are stubbed, so the
|
||||
// module state is driven the way production drives it.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return { ...actual, getAssistantFlags: vi.fn() };
|
||||
});
|
||||
// The heavy builder is stubbed: this test pins the GATE, not the aggregate
|
||||
// (which has its own coverage). It returns one card per emitted kind.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
caches: { analytics: {} },
|
||||
cachedSwr: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
},
|
||||
};
|
||||
|
||||
/** Every card kind the builder can emit, one card each. */
|
||||
const ALL_KINDS = [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
return {
|
||||
id: kind,
|
||||
kind,
|
||||
titleKey: `dashboard.metric.title.${kind}`,
|
||||
latestValue: 1,
|
||||
secondaryValue: null,
|
||||
unitKey: `dashboard.metric.unit.${kind}`,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: "flat",
|
||||
sparkline: [1],
|
||||
updatedAt: new Date().toISOString(),
|
||||
allTimeCount: 5,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drive the real gate through the persisted disabled-allowlist. */
|
||||
function setDisabledModules(disabled: string[]): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])),
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
async function emittedKinds(): Promise<string[]> {
|
||||
const res = await GET(new Request("http://localhost/api/dashboard/summary"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ kind: string }> };
|
||||
};
|
||||
return body.data.metrics.map((m) => m.kind);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
|
||||
vi.mocked(cachedSwr).mockImplementation((async () => ({
|
||||
greeting: { salutation: "hi", date: new Date().toISOString() },
|
||||
streak: { currentDays: 0, longest: 0, label: "" },
|
||||
compliance: { scheduledToday: 0, takenToday: 0 },
|
||||
highlightInsight: null,
|
||||
metrics: ALL_KINDS.map(card),
|
||||
sleepRhythm: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})) as never);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary — module gate", () => {
|
||||
it("emits every card when no module is disabled", async () => {
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toEqual([...ALL_KINDS]);
|
||||
});
|
||||
|
||||
it("drops the glucose card when the glucose module is off", async () => {
|
||||
setDisabledModules(["glucose"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
// Everything else survives — proves an omission, not a refusal.
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops the sleep card when the sleep module is off", async () => {
|
||||
setDisabledModules(["sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toContain("glucose");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops both when both modules are off", async () => {
|
||||
setDisabledModules(["glucose", "sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 2);
|
||||
});
|
||||
|
||||
it("never drops a core vital, whatever is disabled", async () => {
|
||||
// Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship.
|
||||
setDisabledModules([...MODULE_KEYS]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
for (const core of [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toContain("glucose");
|
||||
|
||||
// Same cached body, module now off.
|
||||
setDisabledModules(["glucose"]);
|
||||
expect(await emittedKinds()).not.toContain("glucose");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Every module is on for the aggregate assertions below; the gate's own
|
||||
// behaviour (which cards a disabled module drops) is pinned in the sibling
|
||||
// `module-gate.test.ts`.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () =>
|
||||
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
* subsequent iOS polls inside the window hit memory. Invalidation runs
|
||||
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
* `invalidateUserMedications` hooks.
|
||||
*
|
||||
* Module gate: the emitted `metrics` array is filtered against the account's
|
||||
* module map before it leaves the handler, so a card whose measurement type
|
||||
* belongs to a disabled module never ships. The decision comes from the same
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -364,7 +372,35 @@ export const GET = apiHandler(async () => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
// ── module gate ───────────────────────────────────────────────────
|
||||
// This route builds its own payload instead of going through
|
||||
// `readDashboardSnapshotCached`, so it never picked up the module gate
|
||||
// the shared snapshot builder applies — it emitted glucose and sleep
|
||||
// tiles on data presence alone, while `/api/dashboard/snapshot` correctly
|
||||
// withheld them for the same account.
|
||||
//
|
||||
// OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is
|
||||
// an aggregate of ten metric cards across several domains; refusing the
|
||||
// whole request would blank the iOS dashboard because one module is off.
|
||||
// The card simply does not appear, which is exactly what a disabled module
|
||||
// means on every other dashboard surface.
|
||||
//
|
||||
// Applied AFTER the cache read, deliberately. The analytics LRU is keyed
|
||||
// `<userId>|dashboard-summary` with a 60 s fresh / 1 h stale window, and
|
||||
// toggling a module does not evict it — gating inside the builder would
|
||||
// leave the disabled tile served for up to an hour after the user turned
|
||||
// it off. Filtering the cached body makes the toggle effective on the very
|
||||
// next request.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules);
|
||||
annotate({
|
||||
meta: {
|
||||
metrics_emitted: metrics.length,
|
||||
metrics_gated: body.metrics.length - metrics.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...body, metrics });
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
|
||||
@@ -118,7 +118,7 @@ export {
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
WIDGET_MODULE_BY_ID,
|
||||
SUMMARY_TYPE_MODULE,
|
||||
disabledSummaryTypes,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
@@ -965,10 +965,9 @@ function gateSummariesByModules(
|
||||
summaries: Record<string, DataSummary>;
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
} {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
// The drop decision is shared with `GET /api/dashboard/summary` — see
|
||||
// `disabledSummaryTypes`. Only the filtering below is snapshot-shaped.
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return { summaries, lastSeenByType };
|
||||
const outSummaries: Record<string, DataSummary> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
|
||||
@@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// on the `nutrients` module like the rest of that store's surfaces.
|
||||
NUTRIENT_WATER: "nutrients",
|
||||
};
|
||||
|
||||
/**
|
||||
* The summary types a module map turns off. Lifted out of
|
||||
* `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two
|
||||
* dashboard aggregates that both surface per-metric data — the snapshot
|
||||
* builder and the iOS `GET /api/dashboard/summary` payload — decide from
|
||||
* ONE map instead of each carrying its own copy.
|
||||
*
|
||||
* The two callers shape their payloads differently (the snapshot keys
|
||||
* summaries by `MeasurementType`; the summary route emits an array of
|
||||
* metric cards keyed by an iOS `MetricKind`), so what is shared is this
|
||||
* decision, not the filtering itself.
|
||||
*/
|
||||
export function disabledSummaryTypes(
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric
|
||||
* card) → the `MeasurementType` it is built from. The summary route emits
|
||||
* cards by kind, but module membership is defined per measurement type in
|
||||
* `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the
|
||||
* summary payload gates off the SAME source of truth as the snapshot rather
|
||||
* than a second, drift-prone kind→module list.
|
||||
*
|
||||
* Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood
|
||||
* pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core
|
||||
* vitals and always pass through — they are listed anyway so a reader can
|
||||
* see the full emitted set and so a new card cannot be added without
|
||||
* deciding which type backs it.
|
||||
*/
|
||||
export const SUMMARY_METRIC_TYPE_BY_KIND: Record<string, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the metric cards whose backing measurement type belongs to a module
|
||||
* the account turned off. A card whose kind is absent from
|
||||
* `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric
|
||||
* or a new one, and silently hiding it would be worse than surfacing it.
|
||||
*/
|
||||
export function gateMetricCardsByModules<T extends { kind: string }>(
|
||||
cards: ReadonlyArray<T>,
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): T[] {
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return [...cards];
|
||||
return cards.filter((card) => {
|
||||
const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind];
|
||||
return !type || !dropped.has(type);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user