mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(modules): gate insights status routes behind module flags
This commit is contained in:
commit
1bb2a661d2
4 files changed
+171
No files matched your search
@@ -25,6 +25,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
@@ -37,6 +38,20 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
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.
|
||||
*/
|
||||
const DERIVED_MODULE: Partial<Record<DerivedMetricId, ModuleKey>> = {
|
||||
SLEEP_SCORE: "sleep",
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRAIN_SCORE: "recovery",
|
||||
STRESS_SCORE: "recovery",
|
||||
};
|
||||
|
||||
// Closed enum derived from the registry so the route + registry cannot
|
||||
// drift — an id added to the registry is accepted automatically, an
|
||||
// unknown id 422s rather than silently resolving to no metric.
|
||||
@@ -73,6 +88,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const metric = parsed.data.metric as DerivedMetricId;
|
||||
|
||||
// Per-domain gate: a derived score owned by a toggleable module
|
||||
// (sleep / recovery) is refused with a 403 module.disabled envelope when
|
||||
// the account has that module turned off. Core-vital composites stay open.
|
||||
const moduleKey = DERIVED_MODULE[metric];
|
||||
if (moduleKey) {
|
||||
const gate = await requireModuleEnabled(user.id, moduleKey);
|
||||
if (!gate.enabled) return gate.response;
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader (the same one the batch route
|
||||
// and the nightly score jobs use), passed into the pure compute function
|
||||
// — never re-fetched per metric.
|
||||
|
||||
@@ -27,6 +27,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
generateMetricStatus,
|
||||
resolveMetricStatusLocale,
|
||||
@@ -38,6 +39,20 @@ import {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The handful of generic metrics whose assessment belongs to a toggleable
|
||||
* module rather than a core vital. Everything else this route serves
|
||||
* (resting heart rate, body composition, gait, environmental exposure, …)
|
||||
* is a core/always-on signal and stays ungated. A metric absent from this
|
||||
* map is core by construction.
|
||||
*/
|
||||
const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
|
||||
SLEEP_DURATION: "sleep",
|
||||
BREATHING_DISTURBANCES: "sleep",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
CARDIO_RECOVERY: "recovery",
|
||||
};
|
||||
|
||||
// Closed enum derived from the registry so the route + registry cannot
|
||||
// drift — an id added to the registry is accepted automatically, and an
|
||||
// unknown id 422s rather than silently resolving to no card.
|
||||
@@ -61,6 +76,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const metric = parsed.data.metric as MetricStatusMetricId;
|
||||
|
||||
// Per-domain gate: a metric that belongs to a toggleable module
|
||||
// (sleep / glucose / recovery) is refused with a 403 module.disabled
|
||||
// envelope when the account has that module turned off. Core vitals,
|
||||
// body composition, gait and environmental signals carry no module and
|
||||
// stay open.
|
||||
const moduleKey = METRIC_MODULE[metric];
|
||||
if (moduleKey) {
|
||||
const gate = await requireModuleEnabled(user.id, moduleKey);
|
||||
if (!gate.enabled) return gate.response;
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the heavy generator so the route test stays isolated.
|
||||
vi.mock("@/lib/insights/mood-status", () => ({
|
||||
generateMoodStatusForUser: vi.fn(async () => ({
|
||||
hasProvider: true,
|
||||
text: "ok",
|
||||
cached: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
resolveMoodStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/mood-status"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Persisted module-preferences blob for the gated user row. */
|
||||
function userRow(modulePreferencesJson: unknown) {
|
||||
return {
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/mood-status — module gate", () => {
|
||||
it("200s when the mood module is enabled (default-on, no blob)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
userRow(null) as never,
|
||||
);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { text: string } | null };
|
||||
expect(body.data?.text).toBe("ok");
|
||||
expect(generateMoodStatusForUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("403s + module.disabled when mood is turned off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
userRow({ mood: false }) as never,
|
||||
);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
expect(body.meta?.module).toBe("mood");
|
||||
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,18 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-domain gate: the mood module must be enabled for this account
|
||||
// before the mood AI-status surface is served (mirrors the cycle gate).
|
||||
const gate = await requireModuleEnabled(user.id, "mood");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
|
||||
Reference in new issue
Block a user