From fb13759f458580167de67a614c38a59c7cb391c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:13:16 +0200 Subject: [PATCH] feat(modules): gate insights status routes behind module flags The derived-score, metric-status and mood-status insight routes serve a mix of core-vital and toggleable-module signals. Map the module-owned metric ids (sleep/recovery/glucose) to their module key and refuse with the shared 403 module.disabled envelope when the account has that module disabled; mood-status gates wholesale on the mood module, mirroring the cycle gate. Metrics absent from the map are core vitals and stay open. --- src/app/api/insights/derived/route.ts | 24 ++++ src/app/api/insights/metric-status/route.ts | 26 ++++ .../mood-status/__tests__/route.test.ts | 114 ++++++++++++++++++ src/app/api/insights/mood-status/route.ts | 7 ++ 4 files changed, 171 insertions(+) create mode 100644 src/app/api/insights/mood-status/__tests__/route.test.ts diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 8c0f9d0e8..c06c5eaae 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -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> = { + 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. diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index 3887c1eca..3f6f88141 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -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> = { + 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, diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts new file mode 100644 index 000000000..eaf859da7 --- /dev/null +++ b/src/app/api/insights/mood-status/__tests__/route.test.ts @@ -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; +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); + }); +}); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index c47ff442a..adc0cae74 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -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");