mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts: # src/app/api/insights/correlations/__tests__/route.test.ts # src/app/api/insights/correlations/route.ts
This commit is contained in:
commit
9c7ddc13b3
163 files changed
+5450
-2064
No files matched your search
+579
-392
File diff suppressed because it is too large.
Load diff
@@ -12,11 +12,13 @@ import {
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
import { settleBeforeMeasure, waitForFonts } from "./utils/settle";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
|
||||
|
||||
const NARRATIVE_DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-07-29T08:30:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: {
|
||||
|
||||
@@ -141,8 +141,6 @@ const ALLOWLIST: Record<string, string> = {
|
||||
// --- Capability / config probes: resolve a chain, never call it. ---
|
||||
"app/api/insights/provider-chain/route.ts":
|
||||
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
|
||||
|
||||
// --- Connection test: a real provider call, but with no user data. ---
|
||||
"app/api/ai/test/route.ts":
|
||||
|
||||
@@ -18,6 +18,9 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SCHEMA_FILE = join(ROOT, "src/lib/openapi/routes/insights/schemas.ts");
|
||||
// Shared component schemas the insights schemas import by name (the AI
|
||||
// capability state). Resolved from their own file, never guessed.
|
||||
const IMPORTED_SCHEMA_FILES = [join(ROOT, "src/lib/openapi/routes/profile.ts")];
|
||||
const PATH_FILE = join(ROOT, "src/lib/openapi/routes/insights/paths.ts");
|
||||
const ROUTE_FILE = join(ROOT, "src/app/api/insights/derived/route.ts");
|
||||
const BATCH_ROUTE_FILE = join(
|
||||
@@ -132,11 +135,19 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
const file = parse(path);
|
||||
const initializers = new Map<string, ts.Expression>();
|
||||
const arrays = new Set<string>();
|
||||
for (const statement of file.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name) && declaration.initializer) {
|
||||
initializers.set(declaration.name.text, declaration.initializer);
|
||||
// The schema file's own declarations win; an imported component fills in
|
||||
// only a name the file does not declare itself.
|
||||
for (const source of [file, ...IMPORTED_SCHEMA_FILES.map(parse)]) {
|
||||
for (const statement of source.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.initializer &&
|
||||
!initializers.has(declaration.name.text)
|
||||
) {
|
||||
initializers.set(declaration.name.text, declaration.initializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +179,8 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
`${prefix || schemaName} contains unsupported Zod object syntax: ${property.getText(file)}`,
|
||||
).toBe(true);
|
||||
if (!ts.isPropertyAssignment(property)) return [];
|
||||
const name = propertyName(property.name, file);
|
||||
// The node may come from an imported schema file.
|
||||
const name = propertyName(property.name, property.getSourceFile());
|
||||
expect(
|
||||
name,
|
||||
`${prefix || schemaName} contains a computed property name`,
|
||||
@@ -416,7 +428,8 @@ function apiSuccessLineageBranches(
|
||||
`${path} contains unsupported apiSuccess syntax: ${property.getText(file)}`,
|
||||
).toBe(true);
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyName(property.name, file);
|
||||
// The node may come from an imported schema file.
|
||||
const name = propertyName(property.name, property.getSourceFile());
|
||||
expect(
|
||||
name,
|
||||
`${path} has a computed response field`,
|
||||
@@ -892,7 +905,13 @@ const WIRE_ONLY_PROVENANCE =
|
||||
const WIRE_ONLY_ASSESSMENT_SOURCE =
|
||||
"The public response intentionally identifies deterministic versus cached assessment prose for programmatic and native callers. The web presents the prose and timestamp without exposing its implementation source.";
|
||||
|
||||
const WIRE_ONLY_AI =
|
||||
"The web reads AI availability from the account payload (`ai` on `/api/auth/me`) for every surface at once; the per-response state is for native and programmatic callers that read one route.";
|
||||
|
||||
const EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
"ai.available": WIRE_ONLY_AI,
|
||||
"ai.reason": WIRE_ONLY_AI,
|
||||
"ai.onDeviceAllowed": WIRE_ONLY_AI,
|
||||
metric: WIRE_ONLY_METRIC,
|
||||
"provenance.inputs": WIRE_ONLY_PROVENANCE,
|
||||
"provenance.source": WIRE_ONLY_PROVENANCE,
|
||||
@@ -943,6 +962,7 @@ describe("derived response consumer guard", () => {
|
||||
provenance: ["derived.provenance"],
|
||||
reason: ["derived.reason", "derived.status"],
|
||||
assessment: ["assessment"],
|
||||
ai: ["ai"],
|
||||
};
|
||||
for (const [index, lineage] of apiSuccessLineageBranches(
|
||||
ROUTE_FILE,
|
||||
@@ -987,7 +1007,14 @@ describe("derived response consumer guard", () => {
|
||||
},
|
||||
);
|
||||
assertBatchSchemaReferencesSingleShape(SCHEMA_FILE);
|
||||
assertBatchProducerLineage(BATCH_ROUTE_FILE, responseFields);
|
||||
// `ai` rides the single-metric route only: the batch serves the
|
||||
// deterministic assessment and resolves no capability. The schema marks
|
||||
// the field optional for exactly this reason.
|
||||
const singleRouteOnly = ["ai"];
|
||||
assertBatchProducerLineage(
|
||||
BATCH_ROUTE_FILE,
|
||||
responseFields.filter((field) => !singleRouteOnly.includes(field)),
|
||||
);
|
||||
assertBatchClientHandoff(DERIVED_BATCH_CLIENT, VITALS_DASHBOARD);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Capability states for test fixtures, so a fixture names the state it means
|
||||
* rather than spelling the object out.
|
||||
*/
|
||||
import {
|
||||
ON_DEVICE_ALLOWED_REASONS,
|
||||
type AiCapabilityState,
|
||||
type AiUnavailableReason,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import type { DailyDigestAi } from "@/lib/daily/digest";
|
||||
|
||||
export const AI_AVAILABLE: AiCapabilityState = Object.freeze({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
|
||||
export function aiUnavailable(reason: AiUnavailableReason): AiCapabilityState {
|
||||
return {
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: ON_DEVICE_ALLOWED_REASONS.has(reason),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every AI part of the daily digest available. */
|
||||
export const DIGEST_AI_AVAILABLE: DailyDigestAi = Object.freeze({
|
||||
briefing: AI_AVAILABLE,
|
||||
coach: AI_AVAILABLE,
|
||||
reactionLines: AI_AVAILABLE,
|
||||
});
|
||||
@@ -2943,13 +2943,20 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
expect(narrativeDispatch).toContain("mayDispatchProviderWork");
|
||||
expect(narrativeDispatch).toContain("withProviderWorkAuthority");
|
||||
|
||||
// A shared comprehensive read calls the resolver even before it decides
|
||||
// whether there is enough context to generate. Credential policy belongs
|
||||
// inside every resolver helper so a delegate or managed system job cannot
|
||||
// select a personal BYOK key or custom base URL first.
|
||||
// A shared comprehensive read asks for provider presence on every read.
|
||||
// It asks the presence probe, never the resolver: a read builds no client
|
||||
// and refreshes no token. Credential policy belongs inside every resolver
|
||||
// and presence helper so a delegate or managed system job cannot select a
|
||||
// personal BYOK key or custom base URL first.
|
||||
expect(
|
||||
callsSymbol(
|
||||
"app/api/insights/comprehensive/route.ts",
|
||||
"probeProviderPresence",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
for (const name of [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* AI capability route inventory, across the whole API tree.
|
||||
*
|
||||
* Proves, from source text, the route half of "data never depends on AI":
|
||||
*
|
||||
* 1. Every `route.ts` under `src/app/api` that asks an AI capability
|
||||
* (`requireAiCapability` / `getAiCapability`) is on `AI_ROUTES`, which
|
||||
* names the capability and whether it refuses (`action`) or answers 200
|
||||
* with the model text nulled (`mixed`).
|
||||
* 2. Each `AI_ROUTES` entry asks exactly the capabilities it declares, by
|
||||
* literal key; an `action` route calls `requireAiCapability`, a `mixed`
|
||||
* read never does (a mixed read never refuses for an AI reason).
|
||||
* 3. Every `DATA_ROUTES` entry asks neither gate, nor the retired
|
||||
* `requireAssistantSurface`.
|
||||
* 4. The matchers find what they judge: non-zero counts, and a call split
|
||||
* across lines still matches.
|
||||
*
|
||||
* What it does NOT prove: that a gate sits before the work it guards, or that
|
||||
* a mixed read really nulls the text. The route tests and the integration
|
||||
* files (`tests/integration/ai-optional-*.test.ts`) prove behaviour; this
|
||||
* proves no route escapes classification and no data route grows a gate.
|
||||
*
|
||||
* Mutation checks (each turned this file red by name): adding
|
||||
* `await requireAiCapability("statusText")` to `insights/ecg/route.ts`;
|
||||
* removing the capability call from `insights/chat/route.ts`; swapping
|
||||
* `getAiCapability` for `requireAiCapability` in `insights/narrative`.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import { AI_ROUTES, DATA_ROUTES } from "./ai-route-inventory";
|
||||
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..");
|
||||
|
||||
/** Comment lines out, so prose naming a gate is not read as a call. */
|
||||
function code(path: string): string {
|
||||
return readFileSync(resolve(repoRoot, path), "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Whitespace-tolerant: a call split across lines still matches. */
|
||||
const GATE_CALL =
|
||||
/\b(?:(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g;
|
||||
const ANY_GATE =
|
||||
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
|
||||
const REQUIRE_GATE = /\brequireAiCapability\s*\(/;
|
||||
const RETIRED_GATE = /\brequireAssistantSurface\s*\(/;
|
||||
|
||||
function gateCalls(text: string): Array<{ fn: string; key: string }> {
|
||||
return [...text.matchAll(GATE_CALL)].map((m) => ({
|
||||
fn: m[1] ?? m[3],
|
||||
key: m[2] ?? m[4],
|
||||
}));
|
||||
}
|
||||
|
||||
function routeFiles(): string[] {
|
||||
const root = resolve(repoRoot, "src/app/api");
|
||||
const hits: string[] = [];
|
||||
function walk(dir: string): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full);
|
||||
} else if (entry === "route.ts") {
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
const ROUTES = routeFiles();
|
||||
|
||||
describe("AI capability route inventory", () => {
|
||||
it("the matcher reads a call split across lines", () => {
|
||||
const split = 'await requireAiCapability(\n "coach",\n );';
|
||||
expect(gateCalls(split)).toEqual([
|
||||
{ fn: "requireAiCapability", key: "coach" },
|
||||
]);
|
||||
expect(ANY_GATE.test("getAiCapability (\n")).toBe(true);
|
||||
expect(
|
||||
gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'),
|
||||
).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]);
|
||||
});
|
||||
|
||||
it("finds the routes it is meant to judge", () => {
|
||||
expect(ROUTES.length).toBeGreaterThan(100);
|
||||
const asking = ROUTES.filter((path) => ANY_GATE.test(code(path)));
|
||||
// A matcher that stopped matching would agree with an empty world.
|
||||
expect(asking.length).toBeGreaterThan(0);
|
||||
expect(asking.length).toBe(Object.keys(AI_ROUTES).length);
|
||||
expect(Object.keys(DATA_ROUTES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every route that asks an AI capability is on AI_ROUTES", () => {
|
||||
const unlisted = ROUTES.filter(
|
||||
(path) => ANY_GATE.test(code(path)) && !(path in AI_ROUTES),
|
||||
);
|
||||
expect(
|
||||
unlisted,
|
||||
"these routes ask an AI capability but are not on AI_ROUTES — add them with their capability, or remove the gate from a data route",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe.each(Object.entries(AI_ROUTES))("%s", (path, entry) => {
|
||||
it("exists and asks exactly the capabilities it declares", () => {
|
||||
expect(ROUTES, `${path} no longer exists`).toContain(path);
|
||||
const calls = gateCalls(code(path));
|
||||
expect(calls.length, `${path} asks no capability`).toBeGreaterThan(0);
|
||||
for (const { key } of calls) {
|
||||
expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key);
|
||||
}
|
||||
expect([...new Set(calls.map((c) => c.key))].sort()).toEqual(
|
||||
[...entry.capabilities].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it(`is ${entry.kind === "action" ? "an action that refuses" : "a mixed read that never refuses"}`, () => {
|
||||
const refuses = REQUIRE_GATE.test(code(path));
|
||||
expect(
|
||||
refuses,
|
||||
entry.kind === "action"
|
||||
? `${path} is an action but never calls requireAiCapability`
|
||||
: `${path} is a mixed read but calls requireAiCapability — a mixed read answers 200 and nulls the model text`,
|
||||
).toBe(entry.kind === "action");
|
||||
});
|
||||
});
|
||||
|
||||
it("no data route asks an AI capability or the retired gate", () => {
|
||||
const offenders = Object.keys(DATA_ROUTES).filter((path) => {
|
||||
const text = code(path);
|
||||
return ANY_GATE.test(text) || RETIRED_GATE.test(text);
|
||||
});
|
||||
expect(
|
||||
offenders,
|
||||
"data routes never depend on AI: remove the gate, or move the route to AI_ROUTES if it now serves model text",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("no data route is stale or listed twice", () => {
|
||||
const missing = Object.keys(DATA_ROUTES).filter(
|
||||
(path) => !ROUTES.includes(path),
|
||||
);
|
||||
expect(missing, "DATA_ROUTES names files that do not exist").toEqual([]);
|
||||
const both = Object.keys(DATA_ROUTES).filter((path) => path in AI_ROUTES);
|
||||
expect(both).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* The AI route inventory: which API routes ask an AI capability, and which
|
||||
* are data and must never ask.
|
||||
*
|
||||
* The rule it pins (the AI-optional design): data never depends on AI. A
|
||||
* route that serves measurements, device records, computed scores,
|
||||
* statistics or template sentences imports no AI gate and is never refused
|
||||
* because a switch is off, a provider is missing, consent is absent or the
|
||||
* person opted out of AI analysis. A route that calls a model, or serves text
|
||||
* a model wrote, names its capability:
|
||||
*
|
||||
* - `action` routes call `requireAiCapability(key)` and refuse with the
|
||||
* capability envelope;
|
||||
* - `mixed` reads call `getAiCapability(key)` (the viewer's view, for work
|
||||
* they would start) or `aiCapabilityToServe(recordId, key)` (the record's
|
||||
* own view, for stored model text they would show), answer 200, null the
|
||||
* model text and say why in `ai`. They never refuse for an AI reason, so
|
||||
* they never call `requireAiCapability`.
|
||||
*
|
||||
* Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API
|
||||
* tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every
|
||||
* route in the trees where AI routes live is classified). Not a test file, so
|
||||
* importing it registers nothing.
|
||||
*/
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
|
||||
export interface AiRouteEntry {
|
||||
kind: "action" | "mixed";
|
||||
capabilities: readonly AiCapabilityKey[];
|
||||
why: string;
|
||||
}
|
||||
|
||||
export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
// Reading documents, lab scans and medication text.
|
||||
"src/app/api/documents/inbound/[id]/chat/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "POST asks a model about one document; the history GET asks nothing.",
|
||||
},
|
||||
"src/app/api/documents/inbound/[id]/extract/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "Reads a document's text with a model.",
|
||||
},
|
||||
"src/app/api/documents/inbound/[id]/index/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "Indexes a document's content through a provider.",
|
||||
},
|
||||
"src/app/api/documents/inbound/[id]/suggest/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "Suggests a title and category from the document with a model.",
|
||||
},
|
||||
"src/app/api/documents/inbound/[id]/summary/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "Summarises a document with a model.",
|
||||
},
|
||||
"src/app/api/documents/inbound/reindex/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["documentAi"],
|
||||
why: "Re-indexes the vault through a provider.",
|
||||
},
|
||||
"src/app/api/documents/inbound/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["documentAi"],
|
||||
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
|
||||
},
|
||||
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["documentAi"],
|
||||
why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.",
|
||||
},
|
||||
"src/app/api/insights/chat/fenced/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["coach", "documentAi"],
|
||||
why: "Document chat inside a Coach conversation.",
|
||||
},
|
||||
"src/app/api/insights/chat/[id]/attachments/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["coach", "documentAi"],
|
||||
why: "Attaching a document to a Coach conversation for the model to read.",
|
||||
},
|
||||
"src/app/api/labs/ocr/extract/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["labsOcr"],
|
||||
why: "Reads a lab report image with a model.",
|
||||
},
|
||||
"src/app/api/medications/extract/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["medicationExtract"],
|
||||
why: "Turns typed medication text into a schedule with a model.",
|
||||
},
|
||||
"src/app/api/insights/chat/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["coach"],
|
||||
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
|
||||
},
|
||||
"src/app/api/insights/generate/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["briefing"],
|
||||
why: "POST generates the briefing; GET is a pure AI read (the payload is the model text and nothing else), so both refuse.",
|
||||
},
|
||||
"src/app/api/insights/comprehensive/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["briefing", "statusText"],
|
||||
why: "Computed overview data; the `ai` block says whether the briefing and status notes around it can be shown.",
|
||||
},
|
||||
"src/app/api/insights/derived/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Computed score; only the model-written assessment override follows the capability.",
|
||||
},
|
||||
"src/app/api/insights/narrative/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["periodNarrative"],
|
||||
why: "A deterministic narrative is data; a model-written one is served only while the capability is available.",
|
||||
},
|
||||
"src/app/api/insights/pregenerate/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["briefing", "statusText"],
|
||||
why: "Enqueue-only warm that returns no model text: `queued: false` when neither half can run.",
|
||||
},
|
||||
"src/app/api/insights/coach/nudge-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
|
||||
},
|
||||
"src/app/api/insights/coach/seeded-question/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
|
||||
},
|
||||
"src/app/api/coach/about-me/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["aboutMeQuestions"],
|
||||
why: "The profile is data; only model-written follow-up questions follow the capability.",
|
||||
},
|
||||
"src/app/api/insights/biomarker-assessment/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/blood-pressure-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/bmi-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/medication-compliance-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/metric-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/mood-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/pulse-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/insights/weight-status/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["statusText"],
|
||||
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
|
||||
},
|
||||
"src/app/api/workouts/[id]/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["workoutInsights"],
|
||||
why: "The workout is data; only the stored Activity Insight paragraph follows the capability.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Routes that are data and must never import an AI gate. Every entry is a
|
||||
* claim the guard checks against the file.
|
||||
*/
|
||||
export const DATA_ROUTES: Readonly<Record<string, string>> = {
|
||||
// The design's list of formerly AI-gated data reads and writes.
|
||||
"src/app/api/insights/derived/batch/route.ts":
|
||||
"Deterministic scores and assessments for the whole grid.",
|
||||
"src/app/api/insights/ecg/route.ts":
|
||||
"Device recordings, list and live ingest: an upload is never refused because AI is off.",
|
||||
"src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.",
|
||||
"src/app/api/insights/rhythm-events/route.ts":
|
||||
"The device's own flagged events, verbatim.",
|
||||
"src/app/api/insights/cards/route.ts":
|
||||
"Rule alerts from the threshold engine; `provider` is `rules`.",
|
||||
"src/app/api/insights/correlations/route.ts":
|
||||
"Statistics; the retired Correlations switch gated no model output.",
|
||||
"src/app/api/insights/coach-read/route.ts":
|
||||
"Baseline placement and the strongest association, both computed.",
|
||||
"src/app/api/insights/coach/seen/route.ts":
|
||||
"A timestamp on the caller's own row.",
|
||||
"src/app/api/insights/coach/facts/route.ts":
|
||||
"Stored Coach facts: readable and erasable while the Coach is unavailable.",
|
||||
"src/app/api/insights/coach/facts/[id]/route.ts": "Erasing one stored fact.",
|
||||
"src/app/api/insights/chat/[id]/route.ts":
|
||||
"One stored conversation: read, rename, delete.",
|
||||
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
|
||||
"A rating on a message that already exists.",
|
||||
"src/app/api/insights/breathing-screening/route.ts":
|
||||
"Device breathing data, owned by the sleep module.",
|
||||
"src/app/api/insights/labs-changes/route.ts":
|
||||
"Lab deltas, owned by the labs module.",
|
||||
"src/app/api/insights/health-status/route.ts":
|
||||
"Baseline drift, computed over core vitals.",
|
||||
"src/app/api/insights/pulse/intraday/route.ts":
|
||||
"The day's pulse shape, a core vital.",
|
||||
"src/app/api/insights/patterns/route.ts": "Stored correlation statistics.",
|
||||
"src/app/api/insights/patterns/[id]/route.ts":
|
||||
"Dismissing a stored correlation.",
|
||||
"src/app/api/daily/digest/route.ts":
|
||||
"The Today digest is data; its AI parts are masked by the loader through the capability resolver, never refused.",
|
||||
"src/app/api/daily/digest/dismiss/route.ts": "Dismissing a Today rail item.",
|
||||
"src/app/api/dashboard/snapshot/route.ts":
|
||||
"The dashboard is data; the briefing inside it is masked per read by the snapshot loader, never refused.",
|
||||
// Configuration and other non-AI routes in the insights tree.
|
||||
"src/app/api/insights/feedback/route.ts":
|
||||
"Feedback on a recommendation that already exists.",
|
||||
"src/app/api/insights/glp1-plateau/route.ts":
|
||||
"A deterministic plateau detector over weight and dose history.",
|
||||
"src/app/api/insights/glp1-timeline/route.ts":
|
||||
"A merge of medication events.",
|
||||
"src/app/api/insights/layout/route.ts": "The person's tile layout.",
|
||||
"src/app/api/insights/provider-chain/route.ts":
|
||||
"AI configuration: editable while AI is unavailable, so it can be set up.",
|
||||
"src/app/api/insights/settings/route.ts":
|
||||
"AI configuration: editable while AI is unavailable, so it can be set up.",
|
||||
"src/app/api/insights/targets/route.ts": "Threshold reference values.",
|
||||
// The Coach tree's own data: gated on the `coach` module, never on AI.
|
||||
"src/app/api/coach/about-me/adopt/route.ts":
|
||||
"Folds a typed answer into the stored profile; no model call.",
|
||||
"src/app/api/coach/about-me/questions/route.ts":
|
||||
"Reads or dismisses stored questions; no model call.",
|
||||
"src/app/api/coach/plans/route.ts": "Stored Coach plans.",
|
||||
"src/app/api/coach/plans/[id]/route.ts": "One stored Coach plan.",
|
||||
"src/app/api/coach/reminders/route.ts": "Stored Coach reminders.",
|
||||
"src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.",
|
||||
"src/app/api/coach/reminder-suggestions/route.ts":
|
||||
"Deterministic reminder suggestions.",
|
||||
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
|
||||
"Detaching a document: removal of one's own data.",
|
||||
"src/app/api/coach/suggested-actions/route.ts":
|
||||
"Confirms a proposed action from a closed allowlist; no model call.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Routes in the AI trees whose AI gating another change still owns. Each has
|
||||
* to be classified; the entry names who moves it. An entry that starts
|
||||
* calling a capability gate fails the guard until it moves to `AI_ROUTES`.
|
||||
* Empty: every route in these trees is classified.
|
||||
*/
|
||||
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
|
||||
|
||||
/** The trees in which every route has to be classified above. */
|
||||
export const AI_ROUTE_TREES = [
|
||||
"src/app/api/insights",
|
||||
"src/app/api/coach",
|
||||
"src/app/api/daily",
|
||||
] as const;
|
||||
@@ -32,11 +32,16 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* `delegatesTo`):
|
||||
* - cycle → `requireCycleEnabled(...)` (the cycle gate the
|
||||
* `cycle` ModuleKey delegates to).
|
||||
* - coach → `requireAssistantSurface("coach")` (the assistant
|
||||
* master flag + per-user opt-out the `coach` ModuleKey
|
||||
* delegates to). Covered in depth by the sibling
|
||||
* `coach-route-gate-inventory.test.ts`; listed here so
|
||||
* coach-bearing routes are not flagged as ungated.
|
||||
* - an AI capability → `requireAiCapability(key)` /
|
||||
* `getAiCapability(key)`. A capability resolves its owning
|
||||
* modules and the AI opt-out (the `coach` module and
|
||||
* `disableCoach` for the Coach, the `insights` module for
|
||||
* every other capability), so an AI route that names one is
|
||||
* gated on those modules. Covered in depth by
|
||||
* `ai-capability-route-inventory.test.ts`. The `insights`
|
||||
* module is the AI analysis opt-out and gates only AI parts;
|
||||
* the data routes that happen to live under
|
||||
* `/api/insights` are EXEMPT below.
|
||||
*
|
||||
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* toggleable domain but are deliberately NOT gated, each with the
|
||||
@@ -147,9 +152,10 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly<
|
||||
// counted as directly gated here, and the sibling
|
||||
// `coach-route-gate-inventory.test.ts` covers the surface in depth.
|
||||
coach: ["src/app/api/coach"],
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree, plus v1.28's unified
|
||||
// daily-digest read (`GET /api/daily/digest`), which is the AI-narrative
|
||||
// daily layer and gates on `insights` directly.
|
||||
// The `insights` module is the AI analysis opt-out. It owns no data route:
|
||||
// the AI routes under these trees name a capability that folds it in, and
|
||||
// the data routes that live here (ECG, scores, statistics, the Today
|
||||
// digest) are EXEMPT below because a computation never depends on AI.
|
||||
insights: ["src/app/api/insights", "src/app/api/daily"],
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
// SURFACE-gated (nav entry, dashboard widget, the dedicated Medikamente
|
||||
@@ -244,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// affordance), not the row store. The extract route is read-only vision
|
||||
// assistance, the commit route writes the user's own confirmed lab rows,
|
||||
// and the capability probe is an infra availability check carrying no
|
||||
// module data. All three are owner-scoped and AI-gated (consent / budget /
|
||||
// rate); the module toggle hiding the surface does not need to wedge them.
|
||||
// module data. The extract route is not listed: it names the `labsOcr` AI
|
||||
// capability, which carries the labs module, so it counts as gated.
|
||||
"src/app/api/labs/ocr/capability/route.ts",
|
||||
"src/app/api/labs/ocr/extract/route.ts",
|
||||
"src/app/api/labs/ocr/commit/route.ts",
|
||||
// ── DATA LAYER (medications) ──────────────────────────────────────
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
|
||||
@@ -268,12 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// per-medication importer below: it writes intake rows and reads its own job.
|
||||
"src/app/api/medications/intake/dose-history-import/route.ts",
|
||||
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
|
||||
// `medications/extract` is model work, not data: it answers under the
|
||||
// `medicationExtract` AI capability (`requireAiCapability`), which no
|
||||
// module owns, because medications is a core domain for extraction. The
|
||||
// capability carries the operator switch, the record's authority and the
|
||||
// consent rule; there is no module gate for it to carry.
|
||||
"src/app/api/medications/extract/route.ts",
|
||||
// `medications/extract` is not listed: it is model work under the
|
||||
// `medicationExtract` AI capability (no owning module; medications is a
|
||||
// core domain for extraction), and a capability call counts as the gate.
|
||||
"src/app/api/medications/[id]/route.ts",
|
||||
"src/app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"src/app/api/medications/[id]/cadence/route.ts",
|
||||
@@ -302,12 +304,39 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
// ── DATA (insights tree) ──────────────────────────────────────────
|
||||
// The `insights` module is the AI analysis opt-out, so it gates AI parts
|
||||
// only, through the capability. These routes serve or accept data that no
|
||||
// model wrote: device recordings and events, computed scores, statistics,
|
||||
// rule alerts, the person's stored Coach data and the Today digest. Turning
|
||||
// AI analysis off must leave every one of them working.
|
||||
"src/app/api/insights/ecg/route.ts",
|
||||
"src/app/api/insights/ecg/[id]/route.ts",
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
"src/app/api/insights/derived/batch/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
"src/app/api/insights/coach-read/route.ts",
|
||||
"src/app/api/insights/health-status/route.ts",
|
||||
"src/app/api/insights/pulse/intraday/route.ts",
|
||||
"src/app/api/insights/patterns/route.ts",
|
||||
"src/app/api/insights/patterns/[id]/route.ts",
|
||||
// Stored Coach data: viewing and erasing it never depends on the Coach
|
||||
// being available (the GDPR reason), and a timestamp on one's own row is
|
||||
// data too.
|
||||
"src/app/api/insights/coach/seen/route.ts",
|
||||
"src/app/api/insights/coach/facts/route.ts",
|
||||
"src/app/api/insights/coach/facts/[id]/route.ts",
|
||||
"src/app/api/insights/chat/[id]/route.ts",
|
||||
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
|
||||
// The Today digest and its dismiss: data, with the AI parts masked by the
|
||||
// loader through the capability resolver.
|
||||
"src/app/api/daily/digest/route.ts",
|
||||
"src/app/api/daily/digest/dismiss/route.ts",
|
||||
// ── INFRA / CONFIG (insights) ─────────────────────────────────────
|
||||
// v1.18.0 (B2) — the insights tree's non-narrative routes. The
|
||||
// `insights` module gates the AI-narrative SURFACES (status cards,
|
||||
// correlations, derived scores, period narrative, the rhythm-event
|
||||
// timeline); these six carry no narrative payload, so gating them would
|
||||
// only break configuration / settings reads while the module is off.
|
||||
// The insights tree's configuration routes carry no narrative payload,
|
||||
// so gating them would only break configuration / settings reads while
|
||||
// the module is off.
|
||||
//
|
||||
// AI provider + privacy settings and the read-only chain summary — pure
|
||||
// configuration of the assistant, surfaced under Settings → AI, not an
|
||||
@@ -388,7 +417,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
// An AI capability resolves its owning modules and the AI opt-out, so a route
|
||||
// that names one is gated on them.
|
||||
const AI_CAPABILITY_GATE =
|
||||
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
|
||||
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
|
||||
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
|
||||
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
|
||||
@@ -470,6 +502,15 @@ function fileHasCall(text: string, needle: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
/** True when a non-comment line calls an AI capability gate. */
|
||||
function fileHasCapabilityGate(text: string): boolean {
|
||||
return text.split("\n").some((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false;
|
||||
return AI_CAPABILITY_GATE.test(line);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the module key from a `requireModuleEnabled(user.id, "<key>")`
|
||||
* call. Returns the literal key, or null when the key is computed (the
|
||||
@@ -634,8 +675,8 @@ describe("module API route gate inventory", () => {
|
||||
|
||||
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
|
||||
if (fileHasCapabilityGate(text)) continue;
|
||||
|
||||
if (exempt.has(path)) continue;
|
||||
|
||||
@@ -755,8 +796,8 @@ describe("module API route gate inventory", () => {
|
||||
if (
|
||||
fileHasCall(text, MODULE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, COACH_GATE_NEEDLE) ||
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE)
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
|
||||
fileHasCapabilityGate(text)
|
||||
) {
|
||||
stillGated.push(path);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
}));
|
||||
|
||||
// Operator master flag = on by default; coach delegation keys off it.
|
||||
// Partial mock — api-handler imports `AssistantDisabledError` from here.
|
||||
// Partial mock: the rest of the module stays real.
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return {
|
||||
|
||||
@@ -108,7 +108,10 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
questions: [],
|
||||
source: "none",
|
||||
})),
|
||||
buildFallbackQuestions: vi.fn(() => ["deterministic hint"]),
|
||||
}));
|
||||
// The follow-up questions are the route's one AI part: `aboutMeQuestions`.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import {
|
||||
@@ -118,6 +121,12 @@ import {
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { buildFallbackQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
@@ -151,6 +160,7 @@ function putReq(
|
||||
describe("about-me module gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
|
||||
profileFindUnique.mockResolvedValue({
|
||||
updatedAt: new Date(0),
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
@@ -284,6 +294,7 @@ describe("about-me module gate", () => {
|
||||
},
|
||||
"en",
|
||||
["ABOUT_ME"],
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
@@ -292,6 +303,71 @@ describe("about-me module gate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("derives without the model while aboutMeQuestions is unavailable", async () => {
|
||||
setGates(true, false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue(
|
||||
aiUnavailable("operator_disabled"),
|
||||
);
|
||||
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
|
||||
aboutMe: "I run daily",
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
|
||||
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(getAiCapability).toHaveBeenCalledWith("aboutMeQuestions");
|
||||
expect(vi.mocked(deriveClarifyingQuestions).mock.calls[0]?.[4]).toEqual({
|
||||
aiAvailable: false,
|
||||
});
|
||||
expect((res.data as { ai: unknown }).ai).toEqual(
|
||||
aiUnavailable("operator_disabled"),
|
||||
);
|
||||
});
|
||||
|
||||
it("GET swaps an outstanding (possibly model-written) set for the deterministic hints while unavailable", async () => {
|
||||
setGates(true, false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
|
||||
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([
|
||||
"a question a model may have written",
|
||||
]);
|
||||
|
||||
const res = await get();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = res.data as { pendingQuestions: string[]; ai: unknown };
|
||||
expect(data.pendingQuestions).toEqual(["deterministic hint"]);
|
||||
expect(data.ai).toEqual(aiUnavailable("no_provider"));
|
||||
});
|
||||
|
||||
it("GET keeps an answered (empty) set empty while unavailable", async () => {
|
||||
setGates(true, false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
|
||||
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([]);
|
||||
|
||||
const res = await get();
|
||||
|
||||
expect(
|
||||
(res.data as { pendingQuestions: string[] }).pendingQuestions,
|
||||
).toEqual([]);
|
||||
expect(buildFallbackQuestions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET serves the stored set while available", async () => {
|
||||
setGates(true, false);
|
||||
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([
|
||||
"stored question",
|
||||
]);
|
||||
|
||||
const res = await get();
|
||||
|
||||
expect(
|
||||
(res.data as { pendingQuestions: string[] }).pendingQuestions,
|
||||
).toEqual(["stored question"]);
|
||||
});
|
||||
|
||||
it("clears pending questions when inclusion removes every text field", async () => {
|
||||
setGates(true, false);
|
||||
profileFindUnique.mockResolvedValue({
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
* text child only — no markdown library exists in the tree and none
|
||||
* may be added (XSS posture, see the contributor notes).
|
||||
*
|
||||
* The follow-up questions are the one AI part. Model-written questions are
|
||||
* derived and served only while the `aboutMeQuestions` capability is
|
||||
* available; otherwise the deterministic completion hints stand in, no
|
||||
* provider is called, and the `ai` field says why. Stored questions cannot be
|
||||
* told apart by origin, so while the capability is unavailable an outstanding
|
||||
* set is replaced on read by the deterministic hints for the same profile.
|
||||
*
|
||||
* Ownership: the user id always comes from `requireAuth()`; the body
|
||||
* carries only the text. Audit rows never contain the text itself —
|
||||
* only per-field lengths — because it is free-form health prose.
|
||||
@@ -44,7 +51,11 @@ import {
|
||||
filterSelfContextForAi,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import {
|
||||
buildFallbackQuestions,
|
||||
deriveClarifyingQuestions,
|
||||
} from "@/lib/ai/coach/self-context-questions";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
@@ -79,7 +90,7 @@ export const GET = apiHandler(async () => {
|
||||
const gate = await requireSelfContextConsumer(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [ctx, pendingQuestions, row] = await Promise.all([
|
||||
const [ctx, storedQuestions, row, ai] = await Promise.all([
|
||||
getSelfContextForUser(user.id),
|
||||
gate.coachEnabled
|
||||
? getPendingQuestionsForUser(user.id)
|
||||
@@ -88,7 +99,22 @@ export const GET = apiHandler(async () => {
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true, aiIncludedSections: true },
|
||||
}),
|
||||
getAiCapability("aboutMeQuestions"),
|
||||
]);
|
||||
const aiIncludedSections =
|
||||
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS;
|
||||
// A stored set may be model-written. While the capability is unavailable
|
||||
// an outstanding set is swapped for the deterministic hints; an answered
|
||||
// (empty) set stays empty.
|
||||
const pendingQuestions =
|
||||
ai.available || storedQuestions.length === 0
|
||||
? storedQuestions
|
||||
: buildFallbackQuestions(
|
||||
filterSelfContextForAi(ctx, aiIncludedSections),
|
||||
user.locale,
|
||||
aiIncludedSections,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.about_me.get" },
|
||||
@@ -107,12 +133,11 @@ export const GET = apiHandler(async () => {
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
aiIncludedSections:
|
||||
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
aiIncludedSections,
|
||||
updatedAt: row?.updatedAt?.toISOString() ?? null,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ai,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +350,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
ctx.allergies === null &&
|
||||
ctx.coachFocus === null;
|
||||
|
||||
const ai = await getAiCapability("aboutMeQuestions");
|
||||
let pendingQuestions: string[] = [];
|
||||
let questionsToPersist: string[] | null | undefined;
|
||||
let questionsSource: "ai" | "fallback" | "none" = "none";
|
||||
@@ -342,6 +368,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
aiContext,
|
||||
user.locale,
|
||||
aiIncludedSections,
|
||||
{ aiAvailable: ai.available },
|
||||
);
|
||||
pendingQuestions = derived.questions;
|
||||
questionsSource = derived.source;
|
||||
@@ -404,6 +431,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
updatedAt: updatedAtIso,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ai,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
/**
|
||||
* `GET /api/daily/digest` — the P3 read seam.
|
||||
*
|
||||
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
|
||||
* returns a 403 `module.disabled` envelope when off (even for a valid
|
||||
* session), and the happy path returns the `DailyDigest` DTO shape. The digest
|
||||
* Under test: cookie/Bearer auth narrows the user, the `insights` module (the
|
||||
* AI analysis opt-out) no longer refuses the digest, and the happy path
|
||||
* returns the `DailyDigest` DTO shape. The digest
|
||||
* itself is composed by `loadDailyDigest`, mocked here — the route must reach
|
||||
* no provider (there is nothing AI-shaped in this module graph).
|
||||
*/
|
||||
@@ -44,6 +44,7 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -57,6 +58,7 @@ const SESSION_OK = {
|
||||
|
||||
const DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-07-16T09:00:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: {
|
||||
@@ -95,7 +97,7 @@ describe("GET /api/daily/digest", () => {
|
||||
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the 403 module.disabled envelope when insights is off", async () => {
|
||||
it("serves the digest with the insights module (the AI analysis opt-out) off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403, {
|
||||
@@ -104,18 +106,9 @@ describe("GET /api/daily/digest", () => {
|
||||
}),
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gates on the insights module key", async () => {
|
||||
await callGet(makeReq());
|
||||
expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"insights",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(requireModuleEnabled)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(loadDailyDigest)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 with the DailyDigest DTO on the happy path", async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* `POST /api/daily/digest/dismiss` — the Today rail dismiss surface.
|
||||
*
|
||||
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
|
||||
* returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't
|
||||
* Under test: cookie/Bearer auth narrows the user, the `insights` module (the
|
||||
* AI analysis opt-out) no longer refuses a dismissal, an `itemKey` that isn't
|
||||
* namespaced under a dismissible kind 422s BEFORE any DB write, and the happy
|
||||
* path upserts the `DismissedPriorityItem` row scoped to the caller.
|
||||
*/
|
||||
@@ -98,7 +98,7 @@ describe("POST /api/daily/digest/dismiss", () => {
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the 403 module.disabled envelope when insights is off", async () => {
|
||||
it("accepts a dismissal with the insights module (the AI analysis opt-out) off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403, {
|
||||
@@ -109,8 +109,9 @@ describe("POST /api/daily/digest/dismiss", () => {
|
||||
const res = await callPost(
|
||||
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* an upsert, so a repeat dismiss of the same instance is a no-op rather than
|
||||
* a conflict. `userId` is narrowed from `requireAuth()` only; the body never
|
||||
* carries one. Cookie OR Bearer, mirroring the digest GET route's own auth
|
||||
* policy, and gated on the same `insights` module (the daily rail's host
|
||||
* surface).
|
||||
* policy. Like the digest GET it carries no module gate: the rail is data,
|
||||
* and the `insights` module is the AI analysis opt-out.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -28,7 +28,6 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { isScoreNoticeItemKey } from "@/lib/daily/priority-item";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||||
|
||||
@@ -40,9 +39,6 @@ const DISMISS_WINDOW_MS = 60_000;
|
||||
|
||||
export const POST = apiHandler(async (req: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`daily-digest-dismiss:${user.id}`,
|
||||
DISMISS_RATE_LIMIT,
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
* provider call is reachable from this path, and nothing warms on mount.
|
||||
*
|
||||
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
|
||||
* resolved session — never a body field. Gated on the `insights` module (the
|
||||
* daily digest is the AI-narrative daily layer); a disabled account gets the
|
||||
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
|
||||
* data-tile inputs inherit their own module gates via the snapshot builder.
|
||||
* resolved session — never a body field. No module gate and no AI gate: the
|
||||
* digest is data (score, doses, syncs, checkups, the day's markers). Its AI
|
||||
* parts, the briefing lead, the reaction line and the Coach check-in, are
|
||||
* served only while their capabilities are available, and the `ai` block says
|
||||
* which. The rail's data-tile inputs inherit their own module gates via the
|
||||
* snapshot builder.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
|
||||
@@ -27,13 +28,10 @@ export const GET = apiHandler(async () => {
|
||||
// The record's Today hero. Same family as the dashboard snapshot and
|
||||
// admitted on the same argument: an aggregate over the record's own data,
|
||||
// assembled from already-cached values with no provider on the path. The
|
||||
// module gate below now runs against the RECORD, which is the behaviour that
|
||||
// matters — a delegate gets the hero only where the owner switched insights
|
||||
// on, never where the delegate did. Re-examine with the snapshot when
|
||||
// AI parts resolve against the RECORD, so a delegate sees the owner's
|
||||
// capabilities, never their own. Re-examine with the snapshot when
|
||||
// per-module scope lands; a digest is a summary of several modules at once.
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const digest = await loadDailyDigest(user);
|
||||
|
||||
|
||||
@@ -38,9 +38,19 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
const buildDashboardSnapshot = vi.fn();
|
||||
vi.mock("@/lib/dashboard/snapshot", () => ({
|
||||
vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/dashboard/snapshot")>()),
|
||||
buildDashboardSnapshot: (...a: unknown[]) => buildDashboardSnapshot(...a),
|
||||
}));
|
||||
// The per-read briefing capability; its own behaviour is pinned in
|
||||
// `snapshot-read-briefing-capability.test.ts`.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityToServe: async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
@@ -1,254 +1,120 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.4.38 W-C M6 — Coach API route gate inventory.
|
||||
*
|
||||
* The web surfaces are protected by the `flags.coach` short-circuit in
|
||||
* every Coach-bearing component (see
|
||||
* `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The
|
||||
* server-side mirror is `requireAssistantSurface("coach")` in every
|
||||
* Coach-only route handler. Without a discovery test, a future
|
||||
* contributor who lands a new `/api/insights/<foo>/route.ts` whose body
|
||||
* touches the Coach stack would have to remember the gate by hand — a
|
||||
* silent miss leaks the surface even when the operator turned the
|
||||
* Coach matrix off.
|
||||
*
|
||||
* The walk below scans every `route.ts` under `src/app/api/insights/`
|
||||
* and groups each handler into one of three buckets:
|
||||
*
|
||||
* 1. Coach-gated — file imports + invokes
|
||||
* `requireAssistantSurface("coach")`.
|
||||
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
|
||||
* different surface (`insightStatus`,
|
||||
* `correlations`, `briefing`).
|
||||
* 3. Allowlisted — non-Coach Insights routes that don't gate on
|
||||
* the matrix at all (provider chain, settings,
|
||||
* targets, glp1-timeline, feedback). Allowlist
|
||||
* lives alongside the test for easy review.
|
||||
*
|
||||
* Anything that doesn't fit one of the three buckets is an orphan and
|
||||
* fails the test by name so the fix is one search-and-add.
|
||||
*/
|
||||
|
||||
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
|
||||
// Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as
|
||||
// the metric-status + specialised status routes (no Coach prose).
|
||||
"src/app/api/insights/biomarker-assessment/route.ts",
|
||||
"src/app/api/insights/blood-pressure-status/route.ts",
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
|
||||
// same `insightStatus` sub-flag as the seven specialised status routes.
|
||||
"src/app/api/insights/metric-status/route.ts",
|
||||
"src/app/api/insights/mood-status/route.ts",
|
||||
// v1.11.0 — period-narrative read route. Gates on the same `insightStatus`
|
||||
// sub-flag as the assessment routes (no Coach prose).
|
||||
"src/app/api/insights/narrative/route.ts",
|
||||
// v1.9.0 — on-demand full assessment warm. Warms the same assessment
|
||||
// cards the status routes serve, so it gates on `insightStatus`, not
|
||||
// `coach`: a user with assessments enabled but Coach disabled can warm.
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// Settings + chain inventory — surface-agnostic infra reads. The
|
||||
// settings UI itself is server-rendered and the chain page hides
|
||||
// when no provider is configured; neither one carries assistant
|
||||
// prose. Disabling Coach must not break these reads — the user can
|
||||
// still inspect and reconfigure the chain that gets re-enabled.
|
||||
"src/app/api/insights/provider-chain/route.ts",
|
||||
// Correlation statistics: computed, never written by a model. The switch
|
||||
// that used to gate them was retired because it stopped no AI work.
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
"src/app/api/insights/settings/route.ts",
|
||||
// Read-only data feeds — `/targets` is a non-assistant Zielwerte
|
||||
// page driven by classifications, not LLM prose; the GLP-1 timeline
|
||||
// is the same data the medication page paints whether the assistant
|
||||
// is on or off. Recommendation feedback (`/feedback`) records a
|
||||
// thumbs-up/-down on a NON-Coach recommendation row that was
|
||||
// produced before the operator flipped the flag; gating it would
|
||||
// mean the operator's mid-flight toggle erases the user's
|
||||
// ability to dispose of stale recommendations.
|
||||
"src/app/api/insights/feedback/route.ts",
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
// Deterministic GLP-1 plateau detector read (no assistant prose) — same
|
||||
// medications-domain posture as glp1-timeline directly above.
|
||||
"src/app/api/insights/glp1-plateau/route.ts",
|
||||
// v1.5.5 — per-user tile layout for the `/insights` surface. The
|
||||
// endpoint persists tile visibility + ordering only; it carries no
|
||||
// assistant prose and is the mirror of `/api/dashboard/widgets`,
|
||||
// which also sits outside the Coach gate. Disabling Coach must not
|
||||
// wedge the user's ability to reorder the insights tile strip.
|
||||
"src/app/api/insights/layout/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
// v1.25 — read-only awareness cards for the overview. Each is pure compute
|
||||
// over the rollup / lab tier (baseline-drift, sleep-breathing screening,
|
||||
// last-panel lab deltas) and carries no assistant prose; they gate on the
|
||||
// `insights` module, not on the Coach surface. Disabling Coach must not
|
||||
// wedge these factual reads.
|
||||
"src/app/api/insights/health-status/route.ts",
|
||||
"src/app/api/insights/breathing-screening/route.ts",
|
||||
"src/app/api/insights/labs-changes/route.ts",
|
||||
// S11 — the intraday pulse / tension read. Deterministic compute over the
|
||||
// day's raw PULSE / steps / workouts (10-minute mean shape + the cautious
|
||||
// elevated-at-rest window); it carries no assistant prose and gates on the
|
||||
// `insights` module, not the Coach surface. Same posture as health-status /
|
||||
// labs-changes directly above — disabling Coach must not wedge this read.
|
||||
"src/app/api/insights/pulse/intraday/route.ts",
|
||||
// Persisted correlation decisions are deterministic Insights data. They
|
||||
// gate on the Insights module and remain usable when Coach is disabled.
|
||||
"src/app/api/insights/patterns/route.ts",
|
||||
"src/app/api/insights/patterns/[id]/route.ts",
|
||||
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
|
||||
// mutations. These live under /insights/chat for URL grouping but are
|
||||
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
|
||||
// (`requireModuleEnabled`), NOT on the Coach assistant surface — parity with
|
||||
// the document-sheet chat (`/api/documents/inbound/[id]/chat`, outside this
|
||||
// scan). The security boundary here is the fence (no tools, no snapshot),
|
||||
// not the Coach gate; disabling Coach must not wedge a fenced document chat
|
||||
// any more than it wedges the document sheet. See the S7 design §3.2 step 1.
|
||||
"src/app/api/insights/chat/fenced/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts",
|
||||
// Deterministic reads behind the Insights overview: the comprehensive
|
||||
// overview query, the derived-metric tiles, the ECG list, strip and live
|
||||
// ingest, and the device-flagged rhythm events. None of them carries
|
||||
// assistant prose or calls a provider; they gate on the `insights` module
|
||||
// only. Switching the assistant off, the master or a sub-flag, must not
|
||||
// refuse them, or the overview fails to load.
|
||||
"src/app/api/insights/comprehensive/route.ts",
|
||||
"src/app/api/insights/derived/route.ts",
|
||||
"src/app/api/insights/derived/batch/route.ts",
|
||||
"src/app/api/insights/ecg/route.ts",
|
||||
"src/app/api/insights/ecg/[id]/route.ts",
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
const ANY_GATE_NEEDLE = "requireAssistantSurface(";
|
||||
import {
|
||||
AI_ROUTES,
|
||||
AI_ROUTE_TREES,
|
||||
DATA_ROUTES,
|
||||
PENDING_ROUTES,
|
||||
} from "../../__tests__/ai-route-inventory";
|
||||
|
||||
/**
|
||||
* Return true when the file contains the needle on a line that is NOT
|
||||
* a pure comment. A documentation comment that mentions the gate
|
||||
* (`// requireAssistantSurface("coach") protects this surface`) must
|
||||
* not satisfy the gate-presence check — otherwise a contributor who
|
||||
* deletes the actual call but leaves the docstring would slip through.
|
||||
* Every route in the trees where AI routes live is classified.
|
||||
*
|
||||
* This file used to sort the `/api/insights` routes into "Coach-gated",
|
||||
* "other-gated" and "not Coach-owned" by the operator switch each one read.
|
||||
* That sorting encoded the defect the AI-optional design removes: ECG uploads,
|
||||
* rhythm events, scores and correlation statistics sat behind AI switches, so
|
||||
* turning AI off took data down with it. The rule now is the capability rule:
|
||||
*
|
||||
* - a data route asks no AI capability and is never refused for an AI
|
||||
* reason;
|
||||
* - an AI route names its capability (`requireAiCapability` for an action,
|
||||
* `getAiCapability` for a mixed read).
|
||||
*
|
||||
* The lists live in `src/app/api/__tests__/ai-route-inventory.ts`; the whole
|
||||
* API tree is checked against them by `ai-capability-route-inventory.test.ts`.
|
||||
* This file walks the insights, Coach and daily trees and fails by name on any
|
||||
* route that none of the lists classifies, so a new route there has to decide
|
||||
* which kind it is. `PENDING_ROUTES` names the few whose AI gating another
|
||||
* change owns; an entry there that starts asking a capability fails until it
|
||||
* moves to `AI_ROUTES`.
|
||||
*
|
||||
* Mutation checks: deleting `insights/ecg/route.ts` from `DATA_ROUTES` fails
|
||||
* the orphan test by name; adding `getAiCapability("coach")` to a pending
|
||||
* route fails the pending test; pointing the walk at an empty tree fails the
|
||||
* count.
|
||||
*/
|
||||
function fileHasGateCall(text: string, needle: string): boolean {
|
||||
return text.split("\n").some((line) => {
|
||||
if (!line.includes(needle)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("*")) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `route.ts` under `src/app/api/insights/` (including
|
||||
* dynamic segments under `[id]`). Returns POSIX-style paths relative
|
||||
* to the repo root.
|
||||
*/
|
||||
function findInsightsRouteFiles(): string[] {
|
||||
// __dirname here is `src/app/api/insights/__tests__/`, so the
|
||||
// insights root is one directory up.
|
||||
const insightsRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(insightsRoot, "..", "..", "..", "..");
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
|
||||
function routeFiles(tree: string): string[] {
|
||||
const hits: string[] = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
if (entry === "node_modules") continue;
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
} else if (entry === "route.ts") {
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
if (entry !== "route.ts") continue;
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
walk(insightsRoot);
|
||||
return hits.sort();
|
||||
walk(resolve(repoRoot, tree));
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe("Coach API route gate inventory", () => {
|
||||
it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
const routes = findInsightsRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort();
|
||||
|
||||
const allowlistOther = new Set(NON_COACH_GATED_ROUTES);
|
||||
const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES);
|
||||
const ANY_GATE =
|
||||
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
|
||||
|
||||
const orphans: Array<{ path: string; reason: string }> = [];
|
||||
function code(path: string): string {
|
||||
return readFileSync(resolve(repoRoot, path), "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
for (const path of routes) {
|
||||
const full = resolve(repoRoot, path);
|
||||
const text = readFileSync(full, "utf8");
|
||||
const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE);
|
||||
const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE);
|
||||
|
||||
if (hasCoachGate) continue;
|
||||
|
||||
if (allowlistOther.has(path)) {
|
||||
// Sanity — the file should still gate on SOME assistant
|
||||
// surface. A stale entry on this allowlist is misleading
|
||||
// documentation; flip the failure mode so the orphan is
|
||||
// reported with the right diagnosis.
|
||||
if (!hasAnyGate) {
|
||||
orphans.push({
|
||||
path,
|
||||
reason:
|
||||
"listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowlistNone.has(path)) continue;
|
||||
|
||||
orphans.push({
|
||||
path,
|
||||
reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`,
|
||||
});
|
||||
describe("insights, Coach and daily route classification", () => {
|
||||
it("walks trees that exist and finds routes in them", () => {
|
||||
for (const tree of AI_ROUTE_TREES) {
|
||||
expect(existsSync(resolve(repoRoot, tree)), tree).toBe(true);
|
||||
}
|
||||
expect(ROUTES.length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("classifies every route as AI, data or pending", () => {
|
||||
const orphans = ROUTES.filter(
|
||||
(path) =>
|
||||
!(path in AI_ROUTES) &&
|
||||
!(path in DATA_ROUTES) &&
|
||||
!(path in PENDING_ROUTES),
|
||||
);
|
||||
expect(
|
||||
orphans,
|
||||
[
|
||||
"Coach API route gate inventory found unaccounted-for handler(s):",
|
||||
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
|
||||
"Unclassified routes. Decide what each one is and add it to",
|
||||
"src/app/api/__tests__/ai-route-inventory.ts:",
|
||||
" AI_ROUTES — it calls a model or serves model text (name the capability);",
|
||||
" DATA_ROUTES — it serves or accepts data and must never ask AI.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allowlists do not reference deleted route files", () => {
|
||||
const known = new Set(findInsightsRouteFiles());
|
||||
|
||||
const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter(
|
||||
(path) => !known.has(path),
|
||||
it("classifies no route twice", () => {
|
||||
const lists = [AI_ROUTES, DATA_ROUTES, PENDING_ROUTES].map((list) =>
|
||||
Object.keys(list),
|
||||
);
|
||||
const seen = new Map<string, number>();
|
||||
for (const list of lists) {
|
||||
for (const path of list) seen.set(path, (seen.get(path) ?? 0) + 1);
|
||||
}
|
||||
expect([...seen].filter(([, n]) => n > 1).map(([path]) => path)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
stale,
|
||||
[
|
||||
"Allowlist entries point to files that no longer exist —",
|
||||
"delete the stale entries from",
|
||||
"`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:",
|
||||
...stale.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
it("keeps PENDING_ROUTES honest: existing files, none of them asking yet", () => {
|
||||
for (const path of Object.keys(PENDING_ROUTES)) {
|
||||
expect(ROUTES, `${path} no longer exists`).toContain(path);
|
||||
expect(
|
||||
ANY_GATE.test(code(path)),
|
||||
`${path} now asks an AI capability — move it to AI_ROUTES`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,12 @@ vi.mock("@/lib/db", () => ({
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
// The `statusText` capability decides whether the note is served; the
|
||||
// unavailable body's provider-presence probe is stubbed.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
probeProviderPresence: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// The route resolves the `insights` module gate after `requireAuth()`. Mock
|
||||
// it default-enabled so the assertions ride through; the off → 403 coverage
|
||||
@@ -57,6 +63,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -80,6 +87,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(generateBiomarkerStatus).mockResolvedValue({
|
||||
hasProvider: true,
|
||||
text: "ok",
|
||||
@@ -121,19 +133,22 @@ describe("GET /api/insights/biomarker-assessment", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("403s + errorCode when insightStatus is disabled", async () => {
|
||||
it("answers 200 with no note while statusText is unavailable, calling no generator", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantDocumentAiEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const res = await callGet(makeReq("bm-1"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toMatchObject({
|
||||
text: null,
|
||||
preparing: false,
|
||||
ai: { available: false, reason: "user_disabled" },
|
||||
});
|
||||
expect(generateBiomarkerStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the safe terminal fallback produced when a causal claim is screened", async () => {
|
||||
@@ -161,6 +176,7 @@ describe("GET /api/insights/biomarker-assessment", () => {
|
||||
text: "The assessment could not be completed safely. Your recorded measurements remain available.",
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
ai: { available: true, reason: null, onDeviceAllowed: true },
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain("medication caused");
|
||||
expect(JSON.stringify(body)).not.toContain("causal_claim");
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
* provider. The generator regenerates ONLY when the latest reading
|
||||
* fingerprint changes, so an idle marker re-stamps cached text without an
|
||||
* LLM round-trip.
|
||||
*
|
||||
* A mixed read: the note is served, and warmed, only while the `statusText`
|
||||
* AI capability is available. Otherwise the route answers 200 with no note
|
||||
* and an `ai` state saying why, without reading the cache or queueing
|
||||
* anything. The `insights` module (the AI analysis opt-out) folds into the
|
||||
* capability.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -19,8 +25,8 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
|
||||
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
|
||||
|
||||
@@ -35,10 +41,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = querySchema.safeParse({
|
||||
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
|
||||
});
|
||||
@@ -50,6 +52,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.biomarker-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
@@ -71,5 +82,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { biomarkerId: parsed.data.biomarkerId },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -1,19 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
/**
|
||||
* The status family is a mixed read. The note is model text: served only
|
||||
* while the `statusText` capability is available. Otherwise the route
|
||||
* answers 200 with `text: null`, `preparing: false`, `hasProvider` as provider
|
||||
* presence and an `ai` state, and the generator (the cache read and the warm
|
||||
* enqueue behind it) is never called. The `insights` module is folded into
|
||||
* the capability and no longer refuses the route.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
// v1.18.0 — the route now resolves the `insights` module gate after
|
||||
// `requireAuth()`. Mock it default-enabled so the existing assertions
|
||||
// ride through; the off → 403 coverage lives in the route-gate inventory
|
||||
// test.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
@@ -38,22 +39,28 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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",
|
||||
})),
|
||||
generateBloodPressureStatusForUser: vi.fn(),
|
||||
resolveBloodPressureStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
// The `statusText` capability decides whether the note is served; the
|
||||
// unavailable body's provider-presence probe is stubbed.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
probeProviderPresence: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -72,30 +79,63 @@ function makeReq(): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
vi.mocked(probeProviderPresence).mockResolvedValue(true);
|
||||
vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({
|
||||
hasProvider: true,
|
||||
text: "ok",
|
||||
cached: true,
|
||||
updatedAt: "2026-09-20T06:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => {
|
||||
it("returns 200 when insightStatus is enabled (default)", async () => {
|
||||
describe("GET /api/insights/blood-pressure-status", () => {
|
||||
it("serves the note and the state while statusText is available", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.text).toBe("ok");
|
||||
expect(body.data.ai).toEqual(AI_AVAILABLE);
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "statusText");
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 + errorCode when insightStatus is off", async () => {
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"user_disabled",
|
||||
"consent_required",
|
||||
"check_failed",
|
||||
] as const)(
|
||||
"answers 200 with no note for %s, reading and warming nothing",
|
||||
async (reason) => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toEqual({
|
||||
hasProvider: true,
|
||||
text: null,
|
||||
cached: false,
|
||||
updatedAt: null,
|
||||
preparing: false,
|
||||
ai: aiUnavailable(reason),
|
||||
});
|
||||
expect(generateBloodPressureStatusForUser).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports hasProvider as presence only: false for a missing provider", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantDocumentAiEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(
|
||||
aiUnavailable("no_provider"),
|
||||
);
|
||||
vi.mocked(probeProviderPresence).mockResolvedValue(false);
|
||||
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");
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.hasProvider).toBe(false);
|
||||
expect(body.data.text).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
@@ -104,33 +144,3 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.18.0 (B2) — the route now also requires the `insights` module.
|
||||
// Representative coverage that a disabled module short-circuits with the
|
||||
// 403 module.disabled envelope before any provider / cache work; the
|
||||
// inventory test guards that EVERY insights AI route carries this gate.
|
||||
describe("GET /api/insights/blood-pressure-status — insights module gate", () => {
|
||||
it("returns 200 when insights 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 + module.disabled when insights is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "insights",
|
||||
}),
|
||||
});
|
||||
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("insights");
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.blood-pressure-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.bmi-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.bmi-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the
|
||||
* card states this explicitly.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `sleep`
|
||||
* module gate, the shared analytics-read budget. `userId` is narrowed from the
|
||||
* session.
|
||||
*/
|
||||
@@ -31,7 +31,10 @@ export const GET = apiHandler(async () => {
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
// The screening belongs to sleep: with the sleep module off it hides like
|
||||
// every other sleep surface. Not an AI read, so the `insights` module (the
|
||||
// AI analysis opt-out) does not gate it.
|
||||
const m = await requireModuleEnabled(user.id, "sleep");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
|
||||
@@ -15,9 +15,8 @@ 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.
|
||||
// The operator switches are read by nothing on this route any more; the
|
||||
// mock stays so a test can prove a switch-off changes nothing.
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
// v1.28 perf — PULSE now reads through the DAY-rollup read-swap
|
||||
// (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage
|
||||
@@ -28,10 +27,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.18.0 — the route now resolves the `insights` module gate after
|
||||
// `requireAuth()`. Mock it default-enabled so the existing assertions
|
||||
// (data shape + assistant-flag gating) ride through; the dedicated
|
||||
// off → 403 coverage lives in the route-gate inventory test.
|
||||
// The route no longer asks the `insights` module (the AI analysis opt-out);
|
||||
// the mock stays so a test can prove it is never consulted.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
@@ -80,7 +77,6 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
aiProvider: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
// v1.28 perf — empty coverage probe routes PULSE through the
|
||||
@@ -94,33 +90,29 @@ 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 () => {
|
||||
describe("GET /api/insights/cards — rule alerts are data", () => {
|
||||
it("answers 200 with every AI switch off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: false,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantDocumentAiEnabled: true,
|
||||
assistantDocumentAiEnabled: false,
|
||||
} 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");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 when the master flag is off", async () => {
|
||||
it("answers 200 with the AI analysis opt-out on, without asking the module", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,19 +157,23 @@ describe("GET /api/insights/cards", () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
measurements as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
aiProvider: "ANTHROPIC",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ severity: string; provider: string; title: string }>;
|
||||
data: Array<{
|
||||
severity: string;
|
||||
provider: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
}>;
|
||||
};
|
||||
expect(body.data.length).toBeGreaterThan(0);
|
||||
expect(body.data[0].provider).toBe("anthropic");
|
||||
// The threshold engine wrote these, never a model: the provider says so
|
||||
// and no card ever carries null text (a native decoder needs both).
|
||||
for (const card of body.data) {
|
||||
expect(card.provider).toBe("rules");
|
||||
expect(typeof card.summary).toBe("string");
|
||||
}
|
||||
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
* shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert`
|
||||
* to the iOS Insight model (id, title, summary, severity, recommendations,
|
||||
* provider).
|
||||
*
|
||||
* Every card is a rule alert: deterministic text from the threshold engine,
|
||||
* never model output. So the route carries no AI gate and no `insights`
|
||||
* module gate (the AI analysis opt-out), `summary` is never null, and
|
||||
* `provider` is `"rules"`. It used to echo the account's chosen AI provider
|
||||
* (or `"claude"`), which claimed a model wrote text that none did.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -68,12 +72,6 @@ export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// v1.4.31 — the iOS cards adapter feeds the same per-metric
|
||||
// insight surfaces the web `<InsightStatusCard>` mounts on each
|
||||
// /insights/<metric> sub-page. Both share the operator gate.
|
||||
await requireAssistantSurface("insightStatus");
|
||||
annotate({ action: { name: "insights.cards" } });
|
||||
|
||||
const NINETY_DAY_WINDOW = 90;
|
||||
@@ -83,7 +81,7 @@ export const GET = apiHandler(async () => {
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
|
||||
select: { heightCm: true, dateOfBirth: true },
|
||||
}),
|
||||
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
|
||||
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
|
||||
@@ -257,7 +255,8 @@ export const GET = apiHandler(async () => {
|
||||
medications: medicationCompliance,
|
||||
});
|
||||
|
||||
const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude";
|
||||
// The rule engine wrote these, not a model.
|
||||
const provider = "rules";
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const cards: InsightCard[] = alerts.map((alert, idx) => ({
|
||||
|
||||
@@ -2,14 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted(
|
||||
() => ({
|
||||
requireAuth: vi.fn(),
|
||||
requireAssistantSurface: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { requireAuth, annotate, auditLog } = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ApiHandlerModule>();
|
||||
@@ -20,7 +17,6 @@ vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
requireAuth,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
@@ -65,7 +61,6 @@ async function body(response: Response) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } });
|
||||
requireAssistantSurface.mockResolvedValue(undefined);
|
||||
auditLog.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -77,7 +72,6 @@ describe("PATCH /api/insights/chat/[id]", () => {
|
||||
await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe(
|
||||
unauthenticated,
|
||||
);
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
expect(renameConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types";
|
||||
|
||||
@@ -43,10 +42,9 @@ const renameConversationSchema = z
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
|
||||
// the conversation reader is part of the Coach stack (encrypted
|
||||
// assistant prose), so a disabled surface must 403 here too.
|
||||
await requireAssistantSurface("coach");
|
||||
// Never AI-gated: a stored conversation is the person's own data. Reading,
|
||||
// renaming and deleting it keep working while the Coach is unavailable for
|
||||
// any reason, so a thread can always be reviewed and erased.
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
@@ -74,7 +72,6 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
@@ -114,10 +111,8 @@ export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => {
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface
|
||||
// means the user can't reach the conversation list anyway, so the
|
||||
// delete affordance must stay behind the same kill-switch.
|
||||
await requireAssistantSurface("coach");
|
||||
// Not AI-gated, like GET: deleting one's own thread never waits on the
|
||||
// Coach being available.
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
|
||||
@@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
/**
|
||||
* The Coach chat POST (SSE) asks for the `coach` AI capability right after
|
||||
* auth, before the body is parsed or anything else runs. The capability folds
|
||||
* in what the Coach module gate, the operator switch and the `disableCoach`
|
||||
* opt-out used to answer separately, plus provider presence and consent.
|
||||
*
|
||||
* - Unavailable for any reason but a missing provider: the capability
|
||||
* envelope is thrown (apiHandler renders it) and nothing else runs.
|
||||
* - `no_provider`: the existing `coach.provider.none` SSE frame, now with
|
||||
* `reason`, because the streaming clients already read that frame.
|
||||
*
|
||||
* The conversation-list GET is the person's stored data: it never asks.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { requireAiCapability } = vi.hoisted(() => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiValidationError: (error: string, _issues: unknown, status: number) =>
|
||||
new Response(JSON.stringify({ data: null, error }), { status }),
|
||||
sanitiseZodIssues: () => [],
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
coachChatRequestSchema: {
|
||||
safeParse: () => ({ success: false, error: { issues: [] } }),
|
||||
},
|
||||
}));
|
||||
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
|
||||
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
|
||||
// thin stub keeps the import graph satisfied without pulling the real schemas.
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [],
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
renderDataInventory: vi.fn(),
|
||||
renderFocusHint: vi.fn(() => ""),
|
||||
buildToolModeAddendum: vi.fn(),
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(async () => ({
|
||||
conversations: [],
|
||||
nextCursor: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(),
|
||||
reserveBudget: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
reserved: 0,
|
||||
totalAfter: 0,
|
||||
})),
|
||||
reconcileSpend: vi.fn(),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
|
||||
// Collect the frames a stream would carry, so a refusal frame is observable.
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: vi.fn(
|
||||
(start: (c: { enqueue: (chunk: Uint8Array) => void }) => void) => {
|
||||
const chunks: string[] = [];
|
||||
start({
|
||||
enqueue: (chunk) => chunks.push(new TextDecoder().decode(chunk)),
|
||||
});
|
||||
return chunks.join("");
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
|
||||
type Envelope = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
const get = GET as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
function chatPostReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hi" }),
|
||||
});
|
||||
}
|
||||
|
||||
function listReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("coach chat capability gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operator_disabled", 403, "assistant.disabled.coach"],
|
||||
["module_disabled", 403, "module.disabled"],
|
||||
["user_disabled", 403, "module.disabled"],
|
||||
["consent_required", 403, "consent.ai.required"],
|
||||
["not_permitted_for_record", 403, "ai.record.notPermitted"],
|
||||
["check_failed", 503, "ai.unavailable"],
|
||||
] as const)(
|
||||
"POST refuses with the capability envelope for %s",
|
||||
async (reason, status, errorCode) => {
|
||||
requireAiCapability.mockRejectedValue(
|
||||
new AiUnavailableError("coach", reason),
|
||||
);
|
||||
|
||||
const refused = await post(chatPostReq()).catch((err: unknown) => err);
|
||||
|
||||
expect(refused).toBeInstanceOf(AiUnavailableError);
|
||||
const error = refused as AiUnavailableError;
|
||||
expect(error.status).toBe(status);
|
||||
expect(error.meta).toMatchObject({
|
||||
errorCode,
|
||||
capability: "coach",
|
||||
reason,
|
||||
});
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("coach");
|
||||
},
|
||||
);
|
||||
|
||||
it("POST keeps the coach.provider.none SSE frame for a missing provider, with the reason", async () => {
|
||||
requireAiCapability.mockRejectedValue(
|
||||
new AiUnavailableError("coach", "no_provider"),
|
||||
);
|
||||
|
||||
const res = await post(chatPostReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('"code":"coach.provider.none"');
|
||||
expect(body).toContain('"reason":"no_provider"');
|
||||
});
|
||||
|
||||
it("POST continues past the gate when the Coach is available", async () => {
|
||||
requireAiCapability.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
|
||||
const res = await post(chatPostReq());
|
||||
|
||||
// The stubbed schema refuses the body, so reaching validation proves the
|
||||
// gate let the request through.
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("GET never asks for the capability: stored conversations stay readable", async () => {
|
||||
requireAiCapability.mockRejectedValue(
|
||||
new AiUnavailableError("coach", "operator_disabled"),
|
||||
);
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAiCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -37,11 +37,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -30,11 +30,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -27,11 +27,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -22,12 +22,9 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
@@ -132,8 +129,6 @@ function listReq(qs = ""): Request {
|
||||
describe("GET /api/insights/chat — q search param", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
requireAssistantSurface.mockResolvedValue(undefined);
|
||||
listConversations.mockResolvedValue({
|
||||
conversations: [],
|
||||
nextCursor: null,
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both
|
||||
* enforce the two-layer module gate (operator availability AND the
|
||||
* per-user `disableCoach` opt-out) right after auth. A disabled module
|
||||
* returns the 403 `module.disabled` envelope verbatim before any other
|
||||
* Coach dependency runs; an enabled module falls through to the existing
|
||||
* assistant-flag check. The suite pins both branches so a refactor can't
|
||||
* quietly drop the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// The legacy assistant-flag gate the handler reaches AFTER the module
|
||||
// gate. We throw a sentinel so an enabled module is observable by the
|
||||
// control reaching this call.
|
||||
class AssistantSentinel extends Error {}
|
||||
|
||||
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
requireAssistantSurface: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
|
||||
|
||||
// Everything else the module imports — stubbed harmlessly; none of it is
|
||||
// reached on the disabled branch, and the enabled branch stops at the
|
||||
// assistant sentinel above.
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
}));
|
||||
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
|
||||
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
|
||||
// thin stub keeps the import graph satisfied without pulling the real schemas.
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [],
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
renderDataInventory: vi.fn(),
|
||||
renderFocusHint: vi.fn(() => ""),
|
||||
buildToolModeAddendum: vi.fn(),
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(async () => ({
|
||||
conversations: [],
|
||||
nextCursor: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(),
|
||||
reserveBudget: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
reserved: 0,
|
||||
totalAfter: 0,
|
||||
})),
|
||||
reconcileSpend: vi.fn(),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const post = POST as unknown as (req: Request) => Promise<Envelope>;
|
||||
const get = GET as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function chatPostReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hi" }),
|
||||
});
|
||||
}
|
||||
|
||||
function listReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("coach chat module gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAssistantSurface.mockImplementation(async () => {
|
||||
throw new AssistantSentinel("reached assistant surface");
|
||||
});
|
||||
});
|
||||
|
||||
it("POST returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await post(chatPostReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
// The gate short-circuits before the legacy assistant flag.
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST falls through to the assistant flag when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
|
||||
await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET reaches 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
requireAssistantSurface.mockResolvedValue(undefined);
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
});
|
||||
@@ -28,10 +28,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -41,11 +41,14 @@ const { isModuleEnabled } = vi.hoisted(() => ({
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
|
||||
@@ -43,7 +43,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
@@ -60,11 +59,8 @@ interface RouteContext {
|
||||
|
||||
async function handlePost(request: NextRequest, ctx: RouteContext) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.4.38 W-C M6 — Coach message feedback is a Coach surface
|
||||
// affordance; gate it on the same operator kill-switch so a
|
||||
// disabled Coach matrix can't be skirted through the feedback
|
||||
// endpoint.
|
||||
await requireAssistantSurface("coach");
|
||||
// Not AI-gated: a rating on a message that already exists is data about
|
||||
// the person's own thread and calls no model.
|
||||
const { id: messageId } = await ctx.params;
|
||||
|
||||
// Per-user rate limit — same shape as the recommendation feedback
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* and a closing `done` frame carrying the persisted message ids.
|
||||
*
|
||||
* Behaviour:
|
||||
* 1. requireAuth() — cookie session OR bearer token (iOS app).
|
||||
* 1. requireAuth() — cookie session OR bearer token (iOS app), then the
|
||||
* `coach` AI capability (operator switch, the Coach module and the
|
||||
* `disableCoach` opt-out, provider presence, consent). Refused with the
|
||||
* capability envelope, except `no_provider`, which keeps its
|
||||
* `coach.provider.none` SSE frame (now with `reason`).
|
||||
* 2. Validate body with `coachChatRequestSchema`.
|
||||
* 3. reserveBudget(resolveDailyCap(chain)) — 429 with
|
||||
* `coach.budget.exceeded` when the day's token cap for the
|
||||
@@ -41,8 +45,10 @@ import { redactOptional, redactSecrets } from "@/lib/logging/redact";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
@@ -187,16 +193,38 @@ function flushTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `coach` capability gate for a turn. Every unavailable reason throws the
|
||||
* capability envelope (`assistant.disabled.coach`, `module.disabled`,
|
||||
* `consent.ai.required`, `ai.record.notPermitted`, `ai.unavailable`), except
|
||||
* `no_provider`: the streaming clients already read that one as the
|
||||
* `coach.provider.none` SSE frame, so it keeps that wire and gains `reason`.
|
||||
* Returns the frame to send, or null when the Coach is available.
|
||||
*/
|
||||
async function coachCapabilityRefusal(): Promise<Response | null> {
|
||||
try {
|
||||
await requireAiCapability("coach");
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof AiUnavailableError && err.reason === "no_provider") {
|
||||
annotate({ action: { name: "insights.coach.noProvider" } });
|
||||
return streamProviderError({
|
||||
code: "coach.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// v1.18.0 — two-layer module gate (operator availability + per-user
|
||||
// disableCoach) on top of the legacy assistant flag. 403 module.disabled.
|
||||
const gate = await requireModuleEnabled(auth.user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
// 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");
|
||||
// The `coach` capability, right after auth and before the rate limit and
|
||||
// the body parse. It folds in what the Coach module gate, the operator
|
||||
// switch and the `disableCoach` opt-out used to answer separately, plus
|
||||
// provider presence and consent.
|
||||
const refused = await coachCapabilityRefusal();
|
||||
if (refused) return refused;
|
||||
const userId = auth.user.id;
|
||||
|
||||
let body: unknown;
|
||||
@@ -560,7 +588,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
annotate({
|
||||
action: { name: "insights.coach.noProvider" },
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.none" });
|
||||
return streamProviderError({
|
||||
code: "coach.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
@@ -579,6 +610,15 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// as before — the snapshot builder stays alive as the no-tools floor.
|
||||
const toolMode = chain.every((c) => c.instance.supportsTools !== false);
|
||||
|
||||
// The last check before the budget reservation and the provider call: the
|
||||
// `coach` capability again, at the egress site, so the call site names its
|
||||
// capability whatever happens to the gate at the top. Inside one request
|
||||
// the capability inputs are memoised, so this answers from the same
|
||||
// resolution as the top gate; a fresh read at the wire belongs to the
|
||||
// provider chokepoints, not to this route.
|
||||
const refusedAtEgress = await coachCapabilityRefusal();
|
||||
if (refusedAtEgress) return refusedAtEgress;
|
||||
|
||||
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
|
||||
// the provider call. The reservation increments the day's total by the
|
||||
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
|
||||
@@ -1613,13 +1653,18 @@ async function reportCoachStreamDefect(err: unknown): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function streamProviderError(args: { code: string }): Response {
|
||||
function streamProviderError(args: {
|
||||
code: string;
|
||||
/** The capability reason, when the refusal came from one. */
|
||||
reason?: AiUnavailableReason;
|
||||
}): Response {
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "error",
|
||||
code: args.code,
|
||||
message: args.code,
|
||||
...(args.reason ? { reason: args.reason } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1662,13 +1707,9 @@ const LIST_QUERY_MAX_LEN = 200;
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.18.0 — same two-layer module gate as the SSE POST.
|
||||
const gate = await requireModuleEnabled(auth.user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
// 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");
|
||||
// Never AI-gated and never module-gated: the conversation list is the
|
||||
// person's stored data. It stays readable while the Coach is unavailable
|
||||
// for any reason, so every thread can be found and deleted.
|
||||
const url = new URL(request.url);
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
|
||||
@@ -33,9 +33,6 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod
|
||||
* `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`),
|
||||
* cookie OR Bearer auth, `userId` narrowed from the session (never a query
|
||||
* field), insights-module gate, and the shared analytics-read budget.
|
||||
* field), and the shared analytics-read budget. No AI gate and no module
|
||||
* gate: both lines are computed, and the `insights` module is the AI analysis
|
||||
* opt-out.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -19,8 +21,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -37,13 +37,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// The strip is the ambient Coach presence on the metric page, so it
|
||||
// gates on the Coach assistant matrix too: an operator who turns the
|
||||
// Coach off must not see a "Coach read" line linger.
|
||||
await requireAssistantSurface("coach");
|
||||
// Pure compute over the baseline and correlation engines. The name is
|
||||
// historical: no model writes either line, so neither the Coach switch nor
|
||||
// the AI analysis opt-out refuses it.
|
||||
|
||||
// Shared analytics-read budget — generous; caps a runaway navigation loop.
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* convention is the idempotent-delete one used elsewhere in the tree
|
||||
* (a not-found delete is a successful no-op, not a 404).
|
||||
*
|
||||
* Coach-gated: same `requireAssistantSurface("coach")` kill-switch as the
|
||||
* collection route.
|
||||
* Never AI-gated, like the collection route: deleting a stored fact keeps
|
||||
* working while the Coach is unavailable.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -21,7 +21,6 @@ import { apiHandler, requireAuth } 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";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -30,7 +29,6 @@ interface RouteCtx {
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* returns the count.
|
||||
* - [id] DELETE soft-deletes the caller's own fact, and a cross-user /
|
||||
* unknown id is a 0-count no-op that returns `{ deleted: false }`.
|
||||
* - the `requireAssistantSurface("coach")` gate is present on every verb.
|
||||
* - no verb asks for the `coach` capability: the facts are the person's
|
||||
* data, readable and erasable while the Coach is unavailable.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -28,12 +29,17 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.31 — `requireAssistantSurface()` gates near the top of each
|
||||
// handler. Mock the module boundary so flag reads are deterministic; the
|
||||
// gate-presence assertions below verify the call is actually made.
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
AssistantDisabledError: class extends Error {},
|
||||
// The capability gate answers "unavailable" throughout: every verb must
|
||||
// still work, and must not even ask.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => {
|
||||
throw new Error("the coach capability was consulted");
|
||||
}),
|
||||
getAiCapability: vi.fn(async () => ({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the codec so the test never needs an encryption key. The "row"
|
||||
@@ -78,7 +84,10 @@ import { GET, DELETE } from "../route";
|
||||
import { DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import {
|
||||
getAiCapability,
|
||||
requireAiCapability,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -111,7 +120,6 @@ function deleteReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireAssistantSurface).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/facts", () => {
|
||||
@@ -223,10 +231,12 @@ describe("GET /api/insights/coach/facts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes the coach assistant-surface gate", async () => {
|
||||
it("lists facts while the Coach is unavailable, without asking", async () => {
|
||||
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
|
||||
await callGet();
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAiCapability).not.toHaveBeenCalled();
|
||||
expect(getAiCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,12 +271,14 @@ describe("DELETE /api/insights/coach/facts — forget all", () => {
|
||||
expect(body.data.cleared).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes the coach assistant-surface gate", async () => {
|
||||
it("forgets everything while the Coach is unavailable, without asking", async () => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
await callDeleteAll();
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
const res = await callDeleteAll();
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAiCapability).not.toHaveBeenCalled();
|
||||
expect(getAiCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,13 +328,15 @@ describe("DELETE /api/insights/coach/facts/[id] — forget one", () => {
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("invokes the coach assistant-surface gate", async () => {
|
||||
it("forgets one fact while the Coach is unavailable, without asking", async () => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
await callDeleteOne(deleteReq(), {
|
||||
const res = await callDeleteOne(deleteReq(), {
|
||||
params: Promise.resolve({ id: "f1" }),
|
||||
});
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAiCapability).not.toHaveBeenCalled();
|
||||
expect(getAiCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,20 +14,19 @@
|
||||
* skipped rather than 500ing the whole list — the surface stays available
|
||||
* for the rows that DO decrypt.
|
||||
*
|
||||
* Coach-gated: a fact only exists because the Coach extracted it, so the
|
||||
* management surface sits behind the same `requireAssistantSurface("coach")`
|
||||
* kill-switch as the rest of the Coach stack.
|
||||
* Never AI-gated. The facts are the person's stored data: viewing and
|
||||
* deleting them keeps working while the Coach is off for any reason (the
|
||||
* operator's switch, the person's own opt-out, no provider, no consent).
|
||||
* Erasure that depends on the feature being on is no erasure.
|
||||
*/
|
||||
import { apiHandler, requireAuth } 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 { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
const rows = await prisma.coachFact.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
@@ -80,7 +79,6 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// Soft-delete every active fact — keeps the rows for audit while
|
||||
// hiding them from injection. `updateMany` scoped to the caller can
|
||||
|
||||
@@ -11,19 +11,26 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
|
||||
requireRecordAuth: vi.fn(),
|
||||
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
|
||||
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
|
||||
}));
|
||||
const { requireRecordAuth, evaluateContext, readStatus, aiCapabilityToServe } =
|
||||
vi.hoisted(() => ({
|
||||
requireRecordAuth: vi.fn(),
|
||||
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
|
||||
readStatus: vi.fn(
|
||||
async (): Promise<{ unread: boolean; nudgedAt: string | null }> => ({
|
||||
unread: false,
|
||||
nudgedAt: null,
|
||||
}),
|
||||
),
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
}));
|
||||
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireRecordAuth,
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
|
||||
vi.mock("@/lib/ai/coach/nudge-status", () => ({
|
||||
readCoachNudgeStatus: readStatus,
|
||||
}));
|
||||
@@ -40,6 +47,7 @@ import { GET } from "../route";
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
|
||||
aiCapabilityToServe.mockResolvedValue(AVAILABLE);
|
||||
});
|
||||
|
||||
describe("nudge-status — NEXT_APP_OPEN hook", () => {
|
||||
@@ -93,3 +101,51 @@ describe("nudge-status — NEXT_APP_OPEN hook", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("nudge-status — the coach capability", () => {
|
||||
it("adds the resolved state beside the unread signal while available", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
readStatus.mockResolvedValue({ unread: true, nudgedAt: "2026-07-18" });
|
||||
|
||||
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(res.data).toEqual({
|
||||
unread: true,
|
||||
nudgedAt: "2026-07-18",
|
||||
ai: AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
|
||||
"answers the quiet 200 shape for %s, reading and evaluating nothing",
|
||||
async (reason) => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
const ai = { available: false, reason, onDeviceAllowed: false };
|
||||
aiCapabilityToServe.mockResolvedValue(ai);
|
||||
|
||||
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
|
||||
data: Record<string, unknown>;
|
||||
error: null;
|
||||
};
|
||||
|
||||
expect(res.error).toBeNull();
|
||||
expect(res.data).toEqual({
|
||||
nudgedAt: null,
|
||||
unread: false,
|
||||
conversationId: null,
|
||||
ai,
|
||||
});
|
||||
expect(readStatus).not.toHaveBeenCalled();
|
||||
expect(evaluateContext).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -21,10 +21,17 @@
|
||||
* `nudgedAt` carries the newest assistant-message timestamp so the FAB's
|
||||
* local seen-stamp keys on a stable value (kept for the existing client
|
||||
* contract).
|
||||
*
|
||||
* A mixed read: while the record's `coach` capability is unavailable
|
||||
* (operator switch, opt-out, no provider, no consent) the route still answers
|
||||
* 200, with the
|
||||
* quiet shape `{ unread: false, nudgedAt: null, conversationId: null }` and
|
||||
* an `ai` state saying why. There is no Coach to open, so there is nothing
|
||||
* unread, and no app-open reminder is evaluated.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -46,9 +53,15 @@ export const GET = apiHandler(async () => {
|
||||
// budget and writes into their conversation, and reading whether the thread
|
||||
// has something unopened does neither.
|
||||
const { user, actor } = await requireRecordAuth("read", "record");
|
||||
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
|
||||
// record is open.
|
||||
await requireAssistantSurface("coach");
|
||||
const ai = await aiCapabilityToServe(user.id, "coach");
|
||||
if (!ai.available) {
|
||||
return apiSuccess({
|
||||
nudgedAt: null,
|
||||
unread: false,
|
||||
conversationId: null,
|
||||
ai,
|
||||
});
|
||||
}
|
||||
|
||||
// NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the
|
||||
// app chrome, which makes it the app-open signal itself. Owner-only — a
|
||||
@@ -67,7 +80,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
|
||||
// readers compute the unread signal identically.
|
||||
return apiSuccess(await readCoachNudgeStatus(user.id));
|
||||
return apiSuccess({ ...(await readCoachNudgeStatus(user.id)), ai });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,16 +12,15 @@ const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
|
||||
// No settings row: every assistant switch at its default (on). A switch
|
||||
// read that fails now fails closed, so the row has to be readable.
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
// The `coach` capability decides whether an opener is served at all; pinned
|
||||
// here so the route test does not depend on the capability loader's reads.
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
const getAiCapability = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -84,6 +83,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
|
||||
getAiCapability.mockResolvedValue(AVAILABLE);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/seeded-question", () => {
|
||||
@@ -120,8 +120,25 @@ describe("GET /api/insights/coach/seeded-question", () => {
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
});
|
||||
expect(getAiCapability).toHaveBeenCalledWith("coach");
|
||||
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
|
||||
"answers { signal: null, ai } for %s without running the detector",
|
||||
async (reason) => {
|
||||
const ai = { available: false, reason, onDeviceAllowed: false };
|
||||
getAiCapability.mockResolvedValue(ai);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown; ai: unknown };
|
||||
};
|
||||
expect(body.data).toEqual({ signal: null, ai });
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
|
||||
@@ -17,14 +17,19 @@
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
* analytics-read rate limit, `insights` module gate.
|
||||
* analytics-read rate limit.
|
||||
*
|
||||
* A mixed read: the opener only exists to open the Coach, so it is served
|
||||
* only while the `coach` capability is available. Otherwise the route answers
|
||||
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
|
||||
* with the reason beside it.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
@@ -46,17 +51,19 @@ export interface CoachSeededQuestionDTO {
|
||||
/** Band — `yellow` / `red` (green never surfaces as notable). */
|
||||
band: string;
|
||||
} | null;
|
||||
/** Whether the Coach this opener feeds is available, and why not. */
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// The opener feeds the Coach hero, so it gates on the Coach assistant
|
||||
// matrix: with the Coach off there is no hero to seed.
|
||||
await requireAssistantSurface("coach");
|
||||
// The opener feeds the Coach hero: with the Coach unavailable there is no
|
||||
// hero to seed, so the neutral shape answers and says why.
|
||||
const ai = await getAiCapability("coach");
|
||||
if (!ai.available) {
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
@@ -77,7 +84,7 @@ export const GET = apiHandler(async () => {
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: { has_signal: false, source_metric: "none", band: "none" },
|
||||
});
|
||||
return apiSuccess({ signal: null } satisfies CoachSeededQuestionDTO);
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader; passed into the detector,
|
||||
@@ -102,5 +109,5 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ signal } satisfies CoachSeededQuestionDTO);
|
||||
return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO);
|
||||
});
|
||||
@@ -10,15 +10,16 @@
|
||||
*
|
||||
* No request body — the timestamp is server-minted from `now()` so a
|
||||
* client can never backdate the stamp to suppress a future nudge.
|
||||
*
|
||||
* No AI gate: a timestamp on the caller's own row is data, whatever the
|
||||
* Coach's availability.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
const seenAt = new Date();
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -81,16 +81,21 @@ vi.mock("@/lib/insights/comprehensive-aggregator", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(async () => ({ type: "none" })),
|
||||
probeProviderPresence: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
// The per-request `ai` block beside the body. Pinned here so the envelope
|
||||
// assertions do not depend on the capability loader's own reads.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// The route gates on the `insights` module. Mock it default-enabled so the
|
||||
// envelope assertions ride through; the module-off coverage lives in the
|
||||
// module route-gate inventory test.
|
||||
// The route no longer gates on the `insights` module (the AI analysis
|
||||
// opt-out); the mock stays so a test can prove the route never asks it.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
@@ -100,6 +105,8 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
@@ -125,6 +132,12 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// resetAllMocks drops the module gate's default; restore it.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(probeProviderPresence).mockResolvedValue(false);
|
||||
__resetAllCachesForTests();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
@@ -250,6 +263,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers with the AI analysis opt-out on, and publishes why the AI parts are absent", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
data: { ai: Record<string, { reason: string }> };
|
||||
};
|
||||
expect(body.data.ai.briefing.reason).toBe("user_disabled");
|
||||
expect(body.data.ai.statusText.reason).toBe("user_disabled");
|
||||
expect(
|
||||
vi
|
||||
.mocked(aiCapabilityToServe)
|
||||
.mock.calls.map((c) => c[1])
|
||||
.sort(),
|
||||
).toEqual(["briefing", "statusText"]);
|
||||
});
|
||||
|
||||
it("reports hasProvider from the presence probe, never from building a client", async () => {
|
||||
vi.mocked(probeProviderPresence).mockResolvedValue(true);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as { data: { hasProvider: boolean } };
|
||||
expect(body.data.hasProvider).toBe(true);
|
||||
expect(probeProviderPresence).toHaveBeenCalledWith("user-comp-1");
|
||||
});
|
||||
|
||||
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@@ -49,19 +49,19 @@ export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// No assistant-surface gate. Every field is computed from the record
|
||||
// (the provider chain is only probed for `hasProvider`), and this is the
|
||||
// main read of the Insights overview: an operator who switches the
|
||||
// assistant off still gets the overview.
|
||||
// No AI gate, and no `insights` module gate (that module is the AI
|
||||
// analysis opt-out). Every field is computed from the record (the provider
|
||||
// chain is only probed for presence, for `hasProvider`), and this is the
|
||||
// main read of the Insights overview: switching AI off, or never setting a
|
||||
// provider up, still gets the overview. The `ai` block beside the body says
|
||||
// whether the briefing and status notes that decorate the overview can be
|
||||
// shown, resolved per request and never cached with the body.
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
@@ -94,7 +94,16 @@ export const GET = apiHandler(async () => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess({ ...body, revalidating: outcome === "stale" });
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
aiCapabilityToServe(user.id, "briefing"),
|
||||
aiCapabilityToServe(user.id, "statusText"),
|
||||
]);
|
||||
|
||||
return apiSuccess({
|
||||
...body,
|
||||
revalidating: outcome === "stale",
|
||||
ai: { briefing, statusText },
|
||||
});
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
@@ -599,7 +608,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
hasProvider: (await resolveProvider(userId)).type !== "none",
|
||||
// Presence only: no client is built and no token is refreshed on a read.
|
||||
hasProvider: await probeProviderPresence(userId),
|
||||
dataSpanDays,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
|
||||
@@ -276,10 +276,11 @@ describe("GET /api/insights/correlations", () => {
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// v1.18.0 (B2) — the route now also requires the `insights` module.
|
||||
it("returns 403 + module.disabled when the insights module is off", async () => {
|
||||
// The `insights` module is the AI analysis opt-out. Correlations are
|
||||
// statistics, so turning AI analysis off must not take them away.
|
||||
it("serves the statistics with the insights module (the AI analysis opt-out) off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
@@ -287,13 +288,7 @@ describe("GET /api/insights/correlations", () => {
|
||||
}),
|
||||
});
|
||||
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("insights");
|
||||
// The disabled-module request never reaches the series reads.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,7 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
requireModuleEnabled,
|
||||
resolveModuleMap,
|
||||
} from "@/lib/modules/gate";
|
||||
import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -71,12 +67,10 @@ export const GET = apiHandler(async () => {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// No assistant switch: this is statistics, not model output. The retired
|
||||
// Correlations switch gated it and nothing model-written; an operator's AI
|
||||
// switches never take a computation down.
|
||||
// No AI gate and no `insights` module gate: this is statistics, not model
|
||||
// output. The retired Correlations switch gated it and nothing model-written;
|
||||
// an operator's AI switches, and the person's AI analysis opt-out, never
|
||||
// take a computation down.
|
||||
|
||||
// Reader's locale for the narrated `interpretation` — the correlation cards
|
||||
// render this string verbatim, so it MUST be localised (cookie / User.locale /
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { instructionLocale } from "@/lib/ai/prompts/output-language";
|
||||
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { DERIVED_MODULE } from "../route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -117,10 +117,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on
|
||||
// the path.
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// Same deterministic compute as the single route, so no
|
||||
// assistant-surface gate.
|
||||
// Same deterministic compute as the single route: no AI gate, and no
|
||||
// `insights` module gate (that module is the AI analysis opt-out). Per-metric
|
||||
// module ownership still applies below through `DERIVED_MODULE`.
|
||||
|
||||
// Per-caller limiter, same posture as the compliance routes: the cold
|
||||
// build fans out up to 24 rollup walks, so an unthrottled caller could
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types";
|
||||
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -88,17 +89,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Pure compute over the rollup tier, so no assistant-surface gate:
|
||||
// switching the assistant off does not blank the derived tiles.
|
||||
// Pure compute over the rollup tier, so no AI gate and no `insights`
|
||||
// module gate (the AI analysis opt-out): switching AI off does not blank the
|
||||
// derived tiles. Only the model-written assessment below depends on AI.
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
@@ -159,11 +158,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
// The model-written override is served, and warmed, only while
|
||||
// `statusText` is available. Otherwise the deterministic text stands and
|
||||
// `ai` says why there is no warmer prose.
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
const assessment = await resolveDerivedAssessment({
|
||||
metric,
|
||||
userId: user.id,
|
||||
derived,
|
||||
locale: resolvedLocale,
|
||||
aiAvailable: ai.available,
|
||||
});
|
||||
|
||||
// Flatten the discriminated union for the wire: `metric` tags it,
|
||||
@@ -177,5 +181,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
provenance: derived.provenance,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
assessment,
|
||||
ai,
|
||||
});
|
||||
});
|
||||
@@ -20,15 +20,14 @@
|
||||
* a verdict of its own.
|
||||
*
|
||||
* Mirrors the `rhythm-events` gating: `apiHandler`, cookie OR Bearer auth,
|
||||
* `userId` from the session, the `insights` module gate, and the
|
||||
* `insightStatus` assistant-surface gate. No AI provider call.
|
||||
* `userId` from the session. No AI gate and no module gate: the strip is
|
||||
* device data. No AI provider call.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
|
||||
import {
|
||||
@@ -45,9 +44,8 @@ export const GET = apiHandler(
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// The waveform is never interpreted, so no assistant-surface gate.
|
||||
// The waveform is never interpreted, so neither an AI gate nor the AI
|
||||
// analysis opt-out applies.
|
||||
|
||||
const { id } = await params;
|
||||
const full = request.nextUrl.searchParams.get("full") === "1";
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
*
|
||||
* Both verbs mirror the `rhythm-events` route gating exactly: `apiHandler`
|
||||
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session (never a
|
||||
* body or query field), the `insights` module gate, and the `insightStatus`
|
||||
* assistant-surface gate. No AI provider call on either path.
|
||||
* body or query field). No AI gate and no module gate: a recording is device
|
||||
* data, and the `insights` module is the AI analysis opt-out. An ingest is
|
||||
* never refused because AI is off; the POST keeps its own rate limit.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
|
||||
@@ -110,10 +110,6 @@ export const GET = apiHandler(async () => {
|
||||
// keeps `requireAuth()`: a delegate's phone must never move somebody else's
|
||||
// sync.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// A pure read of the device's own recordings, so no assistant-surface
|
||||
// gate.
|
||||
|
||||
const rows = await prisma.ecgRecording.findMany({
|
||||
where: { userId: user.id },
|
||||
@@ -179,10 +175,8 @@ export const GET = apiHandler(async () => {
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// A device ingest carries no assistant prose, so switching the assistant
|
||||
// off must not refuse a recording.
|
||||
// A device ingest carries no model text: switching AI off, or the AI
|
||||
// analysis opt-out, must never refuse a recording.
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`insights:ecg:ingest:${user.id}`,
|
||||
|
||||
@@ -23,13 +23,12 @@ 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 {},
|
||||
}));
|
||||
// The `briefing` capability is required near the top of both handlers.
|
||||
// The test mocks the apiHandler wrapper itself out, so the gate needs a
|
||||
// deterministic mock at the module boundary: available by default, and a
|
||||
// refusal where a test says so.
|
||||
const requireAiCapability = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability }));
|
||||
|
||||
const selfContextText = vi.hoisted(() => vi.fn<() => Promise<string | null>>());
|
||||
|
||||
@@ -172,9 +171,15 @@ import {
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAiCapability.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
// The B5b fallback runner caches "last working provider" per user
|
||||
// across calls; tests that assert single-provider error mapping
|
||||
// need a fresh cache so the previous test's success doesn't reorder
|
||||
@@ -982,3 +987,48 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(body.data.hasProvider).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the briefing capability", () => {
|
||||
it.each([
|
||||
["operator_disabled", 403, "assistant.disabled.briefing"],
|
||||
["user_disabled", 403, "module.disabled"],
|
||||
["consent_required", 403, "consent.ai.required"],
|
||||
["no_provider", 422, "ai.provider.none"],
|
||||
] as const)(
|
||||
"GET refuses with the envelope for %s before reading the cache or warming",
|
||||
async (reason, status, errorCode) => {
|
||||
requireAiCapability.mockRejectedValue(
|
||||
new AiUnavailableError("briefing", reason),
|
||||
);
|
||||
|
||||
const refused = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/generate")).catch(
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
expect(refused).toBeInstanceOf(AiUnavailableError);
|
||||
expect((refused as AiUnavailableError).status).toBe(status);
|
||||
expect((refused as AiUnavailableError).meta.errorCode).toBe(errorCode);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("briefing");
|
||||
// Stored briefing text is never read, and nothing is warmed.
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("POST refuses before any read or provider work", async () => {
|
||||
requireAiCapability.mockRejectedValue(
|
||||
new AiUnavailableError("briefing", "operator_disabled"),
|
||||
);
|
||||
|
||||
const refused = await POST(jsonRequest({ force: true }) as never).catch(
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
expect(refused).toBeInstanceOf(AiUnavailableError);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("briefing");
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,8 @@ 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 { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
@@ -139,14 +140,22 @@ export function resolveInsightsRateLimit(): number {
|
||||
* cron and the "prepare assessments" button use). The next read reflects
|
||||
* the fresh briefing. User-initiated regeneration stays on the POST path.
|
||||
*
|
||||
* A pure AI read: the payload is the model-written briefing and nothing
|
||||
* else, so it keeps a typed refusal rather than answering 200 with nothing.
|
||||
* The `briefing` capability is required (operator switch, the AI analysis
|
||||
* opt-out, provider presence, consent); an unavailable one refuses with the
|
||||
* capability envelope (`assistant.disabled.briefing`, `module.disabled`,
|
||||
* `consent.ai.required`, `ai.provider.none` 422, …) before the cache is read,
|
||||
* so stored briefing text is never served and no warm is enqueued.
|
||||
*
|
||||
* `userId` is narrowed from the session / Bearer — never a body field.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// Same surface gate as the POST + the read-only status routes: a user
|
||||
// with assessments enabled but Coach disabled still reads the cached
|
||||
// briefing.
|
||||
await requireAssistantSurface("coach");
|
||||
// The briefing is its own capability now, not the Coach's: a user with the
|
||||
// Coach hidden still reads their briefing, and one who opted out of AI
|
||||
// analysis does not.
|
||||
await requireAiCapability("briefing");
|
||||
const userId = user.id;
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
@@ -304,11 +313,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// sibling consumer) for this request.
|
||||
withFeatureCacheScope(async () => {
|
||||
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");
|
||||
// The `briefing` capability, before any read or provider work. Refused
|
||||
// with the capability envelope; no provider is `ai.provider.none` on the
|
||||
// 422 this route has always used for it.
|
||||
await requireAiCapability("briefing");
|
||||
const userId = user.id;
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
@@ -490,6 +498,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
return apiError(
|
||||
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
||||
422,
|
||||
{ errorCode: AI_PROVIDER_NONE_ERROR_CODE },
|
||||
);
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* the rollup tier: no provider call, no fabricated value, never a diagnosis.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the
|
||||
* `insights` module gate, the shared analytics-read budget. `userId` is always
|
||||
* shared analytics-read budget. No module gate: the drift read spans every
|
||||
* vital, and the `insights` module is the AI analysis opt-out, which a
|
||||
* computation does not depend on. `userId` is always
|
||||
* narrowed from the session. The labels are mapped client-side from the
|
||||
* MeasurementType, so the wire carries the type tokens only.
|
||||
*/
|
||||
@@ -18,7 +20,6 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
@@ -32,9 +33,6 @@ export const GET = apiHandler(async () => {
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* about recent news, and it stops being made rather than being made about a
|
||||
* years-old draw. Neutral framing only — a delta is not a diagnosis.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `labs`
|
||||
* module gate, the shared analytics-read budget. `userId` is narrowed from the
|
||||
* session; soft-deleted rows (`deletedAt`) are filtered out.
|
||||
*/
|
||||
@@ -30,7 +30,10 @@ export const GET = apiHandler(async () => {
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
// Lab deltas belong to labs: with the labs module off the card hides like
|
||||
// every other labs surface. Not an AI read, so the `insights` module (the
|
||||
// AI analysis opt-out) does not gate it.
|
||||
const m = await requireModuleEnabled(user.id, "labs");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`summary: null`, no per-medication notes, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.medication-compliance-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableComplianceStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -4,8 +4,15 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
measurement: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
// The `statusText` capability decides whether the note is served; the
|
||||
// unavailable body's provider-presence probe is stubbed.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
probeProviderPresence: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// v1.18.0 — the route now resolves the `insights` module gate after
|
||||
// `requireAuth()` (in addition to the existing per-metric sleep/glucose/
|
||||
@@ -55,6 +62,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -77,6 +85,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/metric-status", () => {
|
||||
@@ -126,18 +139,29 @@ describe("GET /api/insights/metric-status", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("403s + 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,
|
||||
assistantDocumentAiEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
});
|
||||
it.each([
|
||||
[0, true],
|
||||
[3, false],
|
||||
] as const)(
|
||||
"answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)",
|
||||
async (readings, insufficient) => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(readings as never);
|
||||
const res = await callGet(makeReq("RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toMatchObject({
|
||||
text: null,
|
||||
preparing: false,
|
||||
insufficient,
|
||||
ai: { available: false, reason: "operator_disabled" },
|
||||
});
|
||||
expect(generateMetricStatus).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,12 @@
|
||||
* text, never blocking on the provider. An unknown `metric` 422s via the
|
||||
* closed registry enum.
|
||||
*
|
||||
* A mixed read: the note is model text, served (and warmed on a miss) only
|
||||
* while the `statusText` AI capability is available. Otherwise the route
|
||||
* answers 200 with no note, `insufficient` still computed from the data, and
|
||||
* an `ai` state saying why; the cache is not read and nothing is queued. The
|
||||
* `insights` module (the AI analysis opt-out) folds into the capability.
|
||||
*
|
||||
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
|
||||
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
|
||||
* establishing the pattern the legacy routes can be backfilled onto later.
|
||||
@@ -23,14 +29,17 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } 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 { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
generateMetricStatus,
|
||||
resolveMetricStatusLocale,
|
||||
} from "@/lib/insights/metric-status";
|
||||
import {
|
||||
METRIC_STATUS_IDS,
|
||||
getMetricStatusMeta,
|
||||
type MetricStatusMetricId,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
|
||||
@@ -72,10 +81,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = metricQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
});
|
||||
@@ -99,6 +104,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!gate.enabled) return gate.response;
|
||||
}
|
||||
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
// The note is not served, but whether there is anything to assess is
|
||||
// data: the card keeps its insufficient-data state.
|
||||
const meta = getMetricStatusMeta(metric);
|
||||
const readings = meta
|
||||
? await prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: meta.measurementType,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
annotate({
|
||||
action: { name: "insights.metric-status.unavailable" },
|
||||
meta: { metric, reason: ai.reason },
|
||||
});
|
||||
return apiSuccess({
|
||||
...(await unavailableStatusBody(user.id, ai)),
|
||||
insufficient: readings === 0,
|
||||
});
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
@@ -120,5 +149,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { metric },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -10,6 +10,12 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
// The `statusText` capability decides whether the note is served; the
|
||||
// unavailable body's provider-presence probe is stubbed.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
probeProviderPresence: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -45,6 +51,7 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -74,6 +81,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/mood-status — module gate", () => {
|
||||
@@ -102,6 +114,30 @@ describe("GET /api/insights/mood-status — module gate", () => {
|
||||
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the mood module gate ahead of the capability", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
userRow({ mood: false }) as never,
|
||||
);
|
||||
await callGet(makeReq());
|
||||
expect(aiCapabilityToServe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 200 with no note while statusText is unavailable", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "no_provider",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.text).toBeNull();
|
||||
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
|
||||
@@ -7,26 +7,41 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// 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 ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.mood-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -47,5 +62,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.mood-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -10,6 +10,17 @@
|
||||
*
|
||||
* `userId` is narrowed from the session/Bearer (never a query field); an
|
||||
* unknown `period` 422s via the closed enum.
|
||||
*
|
||||
* A mixed read. The narrative is data when the deterministic composer wrote
|
||||
* it and model output when a provider did. Model-written text is served only
|
||||
* while the record's `periodNarrative` capability is available (answered from
|
||||
* the record's own state, so a delegate reads the owner's narrative exactly
|
||||
* when the owner would); otherwise `narrative` is null and `ai` says why. The
|
||||
* warm is enqueued only when the capability is available, or when no row
|
||||
* exists at all (the worker then writes the deterministic narrative); never
|
||||
* for a delegate. The `insights` module is the AI analysis
|
||||
* opt-out and is folded into the capability, so it no longer refuses the
|
||||
* route.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -18,8 +29,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
|
||||
import {
|
||||
PERIOD_DAYS,
|
||||
@@ -57,9 +68,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = narrativeQuerySchema.safeParse({
|
||||
period: request.nextUrl.searchParams.get("period"),
|
||||
@@ -80,10 +88,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = narrowLocale(resolved);
|
||||
|
||||
// Whether model-written text may be shown is the record's own answer,
|
||||
// whoever is reading; the reader already applies it (a model-written row
|
||||
// reads as absent while it may not be shown). Starting a warm for a
|
||||
// delegate stays suppressed below.
|
||||
const ai = await aiCapabilityToServe(user.id, "periodNarrative");
|
||||
const existing = await readPeriodNarrative(user.id, period, locale);
|
||||
const isFresh =
|
||||
existing !== null &&
|
||||
Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS;
|
||||
// With AI unavailable a warm only helps when no row exists at all: the
|
||||
// worker then writes the deterministic narrative. A hidden model-written
|
||||
// row must not be overwritten by one (turning AI back on brings it back),
|
||||
// and a stale deterministic row is served as it is.
|
||||
const mayWarm =
|
||||
ai.available ||
|
||||
(existing === null &&
|
||||
(await prisma.insightNarrative.findUnique({
|
||||
where: { userId_period_locale: { userId: user.id, period, locale } },
|
||||
select: { id: true },
|
||||
})) === null);
|
||||
|
||||
// Read-only: never block on the provider. Warm out of band whenever the row
|
||||
// is stale / missing. The generator produces AI prose when a provider is
|
||||
@@ -98,7 +122,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// The stale row is still served; only the warm is withheld.
|
||||
let revalidating = false;
|
||||
const generationSuppressed = delegatedGenerationSuppressed();
|
||||
if (!isFresh && !generationSuppressed) {
|
||||
if (!isFresh && mayWarm && !generationSuppressed) {
|
||||
void enqueueNarrativeWarm({ userId: user.id, period, locale });
|
||||
revalidating = true;
|
||||
}
|
||||
@@ -110,6 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
has_narrative: existing !== null,
|
||||
revalidating,
|
||||
generation_suppressed: generationSuppressed,
|
||||
ai_reason: ai.reason,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,5 +149,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
: null,
|
||||
revalidating,
|
||||
ai,
|
||||
});
|
||||
});
|
||||
@@ -69,16 +69,19 @@ describe("PATCH /api/insights/patterns/[id]", () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
it("refuses a dismissal when Insights is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
it("accepts a dismissal whatever the AI analysis opt-out says (statistics are data)", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403),
|
||||
});
|
||||
|
||||
const response = await PATCH(request({ dismissed: true }), params);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
|
||||
// No such pattern in this fixture: reaching the lookup proves no module
|
||||
// gate stood in front of it.
|
||||
expect(response.status).toBe(404);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
expect(prisma.correlationPattern.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the current evidence as the account-scoped dismissal baseline", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -26,8 +25,6 @@ export const PATCH = apiHandler(
|
||||
// at no level below. The dismissal stores its own evidence hash and effect
|
||||
// size, so it reads back and reverses.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const parsedId = patternIdSchema.safeParse((await params).id);
|
||||
if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422);
|
||||
const id = parsedId.data;
|
||||
|
||||
@@ -54,15 +54,16 @@ describe("GET /api/insights/patterns", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the list when Insights is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
it("serves the list whatever the AI analysis opt-out says (statistics are data)", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403),
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.correlationPattern.findMany).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
expect(prisma.correlationPattern.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -10,8 +9,9 @@ export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
// Correlation patterns are statistics over the record's own rows. No model
|
||||
// writes them, so the `insights` module (the AI analysis opt-out) does not
|
||||
// gate them.
|
||||
const patterns = await prisma.correlationPattern.findMany({
|
||||
where: { userId: user.id, isCurrent: true },
|
||||
orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }],
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
/**
|
||||
* `POST /api/insights/pregenerate` warms two AI capabilities, `briefing` and
|
||||
* `statusText`. It returns no model output, so it never refuses: with
|
||||
* neither available it answers 200 `{ queued: false }` and enqueues nothing;
|
||||
* with either available it enqueues (the worker checks each half). The `ai`
|
||||
* block always says which half can run.
|
||||
*/
|
||||
|
||||
// v1.18.0 — the route now resolves the `insights` module gate after
|
||||
// `requireAuth()`. Mock it default-enabled so the existing assertions
|
||||
// ride through; the off → 403 coverage lives in the route-gate inventory
|
||||
// test.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -38,8 +32,6 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Always allow the anti-spam bucket so the surface gate is what the test
|
||||
// exercises.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
@@ -49,11 +41,18 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
|
||||
enqueueForceWarm: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -72,57 +71,73 @@ function makeReq(): NextRequest {
|
||||
});
|
||||
}
|
||||
|
||||
function capabilities(
|
||||
states: Partial<Record<AiCapabilityKey, ReturnType<typeof aiUnavailable>>>,
|
||||
) {
|
||||
vi.mocked(getAiCapability).mockImplementation(
|
||||
async (key) => states[key] ?? AI_AVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
capabilities({});
|
||||
});
|
||||
|
||||
describe("POST /api/insights/pregenerate", () => {
|
||||
it("enqueues a warm when insightStatus is enabled", async () => {
|
||||
it("enqueues a warm when both halves are available", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { queued: boolean } | null;
|
||||
data: { queued: boolean; ai: Record<string, unknown> } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.queued).toBe(true);
|
||||
expect(body.data?.ai).toEqual({
|
||||
briefing: AI_AVAILABLE,
|
||||
statusText: AI_AVAILABLE,
|
||||
});
|
||||
expect(enqueueForceWarm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => {
|
||||
it("enqueues when only one half is available (the worker skips the other)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
} as never);
|
||||
capabilities({ briefing: aiUnavailable("operator_disabled") });
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("403s + 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,
|
||||
assistantDocumentAiEnabled: true,
|
||||
} as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"user_disabled",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const)(
|
||||
"answers 200 { queued: false } and enqueues nothing when neither half is available (%s)",
|
||||
async (reason) => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
capabilities({
|
||||
briefing: aiUnavailable(reason),
|
||||
statusText: aiUnavailable(reason),
|
||||
});
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { queued: boolean; ai: { briefing: { reason: string } } };
|
||||
};
|
||||
expect(body.data.queued).toBe(false);
|
||||
expect(body.data.ai.briefing.reason).toBe(reason);
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
// Nothing to warm, so no anti-spam bucket is spent either.
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
@@ -21,12 +21,18 @@
|
||||
* `userId` is always narrowed from the session / Bearer — never a body
|
||||
* field. Both web and the iOS client can call it; they read the same
|
||||
* cached routes afterward.
|
||||
*
|
||||
* The warm covers two AI capabilities: `briefing` (the comprehensive
|
||||
* insight) and `statusText` (the assessment cards). It returns no model
|
||||
* output, so it never refuses: with neither capability available it answers
|
||||
* 200 `{ queued: false }` and enqueues nothing; with one available it
|
||||
* enqueues, and the worker checks each half before it builds anything. The
|
||||
* `ai` block says which half can run.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,21 +46,12 @@ const WARM_WINDOW_MS = 3 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// Gate on the same surface as the read-only status routes: this warms
|
||||
// the assessment cards (the `insightStatus` surface), not the Coach.
|
||||
// A user with assessments enabled but Coach disabled can still warm.
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const userId = user.id;
|
||||
|
||||
// Short per-user bucket so the bypassed nightly budget can't be abused
|
||||
// into a provider-cost amplifier by a tight POST loop. A blocked call
|
||||
// is harmless — the caches are already being warmed by the prior call.
|
||||
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
|
||||
if (!rl.allowed) {
|
||||
return apiError("A warm is already in progress. Try again shortly.", 429);
|
||||
}
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
getAiCapability("briefing"),
|
||||
getAiCapability("statusText"),
|
||||
]);
|
||||
const ai = { briefing, statusText };
|
||||
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
@@ -65,6 +62,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// German warm, so the whole warmed cache family was in the wrong language.
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// Nothing this warm covers can run: enqueue nothing, spend no bucket.
|
||||
if (!briefing.available && !statusText.available) {
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.skipped" },
|
||||
meta: {
|
||||
locale,
|
||||
briefing_reason: briefing.reason,
|
||||
status_reason: statusText.reason,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ queued: false, locale, ai });
|
||||
}
|
||||
|
||||
// Short per-user bucket so the bypassed nightly budget can't be abused
|
||||
// into a provider-cost amplifier by a tight POST loop. A blocked call
|
||||
// is harmless — the caches are already being warmed by the prior call.
|
||||
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
|
||||
if (!rl.allowed) {
|
||||
return apiError("A warm is already in progress. Try again shortly.", 429);
|
||||
}
|
||||
|
||||
await enqueueForceWarm({ userId, locale });
|
||||
|
||||
annotate({
|
||||
@@ -74,5 +92,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// The work runs on the worker; report that it was accepted, not that it
|
||||
// is done. The client polls the read-only status GETs for the text.
|
||||
return apiSuccess({ queued: true, locale });
|
||||
return apiSuccess({ queued: true, locale, ai });
|
||||
});
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.pulse-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.pulse-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -8,7 +8,9 @@
|
||||
* all history.
|
||||
*
|
||||
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
|
||||
* resolved session — never a body field. Gated on the `insights` module. The
|
||||
* resolved session — never a body field. Pulse is a core vital, so no module
|
||||
* gates it; the `insights` module is the AI analysis opt-out, and this read
|
||||
* calls no model. The
|
||||
* `date` defaults to the user's local today; anything but a `YYYY-MM-DD`
|
||||
* literal is a 422 via `returnAllZodIssues`. The frontend day navigator
|
||||
* pages this same route backward through prior days — a day outside
|
||||
@@ -20,7 +22,6 @@ import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
@@ -40,8 +41,6 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
|
||||
@@ -21,13 +21,12 @@
|
||||
*
|
||||
* Follows the `metric-status` / `derived` route precedent: `apiHandler`
|
||||
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session
|
||||
* (never a query field), the `insightStatus` assistant-surface gate (no AI
|
||||
* provider call — this is a pure DB read).
|
||||
* (never a query field). No AI gate and no module gate: this is a pure DB
|
||||
* read of device data.
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -46,9 +45,9 @@ export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// A pure read of the device's own verdicts, so no assistant-surface gate.
|
||||
// A pure read of the device's own verdicts: no AI gate and no module gate.
|
||||
// The `insights` module is the AI analysis opt-out, and nothing here was
|
||||
// written by a model.
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
import { apiHandler, requireRecordAuth } 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";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A mixed read: the card's frame is data, the note inside it is model text.
|
||||
* The note is served, and warmed on a miss, only while the `statusText` AI
|
||||
* capability is available. Otherwise the route answers 200 with no note
|
||||
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
|
||||
* an `ai` state saying why, without reading the cache or queueing anything.
|
||||
* The `insights` module is the AI analysis opt-out and is folded into the
|
||||
* capability, so it no longer refuses the route.
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const ai = await aiCapabilityToServe(user.id, "statusText");
|
||||
if (!ai.available) {
|
||||
annotate({
|
||||
action: { name: "insights.weight-status.unavailable" },
|
||||
meta: { reason: ai.reason },
|
||||
});
|
||||
return apiSuccess(await unavailableStatusBody(user.id, ai));
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "insights.weight-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, ai });
|
||||
});
|
||||
@@ -505,15 +505,15 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("never offers when the operator switched the assistant surfaces off", async () => {
|
||||
it("never offers when the operator switched off everything the consent would unlock", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
briefing: false,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
documentAi: false,
|
||||
});
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
@@ -521,6 +521,41 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["coach", "briefing", "documentAi"] as const)(
|
||||
"still offers while the operator keeps %s on",
|
||||
async (open) => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
[open]: true,
|
||||
});
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderOffer: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports no AI available while the operator's master switch is off, whatever is configured", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
});
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
aiAvailable: false,
|
||||
managedBy: "server",
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers to a managed profile — a child's record does not consent for itself", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
|
||||
@@ -53,7 +53,17 @@ export const GET = apiHandler(async () => {
|
||||
// no personal provider. iOS keys its Coach visibility off `aiAvailable` so
|
||||
// a server-managed provider is no longer invisible to the client.
|
||||
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
|
||||
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
|
||||
//
|
||||
// The operator's master switch is part of the answer: with every AI
|
||||
// capability switched off there is no AI available, whatever is
|
||||
// configured, so a native client that keys on this field hides its AI
|
||||
// surfaces. The switches fail closed (a read error reads as all off).
|
||||
const [presence, assistantFlags] = await Promise.all([
|
||||
resolveProviderAvailability(user.id),
|
||||
getAssistantFlags(),
|
||||
]);
|
||||
const aiAvailable = presence.aiAvailable && assistantFlags.enabled;
|
||||
const { managedBy } = presence;
|
||||
|
||||
// ── v1.38.19 — the shared provider, honestly ────────────────
|
||||
// `managedBy: "server"` says the operator configured a key. It has never
|
||||
@@ -77,7 +87,6 @@ export const GET = apiHandler(async () => {
|
||||
user.id,
|
||||
"coach",
|
||||
);
|
||||
const assistantFlags = await getAssistantFlags();
|
||||
// `personal`, not merely "not deny": a guardian's own record resolves to
|
||||
// `personal`, while a managed profile resolves to `operator-default` —
|
||||
// its provider is the operator's by definition, and a child's record does
|
||||
@@ -93,13 +102,20 @@ export const GET = apiHandler(async () => {
|
||||
// one visitor minted would turn the operator's provider on for every later
|
||||
// one. A button that can only 403 into a generic toast is precisely the
|
||||
// misleading failure this surface exists to remove, so there is no button.
|
||||
//
|
||||
// The offer is worth making while any capability it would unlock is
|
||||
// switched on by the operator: the Coach, the briefing, or reading
|
||||
// documents. With all three off there is nothing for the consent to open.
|
||||
const demoInstance = process.env.DEMO_MODE === "true";
|
||||
const offerUnlocksSomething =
|
||||
assistantFlags.coach ||
|
||||
assistantFlags.briefing ||
|
||||
assistantFlags.documentAi;
|
||||
const serverProviderOffer =
|
||||
!demoInstance &&
|
||||
managedBy === "server" &&
|
||||
serverProviderHealth === "healthy" &&
|
||||
assistantFlags.enabled &&
|
||||
assistantFlags.coach &&
|
||||
offerUnlocksSomething &&
|
||||
credentialPolicy === "personal" &&
|
||||
!serverProviderConsent;
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
@@ -60,7 +61,11 @@ export const GET = apiHandler(
|
||||
// v1.18.0 B1 — gate the detail surface behind the workouts module.
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const insightsEnabled = await isModuleEnabled(user.id, "insights");
|
||||
// The stored Activity Insight paragraph is model text: it is read and
|
||||
// served only while `workoutInsights` is available (operator switch, the
|
||||
// AI analysis opt-out, provider presence, consent). Otherwise the row is
|
||||
// not even loaded, `aiInsight` is null, and `ai` says why.
|
||||
const ai = await aiCapabilityToServe(user.id, "workoutInsights");
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
@@ -79,7 +84,7 @@ export const GET = apiHandler(
|
||||
sampleCount: true,
|
||||
},
|
||||
},
|
||||
insight: insightsEnabled
|
||||
insight: ai.available
|
||||
? {
|
||||
select: { paragraphEncrypted: true, generatedAt: true },
|
||||
}
|
||||
@@ -282,7 +287,7 @@ export const GET = apiHandler(
|
||||
// undecryptable paragraph degrades to no card, exactly like a workout that
|
||||
// never had one.
|
||||
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
|
||||
if (insightsEnabled && row.insight) {
|
||||
if (ai.available && row.insight) {
|
||||
try {
|
||||
aiInsight = {
|
||||
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
|
||||
@@ -331,6 +336,8 @@ export const GET = apiHandler(
|
||||
// re-synced one, every one on a provider-less install) serves null and
|
||||
// the page's `{aiInsight ? <card/> : null}` renders nothing.
|
||||
aiInsight,
|
||||
// Whether the Activity Insight can be shown, and why not.
|
||||
ai,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
|
||||
@@ -60,14 +60,21 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () => ({})),
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
};
|
||||
});
|
||||
|
||||
// The Activity Insight paragraph follows the `workoutInsights` capability.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
|
||||
|
||||
import { GET } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -139,7 +146,7 @@ describe("GET /api/workouts/{id}", () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
} as never);
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
// The timezone resolver caches per user for 60 s in process; drop the
|
||||
// entry so a test that pins a zone is not served the previous one.
|
||||
invalidateUserTimezone(SESSION_OK.user.id);
|
||||
@@ -565,6 +572,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
|
||||
_resetCryptoCacheForTests();
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
@@ -616,27 +624,42 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
|
||||
paragraph: "A steady, aerobic-leaning run.",
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not read or expose the stored paragraph when Insights is disabled", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
insight: {
|
||||
paragraphEncrypted: new Uint8Array([1, 2, 3]),
|
||||
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.aiInsight).toBeNull();
|
||||
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: expect.objectContaining({ insight: false }),
|
||||
}),
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"workoutInsights",
|
||||
);
|
||||
expect(body.data.ai).toEqual(AI_AVAILABLE);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"user_disabled",
|
||||
"module_disabled",
|
||||
"consent_required",
|
||||
"no_provider",
|
||||
] as const)(
|
||||
"does not read or expose the stored paragraph while workoutInsights is %s",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
insight: {
|
||||
paragraphEncrypted: new Uint8Array([1, 2, 3]),
|
||||
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.aiInsight).toBeNull();
|
||||
expect(body.data.ai).toEqual(aiUnavailable(reason));
|
||||
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: expect.objectContaining({ insight: false }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
|
||||
// `decrypt` is fail-closed by design. A key rotated away must not turn the
|
||||
// whole workout-detail page into a 500 over a garnish field.
|
||||
|
||||
@@ -10,20 +10,25 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* The RSC wrapper (`src/app/coach/page.tsx`) dehydrates the coach nudge status
|
||||
* under `queryKeys.coachNudgeStatus()` so the auto-open-most-recent decision is
|
||||
* available at hydrate (collapsing the nudge → auto-open waterfall). These
|
||||
* tests pin: the exact client key; the read is only run when the Coach surface
|
||||
* is reachable (operator flag ON + not user-disabled); and every unreachable /
|
||||
* tests pin: the exact client key; the read is only run when the `coach`
|
||||
* capability is available for the caller's own record; and every unavailable /
|
||||
* error path fails soft. The streaming conversation is never prefetched here.
|
||||
*/
|
||||
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const requireAssistantSurface = vi.fn();
|
||||
const loadAiCapabilityInputs = vi.fn();
|
||||
const resolveAiCapability = vi.fn();
|
||||
const readCoachNudgeStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: (s: string) => requireAssistantSurface(s),
|
||||
vi.mock("@/lib/ai/capabilities/load", () => ({
|
||||
loadAiCapabilityInputs: (scope: unknown) => loadAiCapabilityInputs(scope),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/resolve", () => ({
|
||||
resolveAiCapability: (key: string, inputs: unknown) =>
|
||||
resolveAiCapability(key, inputs),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/nudge-status", () => ({
|
||||
readCoachNudgeStatus: (id: string) => readCoachNudgeStatus(id),
|
||||
@@ -38,6 +43,18 @@ const NUDGE = {
|
||||
conversationId: "c1",
|
||||
};
|
||||
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
const USER_DISABLED = {
|
||||
available: false,
|
||||
reason: "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
};
|
||||
const OPERATOR_DISABLED = {
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
@@ -59,31 +76,65 @@ describe("/coach RSC prefetch", () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
resolveAiCapability.mockReturnValue(AVAILABLE);
|
||||
readCoachNudgeStatus.mockResolvedValue(NUDGE);
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
expect(q).not.toBeNull();
|
||||
expect(q!.queryHash).toBe(hashKey(queryKeys.coachNudgeStatus()));
|
||||
expect(q!.state.data).toEqual(NUDGE);
|
||||
// The seeded value equals the route's wire shape, `ai` included.
|
||||
expect(q!.state.data).toEqual({ ...NUDGE, ai: AVAILABLE });
|
||||
});
|
||||
|
||||
it("resolves the Coach for the caller's own record with the owner's authority", async () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
resolveAiCapability.mockReturnValue(AVAILABLE);
|
||||
readCoachNudgeStatus.mockResolvedValue(NUDGE);
|
||||
|
||||
await CoachPage();
|
||||
expect(loadAiCapabilityInputs).toHaveBeenCalledWith({
|
||||
recordId: "u1",
|
||||
authority: {
|
||||
origin: "owner",
|
||||
recordUserId: "u1",
|
||||
actorUserId: "u1",
|
||||
grantId: null,
|
||||
},
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveAiCapability.mock.calls[0]?.[0]).toBe("coach");
|
||||
});
|
||||
|
||||
it("skips the prefetch when the user opted out of the Coach", async () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: true },
|
||||
});
|
||||
resolveAiCapability.mockReturnValue(USER_DISABLED);
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when the operator Coach flag is off (surface throws)", async () => {
|
||||
it("skips the prefetch when the operator turned the Coach off", async () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockRejectedValue(new Error("assistant disabled"));
|
||||
resolveAiCapability.mockReturnValue(OPERATOR_DISABLED);
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when the capability load throws", async () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
loadAiCapabilityInputs.mockRejectedValueOnce(new Error("db blip"));
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
@@ -94,7 +145,7 @@ describe("/coach RSC prefetch", () => {
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
resolveAiCapability.mockReturnValue(AVAILABLE);
|
||||
readCoachNudgeStatus.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
|
||||
+38
-11
@@ -5,8 +5,10 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
import { loadAiCapabilityInputs } from "@/lib/ai/capabilities/load";
|
||||
import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import CoachPageClient from "./page-client";
|
||||
@@ -35,11 +37,12 @@ import CoachPageClient from "./page-client";
|
||||
* immediate refetch. When the client query is disabled (a `?c=`/`?doc=`/
|
||||
* `?scope=` deep-link makes it `enabled: false`) the seeded value simply
|
||||
* sits unused — harmless.
|
||||
* - Availability parity: prefetch only when the Coach surface is actually
|
||||
* reachable — the operator `coach` assistant flag is ON
|
||||
* (`requireAssistantSurface` throws otherwise) AND the user has not opted
|
||||
* out (`disableCoach`). Mirrors the client's `!flags.coach || disableCoach`
|
||||
* redirect gate, so a disabled account does no wasted work.
|
||||
* - Availability parity: prefetch only when the `coach` capability is
|
||||
* available for the caller's own record, resolved from every layer (the
|
||||
* operator switch, the `disableCoach` opt-out, provider presence and
|
||||
* consent) by the same resolver the API routes use. An unavailable Coach
|
||||
* does no wasted work. The seeded value carries the same `ai` state the
|
||||
* nudge-status route returns, so the hydrated shape equals the wire.
|
||||
* - Left CLIENT-LAZY on purpose: the streaming conversation itself (the SSE
|
||||
* thread, snapshot, any provider-touching path) is NOT prefetched or warmed
|
||||
* — it depends on URL params + the nudge outcome and must never be triggered
|
||||
@@ -52,6 +55,28 @@ import CoachPageClient from "./page-client";
|
||||
* - Fail-soft: no session, the Coach being off, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cell owns the fetch.
|
||||
*/
|
||||
/**
|
||||
* The `coach` capability for the caller's own record. An RSC has no request
|
||||
* event, so the route gate cannot resolve the record from it; the unswitched
|
||||
* session is always the caller's own record, read with the owner's authority.
|
||||
*/
|
||||
async function coachCapabilityForOwnRecord(
|
||||
userId: string,
|
||||
): Promise<AiCapabilityState> {
|
||||
const inputs = await loadAiCapabilityInputs({
|
||||
recordId: userId,
|
||||
authority: {
|
||||
origin: "owner",
|
||||
recordUserId: userId,
|
||||
actorUserId: userId,
|
||||
grantId: null,
|
||||
},
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
return resolveAiCapability("coach", inputs);
|
||||
}
|
||||
|
||||
export default async function CoachPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
@@ -63,15 +88,17 @@ export default async function CoachPage() {
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getUnswitchedSession();
|
||||
if (session && !session.user.disableCoach) {
|
||||
// Throws `AssistantDisabledError` when the operator flag is off → caught
|
||||
// below, prefetch skipped, client path stands (and redirects to /insights).
|
||||
await requireAssistantSurface("coach");
|
||||
const ai = session
|
||||
? await coachCapabilityForOwnRecord(session.user.id)
|
||||
: null;
|
||||
// An unavailable Coach skips the prefetch; the client path stands (and
|
||||
// redirects to /insights).
|
||||
if (session && ai?.available) {
|
||||
const nudge = await readCoachNudgeStatus(session.user.id);
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.coachNudgeStatus(),
|
||||
JSON.parse(JSON.stringify(nudge)),
|
||||
JSON.parse(JSON.stringify({ ...nudge, ai })),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
function render(
|
||||
access: AccountAccess,
|
||||
@@ -344,6 +345,7 @@ describe("the medication card menu", () => {
|
||||
|
||||
const DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-08-03T06:00:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TodayHero } from "../today-hero";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient();
|
||||
@@ -35,6 +36,7 @@ const ARRIVED_AT = "2026-07-16T05:41:00.000Z";
|
||||
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 3 },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TodayHero } from "../today-hero";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
// The hero now wires the coach check-in card's keep / let-go taps through
|
||||
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
|
||||
@@ -51,6 +52,7 @@ const syncItem: PriorityItem = {
|
||||
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: {
|
||||
|
||||
@@ -185,3 +185,42 @@ export async function aiCapabilityForRecord(
|
||||
? getAiCapability(key, { recordId })
|
||||
: aiCapabilityForJob(recordId, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether text a model already wrote for a record may be SHOWN, answered from
|
||||
* the record's own state, whoever is reading.
|
||||
*
|
||||
* Serving stored text and starting model work are different questions. A
|
||||
* delegate inside somebody else's record may not start model work there
|
||||
* (`not_permitted_for_record`: no generation, no warm, no chat on the owner's
|
||||
* key), but reads the owner's stored briefing, narrative, status notes and
|
||||
* workout notes exactly when the owner would. So this resolves the record's
|
||||
* operator switches, its modules and AI opt-outs, its provider and its
|
||||
* consent, under the record's own authority (`system`, as a job would, which
|
||||
* also answers a managed profile correctly), unmasked by the viewer's grant.
|
||||
*
|
||||
* Use it only to decide whether stored model text is served. Anything that
|
||||
* generates, warms or enqueues asks `getAiCapability` / `requireAiCapability`
|
||||
* (the viewer's view) instead. Never throws.
|
||||
*/
|
||||
export async function aiCapabilityToServe(
|
||||
recordId: string,
|
||||
key: AiCapabilityKey,
|
||||
): Promise<AiCapabilityState> {
|
||||
try {
|
||||
const inputs = await loadAiCapabilityInputs({
|
||||
recordId,
|
||||
authority: {
|
||||
origin: "system",
|
||||
recordUserId: recordId,
|
||||
actorUserId: null,
|
||||
grantId: null,
|
||||
},
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
return resolveAiCapability(key, inputs);
|
||||
} catch {
|
||||
return resolveAiCapability(key, null);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { deriveClarifyingQuestions } from "../self-context-questions";
|
||||
import type { SelfContext } from "../about-me";
|
||||
import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
const ctx: SelfContext = {
|
||||
aboutMe: "Shift work, half-marathon training.",
|
||||
@@ -104,6 +105,7 @@ describe("deriveClarifyingQuestions — included sections", () => {
|
||||
{ ...ctx, aboutMe: null },
|
||||
"en",
|
||||
["ABOUT_ME"],
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.questions).toEqual([
|
||||
@@ -114,9 +116,13 @@ describe("deriveClarifyingQuestions — included sections", () => {
|
||||
});
|
||||
|
||||
it("does no question work when no question-bearing field is included", async () => {
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en", [
|
||||
"FAMILY_HISTORY",
|
||||
]);
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
["FAMILY_HISTORY"],
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.questions).toEqual([]);
|
||||
expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled();
|
||||
@@ -124,13 +130,43 @@ describe("deriveClarifyingQuestions — included sections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveClarifyingQuestions — capability unavailable", () => {
|
||||
it("serves the deterministic hints and never touches a provider, budget or snapshot", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
{ ...ctx, aboutMe: null },
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: false },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(out.questions.length).toBeGreaterThan(0);
|
||||
expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled();
|
||||
expect(providerMocks.resolveProviderChain).not.toHaveBeenCalled();
|
||||
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
|
||||
expect(buildCoachSnapshot).not.toHaveBeenCalled();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
it("refuses the AI path on an operator-managed chain with no receipt", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
// The snapshot is the PHI: it must never even be built, let alone sent.
|
||||
@@ -145,7 +181,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
{ providerType: "admin-codex", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
@@ -157,7 +199,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([]);
|
||||
providerMocks.resolveProvider.mockResolvedValue(provider);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
@@ -171,7 +219,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
kind === "ai_coach" ? { id: "receipt-1" } : null,
|
||||
);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(out.questions).toEqual(["Question one?", "Question two?"]);
|
||||
@@ -186,7 +240,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
kind === "ai_full" ? { id: "receipt-2" } : null,
|
||||
);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
@@ -197,7 +257,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
@@ -209,7 +275,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
{ providerType: "local", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
@@ -223,7 +295,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
@@ -238,7 +316,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
});
|
||||
|
||||
it("reserves before the call and reconciles the actual spend after", async () => {
|
||||
await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(budgetMocks.reserveBudget).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
@@ -267,7 +351,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
operatorAfter: 200_000,
|
||||
});
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
@@ -276,7 +366,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
it("reconciles the reservation to zero when the provider throws", async () => {
|
||||
provider.generateCompletion.mockRejectedValue(new Error("upstream down"));
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
|
||||
|
||||
@@ -181,17 +181,23 @@ async function resolveQuestionsChain(
|
||||
* Derive the pending questions for a freshly saved self-context.
|
||||
* Never throws — every failure path lands on the deterministic
|
||||
* fallback so the PUT route stays robust.
|
||||
*
|
||||
* `aiAvailable` is the caller's resolved `aboutMeQuestions` capability. When
|
||||
* it is false the deterministic fallback is the whole answer: no provider is
|
||||
* resolved, no budget reserved, nothing leaves the server.
|
||||
*/
|
||||
export async function deriveClarifyingQuestions(
|
||||
userId: string,
|
||||
ctx: SelfContext,
|
||||
locale: string | null | undefined,
|
||||
includedSections: readonly HealthProfileAiSection[] = DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
includedSections: readonly HealthProfileAiSection[],
|
||||
options: { aiAvailable: boolean },
|
||||
): Promise<{ questions: string[]; source: "ai" | "fallback" }> {
|
||||
const fallback = () => ({
|
||||
questions: buildFallbackQuestions(ctx, locale, includedSections),
|
||||
source: "fallback" as const,
|
||||
});
|
||||
if (!options.aiAvailable) return fallback();
|
||||
const included = new Set(includedSections);
|
||||
// A free-form model reply cannot be proven not to cross into an omitted
|
||||
// field. Partial inclusion therefore stays on the closed deterministic
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
|
||||
import type { CoachSuggestedAction } from "./suggest-action";
|
||||
|
||||
/**
|
||||
@@ -227,7 +228,17 @@ export type CoachStreamEvent =
|
||||
messageId: string;
|
||||
usage?: CoachUsage;
|
||||
}
|
||||
| { type: "error"; code: string; message: string };
|
||||
| {
|
||||
type: "error";
|
||||
code: string;
|
||||
message: string;
|
||||
/**
|
||||
* The AI capability reason, when the refusal came from one (today only
|
||||
* `no_provider` on `coach.provider.none`). Additive: older clients
|
||||
* ignore it.
|
||||
*/
|
||||
reason?: AiUnavailableReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.22 — Zod schema for one entry inside the Coach's evidence
|
||||
|
||||
@@ -52,7 +52,7 @@ const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
|
||||
|
||||
/**
|
||||
* Error thrown when an external-LLM egress on a server-managed key is
|
||||
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
|
||||
* attempted without an active consent receipt. Mirrors the AI capability refusal
|
||||
* so the api-handler renders the same 403 envelope shape the iOS client
|
||||
* already branches on:
|
||||
*
|
||||
|
||||
+2
-20
@@ -26,7 +26,6 @@ import {
|
||||
selectorNamesAnAccount,
|
||||
type ActingCarrier,
|
||||
} from "./auth/acting-carrier";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { AiUnavailableError } from "./ai/capabilities/refusal";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
@@ -246,27 +245,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
{ data: null, error: error.message, meta: error.meta },
|
||||
{ status: error.status },
|
||||
);
|
||||
} else if (error instanceof AssistantDisabledError) {
|
||||
// v1.4.31 — operator has disabled the assistant surface.
|
||||
// The 403 + `errorCode: "assistant.disabled.<surface>"`
|
||||
// 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 can branch on the
|
||||
// errorCode to render an inline operator-disabled notice.
|
||||
evt.setError(error);
|
||||
response = NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: error.message,
|
||||
meta: { errorCode: error.errorCode },
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
} else if (error instanceof ConsentRequiredError) {
|
||||
// v1.12.1 — server-side consent gate before external-LLM PHI
|
||||
// egress on the operator's server-managed key. Mirrors the
|
||||
// AssistantDisabledError envelope (403 + meta.errorCode) so the
|
||||
// egress on the operator's server-managed key. 403 +
|
||||
// meta.errorCode, like the capability refusal above, so the
|
||||
// iOS client renders an inline "grant consent" notice instead of
|
||||
// a generic failure.
|
||||
evt.setError(error);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isUrgentPayload } from "@/lib/notifications/types";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the
|
||||
// fixed fallback hour. The tz-window cases move only this instant.
|
||||
@@ -31,6 +32,7 @@ const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin
|
||||
function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "good", delta: 1 },
|
||||
@@ -80,7 +82,6 @@ function makeDeps(
|
||||
return {
|
||||
dispatch,
|
||||
loadDigest: async () => makeDigest(),
|
||||
isModuleEnabled: async () => true,
|
||||
...over,
|
||||
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
@@ -251,17 +252,27 @@ describe("maybeDispatchDailyBriefing", () => {
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("insights module off → no dispatch", async () => {
|
||||
it("AI analysis off: the push still goes out with the deterministic line", async () => {
|
||||
// The digest the loader returns when the briefing capability is
|
||||
// unavailable: no lead, no top signal, the score floor as the line.
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps({ isModuleEnabled: async () => false });
|
||||
const deps = makeDeps({
|
||||
loadDigest: async () =>
|
||||
makeDigest({
|
||||
briefingLead: null,
|
||||
topSignal: null,
|
||||
line: "Your health score today is 82.",
|
||||
}),
|
||||
});
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("module-off");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
expect(result).toBe("sent");
|
||||
const payload = JSON.stringify(deps.dispatch.mock.calls[0]);
|
||||
expect(payload).toContain("Your health score today is 82.");
|
||||
});
|
||||
|
||||
it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type DailyDigestInput,
|
||||
} from "@/lib/daily/digest";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
const NOW = new Date("2026-07-16T09:00:00.000Z");
|
||||
@@ -38,6 +39,7 @@ function meds(): MedsTodayBlock {
|
||||
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
return {
|
||||
now: NOW,
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"),
|
||||
modules: {},
|
||||
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
sameTimeBaselineItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
import {
|
||||
aiUnavailable,
|
||||
DIGEST_AI_AVAILABLE,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
const NOW = new Date("2026-07-16T09:00:00.000Z");
|
||||
@@ -64,6 +68,7 @@ const briefing: DailyBriefing = {
|
||||
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
return {
|
||||
now: NOW,
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
// NOW is 09:00Z on 2026-07-16; UTC profile day ends at next midnight.
|
||||
todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"),
|
||||
modules: {},
|
||||
@@ -766,13 +771,25 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT emit a check-in when the coach module is off", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ modules: { coach: false }, coachPlans: [plan()] }),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"module_disabled",
|
||||
"user_disabled",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const)(
|
||||
"does NOT emit a check-in while the coach capability is %s",
|
||||
(reason) => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
ai: { ...DIGEST_AI_AVAILABLE, coach: aiUnavailable(reason) },
|
||||
coachPlans: [plan()],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("emits none when there are no standing plans", () => {
|
||||
const d = buildDailyDigest(input({ coachPlans: [] }), t);
|
||||
@@ -862,12 +879,13 @@ describe("S12 — the milestone reward card", () => {
|
||||
expect(milestone(buildDailyDigest(input(), t))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is suppressed when the insights module is off (module-gated)", () => {
|
||||
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ milestone: RECORD_MILESTONE, modules: { insights: false } }),
|
||||
t,
|
||||
);
|
||||
expect(milestone(d)).toBeUndefined();
|
||||
expect(milestone(d)).toBeDefined();
|
||||
expect(milestone(d)?.moduleKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sits just below an overdue dose and above ambient items", () => {
|
||||
@@ -936,7 +954,7 @@ describe("buildDailyDigest — S11 tension_window item", () => {
|
||||
expect(tension(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when the insights module is off", () => {
|
||||
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
modules: { insights: false },
|
||||
@@ -944,7 +962,7 @@ describe("buildDailyDigest — S11 tension_window item", () => {
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(tension(d)).toBeUndefined();
|
||||
expect(tension(d)).toBeDefined();
|
||||
});
|
||||
|
||||
it("yields the bounded rail to time-sensitive actions first", () => {
|
||||
@@ -1042,12 +1060,12 @@ describe("buildDailyDigest — same_time_baseline item", () => {
|
||||
expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when the insights module is off", () => {
|
||||
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ modules: { insights: false }, sameTime: sameTime() }),
|
||||
t,
|
||||
);
|
||||
expect(sameTimeItem(d)).toBeUndefined();
|
||||
expect(sameTimeItem(d)).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses a different sentence when today is ahead", () => {
|
||||
@@ -1131,7 +1149,8 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
|
||||
const item = ecgItem(d);
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.status).toBe("info");
|
||||
expect(item?.moduleKey).toBe("insights");
|
||||
// Device data: no module owns it.
|
||||
expect(item?.moduleKey).toBeUndefined();
|
||||
// Single action, deep-linking the ECG viewer.
|
||||
expect(item?.actions).toHaveLength(1);
|
||||
expect(item?.actions[0].intent).toBe("ecg.view");
|
||||
@@ -1182,7 +1201,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
|
||||
expect(ecgItem(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit when the insights module is off", () => {
|
||||
it("is device data: the AI analysis opt-out (insights module off) does not hide it", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
modules: { insights: false },
|
||||
@@ -1193,7 +1212,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(ecgItem(d)).toBeUndefined();
|
||||
expect(ecgItem(d)).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits nothing when there is no recent recording", () => {
|
||||
@@ -1274,3 +1293,27 @@ describe("buildDailyDigest — dismiss filtering never touches actionable kinds"
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — AI text follows its capability", () => {
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"user_disabled",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const)(
|
||||
"drops the briefing lead and top signal while briefing is %s",
|
||||
(reason) => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
ai: { ...DIGEST_AI_AVAILABLE, briefing: aiUnavailable(reason) },
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.briefingLead).toBeNull();
|
||||
expect(d.topSignal).toBeNull();
|
||||
// The push line falls to the deterministic floor, never to model text.
|
||||
expect(d.line).toBe("Your health score today is 82.");
|
||||
expect(d.ai.briefing.reason).toBe(reason);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -44,6 +44,15 @@ vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
|
||||
loadIntradayPulse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "a reaction line"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-translator", () => ({
|
||||
getServerTranslator: vi.fn().mockReturnValue({ t: (key: string) => key }),
|
||||
}));
|
||||
@@ -57,6 +66,16 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
aiCapabilityForRecord,
|
||||
aiCapabilityToServe,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const SNAPSHOT = {
|
||||
body: {
|
||||
@@ -75,6 +94,7 @@ const SNAPSHOT = {
|
||||
briefingState: "ready",
|
||||
briefingUpdatedAt: null,
|
||||
briefingStale: false,
|
||||
briefingAi: AI_AVAILABLE,
|
||||
},
|
||||
locale: "en",
|
||||
};
|
||||
@@ -94,6 +114,8 @@ beforeEach(() => {
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
|
||||
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
|
||||
vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never);
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — S11/S12 extras cache", () => {
|
||||
@@ -145,13 +167,13 @@ describe("loadDailyDigest — S11/S12 extras cache", () => {
|
||||
expect(loadIntradayPulse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never gathers the extras when the insights module is disabled", async () => {
|
||||
it("gathers the extras even with the insights module (the AI analysis opt-out) off", async () => {
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({ insights: false } as never);
|
||||
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(probeRollupCoverage).not.toHaveBeenCalled();
|
||||
expect(loadIntradayPulse).not.toHaveBeenCalled();
|
||||
expect(probeRollupCoverage).toHaveBeenCalled();
|
||||
expect(loadIntradayPulse).toHaveBeenCalled();
|
||||
});
|
||||
it("threads the resolved hero-item visibility into composition", async () => {
|
||||
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
|
||||
@@ -191,3 +213,91 @@ describe("loadDailyDigest — S11/S12 extras cache", () => {
|
||||
expect(digest.worthALook.map((item) => item.kind)).toContain("dose_window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — AI parts", () => {
|
||||
const ARRIVAL = {
|
||||
kind: "sleep_night",
|
||||
occurredAt: new Date("2026-07-17T06:00:00.000Z"),
|
||||
arrivedAt: new Date("2026-07-17T08:55:00.000Z"),
|
||||
lineEncrypted: new Uint8Array([1, 2, 3]),
|
||||
generatedAt: new Date("2026-07-17T08:56:00.000Z"),
|
||||
};
|
||||
|
||||
it("resolves the coach and reaction-line capabilities for the record and publishes them", async () => {
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(aiCapabilityForRecord).toHaveBeenCalledWith("user-1", "coach");
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "reactionLines");
|
||||
expect(digest.ai).toEqual({
|
||||
briefing: AI_AVAILABLE,
|
||||
coach: AI_AVAILABLE,
|
||||
reactionLines: AI_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves the stored reaction line while the capability is available", async () => {
|
||||
vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([
|
||||
ARRIVAL,
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.reactionLine).toBe("a reaction line");
|
||||
});
|
||||
|
||||
it("neither decrypts nor serves a stored reaction line while the capability is unavailable", async () => {
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(
|
||||
aiUnavailable("user_disabled"),
|
||||
);
|
||||
vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([
|
||||
ARRIVAL,
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.reactionLine).toBeNull();
|
||||
expect(decryptFromBytes).not.toHaveBeenCalled();
|
||||
// The marker itself is data and still drives the "just in" chip.
|
||||
expect(digest.justIn?.kind).toBe("sleep_night");
|
||||
});
|
||||
|
||||
it("does not decrypt plan prose while the coach capability is unavailable", async () => {
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue(
|
||||
aiUnavailable("operator_disabled"),
|
||||
);
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "p1",
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
ifCueEncrypted: new Uint8Array([1]),
|
||||
thenActionEncrypted: new Uint8Array([2]),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(decryptFromBytes).not.toHaveBeenCalled();
|
||||
expect(digest.worthALook.some((i) => i.kind === "coach_checkin")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on the briefing when a cached body predates the published state", async () => {
|
||||
const { briefingAi: _dropped, ...older } = SNAPSHOT.body;
|
||||
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
|
||||
...SNAPSHOT,
|
||||
body: older,
|
||||
} as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.ai.briefing).toEqual({
|
||||
available: false,
|
||||
reason: "check_failed",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
@@ -72,7 +71,6 @@ export type DailyBriefingDispatchResult =
|
||||
| "suppressed-frequency"
|
||||
| "no-digest"
|
||||
| "opted-out"
|
||||
| "module-off"
|
||||
| "outside-window"
|
||||
| "no-channel"
|
||||
| "missing-user"
|
||||
@@ -81,7 +79,6 @@ export type DailyBriefingDispatchResult =
|
||||
export interface DailyBriefingDispatchDeps {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
|
||||
isModuleEnabled?: typeof isModuleEnabled;
|
||||
}
|
||||
|
||||
function loadDailyBriefingDigest(
|
||||
@@ -137,10 +134,15 @@ export function buildDailyBriefingPush(
|
||||
* 1. user missing → `missing-user`
|
||||
* 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref)
|
||||
* 3. outside morning window → `outside-window`
|
||||
* 4. insights module off → `module-off`
|
||||
* 5. already pushed today → `suppressed-frequency` (push_attempts ledger)
|
||||
* 6. digest not substantive → `no-digest`
|
||||
* 7. dispatch; no channel → `no-channel`; else `sent`.
|
||||
* 4. already pushed today → `suppressed-frequency` (push_attempts ledger)
|
||||
* 5. digest not substantive → `no-digest`
|
||||
* 6. dispatch; no channel → `no-channel`; else `sent`.
|
||||
*
|
||||
* No module gate. The `insights` module is the AI analysis opt-out: with it
|
||||
* off (or AI unavailable for any other reason) the digest carries no briefing
|
||||
* lead or top signal, and the push body falls to the digest's deterministic
|
||||
* line (the score, or the all-clear). A digest with nothing deterministic to
|
||||
* say is skipped as `no-digest`, like any other.
|
||||
*/
|
||||
export async function maybeDispatchDailyBriefing(
|
||||
prisma: PrismaClient,
|
||||
@@ -150,7 +152,6 @@ export async function maybeDispatchDailyBriefing(
|
||||
): Promise<DailyBriefingDispatchResult> {
|
||||
const dispatch = deps.dispatch ?? dispatchNotification;
|
||||
const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest;
|
||||
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
@@ -187,13 +188,6 @@ export async function maybeDispatchDailyBriefing(
|
||||
return "outside-window";
|
||||
}
|
||||
|
||||
// Insights is the digest's home module; a user who turned it off gets no
|
||||
// digest surfaces at all, so no morning push either.
|
||||
if (!(await moduleGate(userId, "insights"))) {
|
||||
annotate({ action: { name: "daily.briefing_push.module_off" } });
|
||||
return "module-off";
|
||||
}
|
||||
|
||||
// One per user per LOCAL day. Anchored on the `push_attempts` ledger the
|
||||
// senders already write (`ok` on a successful delivery), exactly like the
|
||||
// coach-nudge cap: whichever trigger fired first this morning left an `ok`
|
||||
|
||||
+41
-21
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import type { ArrivalKind } from "@/lib/arrivals/types";
|
||||
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
@@ -254,8 +255,22 @@ export interface DailyDigestArrival {
|
||||
export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
/** The fully-resolved, IO-free input the composer folds into a digest. */
|
||||
/**
|
||||
* The three AI capabilities the digest carries text or a card for, resolved
|
||||
* for this record by the IO seam. The builder serves the briefing lead and
|
||||
* top signal only while `briefing` is available, the reaction line only while
|
||||
* `reactionLines` is, and the Coach check-in only while `coach` is; the rest
|
||||
* of the digest is data and never depends on them.
|
||||
*/
|
||||
export interface DailyDigestAi {
|
||||
briefing: AiCapabilityState;
|
||||
coach: AiCapabilityState;
|
||||
reactionLines: AiCapabilityState;
|
||||
}
|
||||
|
||||
export interface DailyDigestInput {
|
||||
now: Date;
|
||||
ai: DailyDigestAi;
|
||||
/**
|
||||
* First instant of the user's NEXT local day (profile tz), resolved by the
|
||||
* IO seam via `getUserTodayBounds`. Bounds the dose-window rail to TODAY'S
|
||||
@@ -374,6 +389,8 @@ export interface DailyDigest {
|
||||
* sentence is garnish; its absence degrades nothing.
|
||||
*/
|
||||
reactionLine: string | null;
|
||||
/** Why the briefing lead, the reaction line or the check-in are absent. */
|
||||
ai: DailyDigestAi;
|
||||
}
|
||||
|
||||
/** First sentence of a paragraph, trimmed; null when empty. */
|
||||
@@ -598,8 +615,8 @@ function buildUpcomingVisitItem(
|
||||
/**
|
||||
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
|
||||
* state was reached TODAY (the IO seam already applied the reached-once gate).
|
||||
* Gated on the `insights` module — the daily narrative layer that hosts it.
|
||||
* Celebratory-but-quiet: `success` status, a single "view" action into the
|
||||
* Data, not AI: no module gate beyond the metric's own (applied by the IO
|
||||
* seam). Celebratory-but-quiet: `success` status, a single "view" action into the
|
||||
* metric's insight, and copy that marks arrival at a state, never a maintained
|
||||
* count. Shown the day reached and never again — never a "you broke it" note.
|
||||
*/
|
||||
@@ -609,7 +626,6 @@ function buildMilestoneItem(
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!milestone) return null;
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
const { title, body } = milestoneCopy(milestone, t);
|
||||
return {
|
||||
kind: "milestone",
|
||||
@@ -624,15 +640,14 @@ function buildMilestoneItem(
|
||||
href: milestoneHref(milestone),
|
||||
},
|
||||
],
|
||||
moduleKey: "insights",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* S11 — the elevated-at-rest ("tension") card. Emitted at most once per day
|
||||
* (the window is already the day's single most confident stretch), gated on
|
||||
* the `insights` module and on a non-null window (the analytics layer stays
|
||||
* silent unless every confidence gate holds). Cautious, non-diagnostic copy:
|
||||
* a non-null window (the analytics layer stays silent unless every confidence
|
||||
* gate holds). Pulse is a core vital, so no module gates it. Cautious, non-diagnostic copy:
|
||||
* "possible tension", never a clinical stress verdict. The one action deep-
|
||||
* links into the pulse insight where the intraday shape is charted.
|
||||
*/
|
||||
@@ -642,7 +657,6 @@ function buildTensionWindowItem(
|
||||
todayLocalDate: string,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
if (!window) return null;
|
||||
return {
|
||||
kind: "tension_window",
|
||||
@@ -657,7 +671,6 @@ function buildTensionWindowItem(
|
||||
href: "/insights/pulse",
|
||||
},
|
||||
],
|
||||
moduleKey: "insights",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -681,7 +694,6 @@ function buildSameTimeBaselineItem(
|
||||
todayLocalDate: string,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
if (!sameTime) return null;
|
||||
if (sameTime.band === "within") return null;
|
||||
// The comparison is against everything accumulated through the END of
|
||||
@@ -704,7 +716,6 @@ function buildSameTimeBaselineItem(
|
||||
href: "/insights/steps",
|
||||
},
|
||||
],
|
||||
moduleKey: "insights",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,7 +746,10 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one coach check-in card (§2.3). Gated on the `coach` module. Capped at
|
||||
* The one coach check-in card (§2.3). Served only while the `coach` AI
|
||||
* capability is available: the card's "Adjust" action opens the Coach, and a
|
||||
* Coach that is off, opted out or without a provider has nothing to open.
|
||||
* The capability folds in the `coach` module. Capped at
|
||||
* ONE per day across every plan: the earliest-due check-in wins, the rest wait
|
||||
* for a following day. Reads existing plan state only — never a fresh AI call.
|
||||
* Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust
|
||||
@@ -743,11 +757,11 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
|
||||
*/
|
||||
function buildCoachCheckinItem(
|
||||
plans: DailyDigestCoachPlan[],
|
||||
modules: DigestModuleMap,
|
||||
coach: AiCapabilityState,
|
||||
now: Date,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "coach")) return null;
|
||||
if (!coach.available) return null;
|
||||
const nowMs = now.getTime();
|
||||
const due = plans
|
||||
.filter((p) => isCheckinDue(p, nowMs))
|
||||
@@ -820,8 +834,8 @@ const ECG_VERDICT_KEYS: Record<
|
||||
};
|
||||
|
||||
/**
|
||||
* The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
|
||||
* (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
|
||||
* The one ECG "new recording" item (§3.5.3). Device data: no module gates
|
||||
* it, as none gates the ECG viewer. Fires ONLY when the freshest recording landed
|
||||
* within the last day — a calm "a new ECG recording is ready to view" pointer
|
||||
* into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
|
||||
* verdict, attributed to the device; HealthLog never interprets the trace (the
|
||||
@@ -834,7 +848,6 @@ function buildEcgNewRecordingItem(
|
||||
now: Date,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
if (!ecg) return null;
|
||||
const age = now.getTime() - ecg.recordedAt.getTime();
|
||||
// Skip a future-dated row (clock skew) and anything older than the window.
|
||||
@@ -860,7 +873,6 @@ function buildEcgNewRecordingItem(
|
||||
href: "/insights#ecg",
|
||||
},
|
||||
],
|
||||
moduleKey: "insights",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -932,8 +944,12 @@ export function buildDailyDigest(
|
||||
const sleepPending = sleepEnabled && sleepExpected && !isFinal;
|
||||
const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional";
|
||||
|
||||
const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;
|
||||
const briefingLead = firstSentence(input.briefing?.paragraph);
|
||||
// The briefing is model text: its lead and top signal are served only while
|
||||
// the `briefing` capability is available. The IO seam already hides it; this
|
||||
// is the builder's own backstop, so the DTO cannot carry it by accident.
|
||||
const briefing = input.ai.briefing.available ? input.briefing : null;
|
||||
const topSignal = briefing?.signalsOfDay?.[0] ?? null;
|
||||
const briefingLead = firstSentence(briefing?.paragraph);
|
||||
|
||||
// The day's most recently LANDED arrival drives both reaction fields. The
|
||||
// sample timestamp may be hours old (sleep synced after waking, an offline
|
||||
@@ -969,7 +985,10 @@ export function buildDailyDigest(
|
||||
: null;
|
||||
// An empty or whitespace-only line is treated as absent rather than shipped
|
||||
// as a blank lead — a degraded generation must fall through to the floor.
|
||||
const reactionLine = newestArrival?.line?.trim() || null;
|
||||
// Model text too: served only while `reactionLines` is available.
|
||||
const reactionLine = input.ai.reactionLines.available
|
||||
? newestArrival?.line?.trim() || null
|
||||
: null;
|
||||
|
||||
// Priority order: a due or overdue dose is the most time-sensitive daily
|
||||
// action, a broken sync next, then the calm coach check-in, a preventive
|
||||
@@ -993,7 +1012,7 @@ export function buildDailyDigest(
|
||||
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
|
||||
const checkin = buildCoachCheckinItem(
|
||||
input.coachPlans,
|
||||
input.modules,
|
||||
input.ai.coach,
|
||||
input.now,
|
||||
t,
|
||||
);
|
||||
@@ -1070,5 +1089,6 @@ export function buildDailyDigest(
|
||||
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
|
||||
justIn,
|
||||
reactionLine,
|
||||
ai: input.ai,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user