diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts new file mode 100644 index 000000000..44d04cb2c --- /dev/null +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { 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 gate test stays isolated from the +// real status pipeline. +vi.mock("@/lib/insights/blood-pressure-status", () => ({ + generateBloodPressureStatusForUser: vi.fn(async () => ({ + available: true, + locale: "en", + text: "ok", + })), + resolveBloodPressureStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +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: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + "http://localhost/api/insights/blood-pressure-status", + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => { + it("returns 200 when insightStatus is enabled (default)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + }); + + it("returns 403 + errorCode when insightStatus is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); + + it("returns 401 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/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 5f1c6da18..4739935bd 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index 2dad4022d..1ea982caf 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 0f1a471f7..054ea6182 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -7,6 +7,10 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + // v1.4.31 — gated on `assistant.insightStatus`; null row falls + // back to the all-on default so existing assertions ride + // through unchanged. + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, }, })); @@ -52,6 +56,7 @@ beforeEach(() => { dateOfBirth: null, aiProvider: null, } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -59,6 +64,38 @@ function makeReq(): NextRequest { return new NextRequest("http://localhost/api/insights/cards"); } +describe("GET /api/insights/cards — assistant-flag gate", () => { + it("returns 403 + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); + + it("returns 403 when the master flag is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: false, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + }); +}); + describe("GET /api/insights/cards", () => { it("returns 401 when unauthenticated", async () => { vi.mocked(getSession).mockResolvedValue(null); diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 1a683f8fb..f070aa305 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -7,6 +7,7 @@ * provider). */ import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireAssistantSurface } from "@/lib/feature-flags"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -56,6 +57,10 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.4.31 — the iOS cards adapter feeds the same per-metric + // insight surfaces the web `` mounts on each + // /insights/ sub-page. Both share the operator gate. + await requireAssistantSurface("insightStatus"); annotate({ action: { name: "insights.cards" } }); const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index efe3f1eea..32f3de3aa 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -32,6 +32,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; +import { requireAssistantSurface } from "@/lib/feature-flags"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { @@ -127,6 +128,10 @@ function buildHistoryWindow(turns: CoachTurn[]): CoachTurn[] { async function handleChatRequest(request: NextRequest): Promise { const auth = await requireAuth(); + // v1.4.31 — operator can disable the Coach surface app-wide. + // Throws AssistantDisabledError → apiHandler returns 403 + + // `errorCode: "assistant.disabled.coach"` per the iOS contract. + await requireAssistantSurface("coach"); const userId = auth.user.id; let body: unknown; @@ -501,6 +506,10 @@ export const POST = apiHandler(handleChatRequest); */ export const GET = apiHandler(async (request: NextRequest) => { const auth = await requireAuth(); + // v1.4.31 — same gate as the SSE POST. Hiding the rail when + // the operator has disabled Coach matches the FAB suppression + // on the client. + await requireAssistantSurface("coach"); const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); const limitRaw = url.searchParams.get("limit"); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 4402a74c9..cd7e6d8cb 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -20,11 +20,15 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { requireAssistantSurface } from "@/lib/feature-flags"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.4.31 — comprehensive feeds the hero strip narration and the + // recommendations grid that share the Coach gate. + await requireAssistantSurface("coach"); const userId = user.id; const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index d6ffd8c7f..213ec066c 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ - prisma: { auditLog: { create: vi.fn() } }, + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -29,6 +32,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -37,6 +41,12 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // v1.4.31 — default to an all-on flag set so existing assertions + // ride through unchanged. Tests that need the gate-off path set + // their own findUnique resolution. + (prisma.appSettings.findUnique as ReturnType).mockResolvedValue( + null, + ); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -67,4 +77,38 @@ describe("GET /api/insights/correlations", () => { expect.objectContaining({ userId: "user-1" }), ); }); + + it("returns 403 + errorCode when the correlations flag is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + prisma.appSettings.findUnique as ReturnType + ).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: false, + assistantHealthScoreExplainerEnabled: true, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.correlations"); + }); + + it("returns 403 when the master flag is off (sub-flag forced)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + prisma.appSettings.findUnique as ReturnType + ).mockResolvedValueOnce({ + assistantEnabled: false, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index a21b0f8a5..a45cc8cf5 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -10,11 +10,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { requireAssistantSurface } from "@/lib/feature-flags"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.4.31 — operator can hide the correlation narration tile. + await requireAssistantSurface("correlations"); annotate({ action: { name: "insights.correlations" } }); await auditLog("insights.correlations.empty", { diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 938d27bbb..5ebae2f21 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -23,6 +23,14 @@ vi.mock("@/lib/api-handler", () => ({ })), })); +// v1.4.31 — `requireAssistantSurface()` is gated near the top of +// the handler. The test mocks the apiHandler wrapper itself out, so +// flag reads need a deterministic mock at the module boundary. +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), + AssistantDisabledError: class extends Error {}, +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index df73885f9..5e6e35c0c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -27,6 +27,7 @@ import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireAssistantSurface } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -174,6 +175,11 @@ async function buildComparisonSnapshotForUser( export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // v1.4.31 — the advisor + daily briefing surfaces ride this + // endpoint. Operator disables Coach, every advisor consumer + // (briefing card, recommendations grid, hero strip narration) + // empties out. + await requireAssistantSurface("coach"); const userId = user.id; const dbUser = await prisma.user.findUnique({ diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 7d20e1f94..541d5c6d9 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index 8fd3e3482..45edeac6c 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 2d8bf25c4..57de1cbdc 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index b678c90c1..43d070aa8 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -7,11 +7,13 @@ import { 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"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index b9afbc02b..86c042fde 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -9,6 +9,7 @@ import { getSession } from "./auth/session"; import { hashToken } from "./auth/hmac"; import { prisma } from "./db"; import { auditLog } from "./auth/audit"; +import { AssistantDisabledError } from "./feature-flags"; /** * Custom error class for HTTP errors with status codes. @@ -162,7 +163,24 @@ export function apiHandler Promise>( try { response = await handler(...args); } catch (error) { - if (error instanceof HttpError) { + if (error instanceof AssistantDisabledError) { + // v1.4.31 — operator has disabled the assistant surface. + // The 403 + `errorCode: "assistant.disabled."` + // envelope is locked per + // `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. + // Older iOS clients that don't know the errorCode surface + // this as a generic 403; v1.4.31+ clients render the + // `` empty state. + evt.setError(error); + response = NextResponse.json( + { + data: null, + error: error.message, + meta: { errorCode: error.errorCode }, + }, + { status: 403 }, + ); + } else if (error instanceof HttpError) { evt.setError(error); response = NextResponse.json( { data: null, error: error.message },