mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction routes. Conflicts resolved: - The digest keeps its capability-driven reaction line and check-in, now through the shared `aiCapabilityForRecord`; the local copy of it is removed. - The route inventories classify the extraction routes (documents, labs OCR, medication text, fenced chat and attachments) as actions; a capability call counts as the gate for the module inventory. - With every caller moved, `requireAssistantSurface` and `AssistantDisabledError` are deleted. The `assistant.disabled.*` codes stay on the capability refusal, and the guard now asserts the retired gate is gone. - `aiCapabilityToServe` answers whether stored model text may be shown from the record's own state, apart from who is reading. Refs #975
This commit is contained in:
commit
c509afeb2e
256 files changed
+13048
-4642
No files matched your search
@@ -45,13 +45,18 @@ function code(path: string): string {
|
||||
}
|
||||
|
||||
/** Whitespace-tolerant: a call split across lines still matches. */
|
||||
const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g;
|
||||
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
|
||||
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], key: m[2] }));
|
||||
return [...text.matchAll(GATE_CALL)].map((m) => ({
|
||||
fn: m[1] ?? m[3],
|
||||
key: m[2] ?? m[4],
|
||||
}));
|
||||
}
|
||||
|
||||
function routeFiles(): string[] {
|
||||
@@ -81,6 +86,9 @@ describe("AI capability route inventory", () => {
|
||||
{ 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", () => {
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
*
|
||||
* - `action` routes call `requireAiCapability(key)` and refuse with the
|
||||
* capability envelope;
|
||||
* - `mixed` reads call `getAiCapability(key)`, answer 200, null the model
|
||||
* text and say why in `ai`. They never refuse for an AI reason, so they
|
||||
* never call `requireAiCapability`.
|
||||
* - `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
|
||||
@@ -29,6 +31,67 @@ export interface AiRouteEntry {
|
||||
}
|
||||
|
||||
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"],
|
||||
@@ -189,23 +252,19 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
|
||||
"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 owns. Each still has
|
||||
* 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>> = {
|
||||
"src/app/api/insights/chat/fenced/route.ts":
|
||||
"Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
|
||||
"src/app/api/insights/chat/[id]/attachments/route.ts":
|
||||
"Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
|
||||
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
|
||||
"Detaching a document: removal of one's own data, stays ungated once the document routes move.",
|
||||
};
|
||||
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
|
||||
|
||||
/** The trees in which every route has to be classified above. */
|
||||
export const AI_ROUTE_TREES = [
|
||||
|
||||
@@ -42,9 +42,6 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* 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.
|
||||
* - coach → `requireAssistantSurface("coach")`, the retired gate,
|
||||
* still recognised for the one route outside these trees
|
||||
* that has not moved to its capability yet.
|
||||
*
|
||||
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* toggleable domain but are deliberately NOT gated, each with the
|
||||
@@ -253,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,
|
||||
@@ -277,9 +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",
|
||||
// NB: `medications/extract` is NOT exempt — it gates on
|
||||
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
|
||||
// surface), so the inventory already counts it as a delegated gate.
|
||||
// `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",
|
||||
@@ -421,10 +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)\s*\(/;
|
||||
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
|
||||
@@ -679,7 +675,6 @@ 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;
|
||||
|
||||
@@ -801,7 +796,6 @@ 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) ||
|
||||
fileHasCapabilityGate(text)
|
||||
) {
|
||||
|
||||
@@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
loadAssistantSwitches: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(generateCompletion).not.toHaveBeenCalled();
|
||||
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("ai.budget.exceeded");
|
||||
});
|
||||
|
||||
it("records a successful probe on the ledger", async () => {
|
||||
@@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ai/test — the operator's master switch and typed refusals", () => {
|
||||
it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
});
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.enabled",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed, without a 5xx, when the switches cannot be read", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.unavailable",
|
||||
reason: "check_failed",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a missing provider", async () => {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "none",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,23 @@ import {
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
AI_PROVIDER_NONE_ERROR_CODE,
|
||||
AI_UNAVAILABLE_ERROR_CODE,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
/**
|
||||
* The master switch's refusal code. The connection test sends a fixed prompt
|
||||
* and no health data, so it belongs to no capability; it answers to the
|
||||
* operator's master switch alone, under the `assistant.disabled.<switch>`
|
||||
* template with the master's own name (`enabled`) as the last segment.
|
||||
*/
|
||||
const MASTER_SWITCH = "enabled";
|
||||
const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`;
|
||||
|
||||
/** The daily AI budget refusal's code. */
|
||||
const AI_TEST_BUDGET_CODE = "ai.budget.exceeded";
|
||||
|
||||
/**
|
||||
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
||||
@@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
// With the operator's master switch off no AI call leaves this server, and
|
||||
// the connection test is an AI call. A switch set that could not be read
|
||||
// fails closed, like every capability does.
|
||||
// (403, not the usual 503 for `ai.unavailable`: this route never answers
|
||||
// 5xx, because a proxy rewrites a 5xx into an HTML page the settings form
|
||||
// cannot parse. See the catch below.)
|
||||
const switches = await loadAssistantSwitches();
|
||||
if (switches === null) {
|
||||
return apiError("AI is unavailable right now", 403, {
|
||||
errorCode: AI_UNAVAILABLE_ERROR_CODE,
|
||||
reason: "check_failed",
|
||||
});
|
||||
}
|
||||
if (!switches.enabled) {
|
||||
annotate({ action: { name: "ai.test.operator_disabled" } });
|
||||
return apiError("AI is turned off on this server", 403, {
|
||||
errorCode: AI_TEST_MASTER_DISABLED_CODE,
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
@@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
return apiError("No AI provider configured", 422, {
|
||||
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
|
||||
reason: "no_provider",
|
||||
});
|
||||
}
|
||||
|
||||
// Meter the probe on the same daily ledger every other AI surface writes to,
|
||||
@@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
||||
return apiError("Daily AI budget exhausted", 429);
|
||||
return apiError("Daily AI budget exhausted", 429, {
|
||||
errorCode: AI_TEST_BUDGET_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
|
||||
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
@@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -74,6 +77,13 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
|
||||
@@ -103,23 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
|
||||
await PATCH(mkPatch(false));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints the consent receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The mint
|
||||
// is marked `affirmative` because switching the toggle on IS the consent
|
||||
// act: unlike the settings-mount heal, it may lift an earlier
|
||||
// withdrawal, which is the user deciding again rather than the page
|
||||
// deciding for them.
|
||||
it("mints the extraction receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The toggle
|
||||
// grants document reading only: `ai_extraction`, not the master `ai_full`
|
||||
// that would also open the Coach and the AI analysis.
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"affirmative",
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
|
||||
.data as { userId: string; kind: string };
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.kind).toBe("ai_extraction");
|
||||
// The capability is read after the mint, so the fresh receipt counts.
|
||||
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
|
||||
.invocationCallOrder[0]!;
|
||||
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
|
||||
expect(mintOrder).toBeLessThan(readOrder);
|
||||
});
|
||||
|
||||
it("does not mint a second receipt when one that covers document reads is active", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "r1",
|
||||
} as never);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
revokedAt: null,
|
||||
kind: { in: ["ai_extraction", "ai_full"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a concurrent mint as success", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
|
||||
code: "P2002",
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
|
||||
// The operator turned document reading off: every summary the pass would
|
||||
// enqueue could only be refused, so it is not enqueued at all.
|
||||
withPrevious(false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the flag field-by-field", async () => {
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
* through the user's configured external provider with no per-document tap.
|
||||
*
|
||||
* Flipping it ON is itself the standing consent act, so the write also mints an
|
||||
* append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the
|
||||
* durable audit record that sits alongside the runtime short-circuit the
|
||||
* document consent gate reads. An OFF→ON flip additionally schedules a bounded
|
||||
* catch-up over the documents already stored (`enqueueSummaryCatchUp`), because
|
||||
* the summary job is enqueued at upload time and would otherwise never revisit
|
||||
* a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* append-only `ai_extraction` consent receipt — the durable record the document
|
||||
* capability reads. It covers reading documents, lab reports and medication
|
||||
* text and nothing else: the Coach and the AI analysis keep asking for their
|
||||
* own consent. (Before this release the toggle minted `ai_full`, which also
|
||||
* opened those; receipts minted that way stay valid, because nothing tells
|
||||
* them apart from a deliberate master grant.) An OFF→ON flip additionally
|
||||
* schedules a bounded catch-up over the documents already stored
|
||||
* (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time
|
||||
* and would otherwise never revisit a vault filled before the opt-in; it is
|
||||
* skipped when the `documentAi` capability is closed for the record, since
|
||||
* every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment). Idempotent — always returns the
|
||||
* resolved next state so the client can hard-set the optimistic update.
|
||||
@@ -30,8 +35,11 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
|
||||
import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
|
||||
@@ -45,6 +53,55 @@ type DocumentsAutoAiReadResponse = {
|
||||
documentsAutoAiRead: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The receipt kind the toggle mints: the extraction grant, narrower than the
|
||||
* master `ai_full`. The capability table lists it (with `ai_full`) as what
|
||||
* satisfies the document consent rule.
|
||||
*/
|
||||
const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND;
|
||||
|
||||
/**
|
||||
* Mint the extraction receipt unless one that satisfies document reads is
|
||||
* already active. The toggle is an affirmative act, so an earlier revocation
|
||||
* does not block it, exactly as a first grant would not be blocked. The
|
||||
* partial unique index on active receipts is the backstop for two concurrent
|
||||
* flips; losing that race means the other flip already minted, which is the
|
||||
* outcome wanted. Returns whether a receipt was written.
|
||||
*/
|
||||
async function ensureExtractionReceipt(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
const active = await prisma.consentReceipt.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (active) return false;
|
||||
try {
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: AUTO_READ_CONSENT_KIND,
|
||||
artefact: JSON.stringify({
|
||||
source: "web",
|
||||
kind: AUTO_READ_CONSENT_KIND,
|
||||
grantedAt: now.toISOString(),
|
||||
note: "Documents read automatically with AI, turned on in the app.",
|
||||
}),
|
||||
signedAt: now,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isP2002(err)) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } });
|
||||
@@ -103,11 +160,9 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
|
||||
// Turning the toggle ON is the standing consent act — mint an append-only
|
||||
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
|
||||
// addition to the runtime short-circuit the document consent gate reads.
|
||||
if (next) {
|
||||
await ensureWebAiConsentReceipt(user.id, "affirmative");
|
||||
}
|
||||
// extraction receipt (idempotent) so the durable audit trail records it and
|
||||
// the document capability can read it.
|
||||
const consentMinted = next ? await ensureExtractionReceipt(user.id) : false;
|
||||
|
||||
// A genuine OFF→ON flip schedules a catch-up over the documents already in
|
||||
// the vault. Without it the switch only ever applied to FUTURE uploads: the
|
||||
@@ -117,7 +172,16 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
// budget gate still runs per document inside the summary job itself.
|
||||
const wasEnabled = previous?.documentsAutoAiRead ?? false;
|
||||
if (next && !wasEnabled) {
|
||||
void enqueueSummaryCatchUp(user.id);
|
||||
// Read after the mint, so a receipt this request just wrote counts.
|
||||
const documentAi = await getAiCapability("documentAi");
|
||||
if (documentAi.available) {
|
||||
void enqueueSummaryCatchUp(user.id);
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" },
|
||||
meta: { reason: documentAi.reason },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("user.documentsAutoAiRead.update", {
|
||||
@@ -126,6 +190,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
details: {
|
||||
previous: previous?.documentsAutoAiRead ?? false,
|
||||
next,
|
||||
consentMinted,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
resolveModuleMap,
|
||||
@@ -272,7 +276,12 @@ export const GET = apiHandler(async () => {
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
timezone: user.timezone,
|
||||
// The zone the server cuts this account's days in: the stored zone, or
|
||||
// the instance default when the stored one is unusable. Clients bucket
|
||||
// by this value, so it must be the resolved one, not the raw column.
|
||||
timezone: isValidTimezone(user.timezone)
|
||||
? user.timezone
|
||||
: await resolveServerDefaultTimezone(),
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
|
||||
// the mock runs the callback against the same proxy. The withdrawal also
|
||||
// purges the regenerable AI text, so the proxy carries those delegates too.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
const prisma = {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
insightStatusCache: { deleteMany: vi.fn() },
|
||||
user: { updateMany: vi.fn() },
|
||||
insightNarrative: { deleteMany: vi.fn() },
|
||||
arrivalReaction: { updateMany: vi.fn() },
|
||||
workoutInsight: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
return { prisma };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -78,9 +84,21 @@ beforeEach(() => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
// Default: another receipt still covers the analysis, so no purge runs.
|
||||
// The purge cases below set this to 0.
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
for (const deleteMany of [
|
||||
prisma.insightStatusCache.deleteMany,
|
||||
prisma.insightNarrative.deleteMany,
|
||||
prisma.workoutInsight.deleteMany,
|
||||
]) {
|
||||
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
|
||||
}
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
// The withdrawal runs per kind in `consentKindEnum.options` order:
|
||||
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
|
||||
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
|
||||
// revoke a row; the others have none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
|
||||
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
|
||||
const revokedRow = {
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_insights_only",
|
||||
artefact: "PDF",
|
||||
signedAt: new Date("2026-05-18T10:00:00.000Z"),
|
||||
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
|
||||
createdAt: new Date("2026-05-18T10:00:01.000Z"),
|
||||
};
|
||||
|
||||
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
|
||||
userId: "user-1",
|
||||
details: {
|
||||
statusNotes: 4,
|
||||
briefing: 0,
|
||||
narratives: 0,
|
||||
reactionLines: 0,
|
||||
workoutParagraphs: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the text while another receipt still covers the analysis", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"consent.ai.purge",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("append-only invariant", () => {
|
||||
/**
|
||||
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* DELETE /api/consent/ai/latest
|
||||
* Master toggle — revokes the latest active receipt across every
|
||||
* consent kind. Returns the list of revoked rows.
|
||||
*
|
||||
* Either DELETE also deletes the regenerable model-written text (status
|
||||
* notes, the cached briefing, model-written narratives, reaction lines,
|
||||
* workout paragraphs) when it leaves no receipt covering the analysis, in the
|
||||
* same transaction as the revoke. Coach memory and document summaries stay.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -28,8 +33,8 @@ import {
|
||||
import {
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "@/lib/consent/receipts";
|
||||
import { withdrawConsent } from "@/lib/consent/withdrawal";
|
||||
import { serialiseReceipt } from "../route";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
: null,
|
||||
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
|
||||
ai_extraction: byKind.ai_extraction
|
||||
? serialiseReceipt(byKind.ai_extraction)
|
||||
: null,
|
||||
};
|
||||
|
||||
annotate({
|
||||
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
if (kind) {
|
||||
const revoked = await revokeLatest(user.id, kind);
|
||||
if (!revoked) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped.
|
||||
// The iOS toggle hammers this on every flip; returning 404
|
||||
// would surface as a spurious error in the client.
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, revoked: false },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: null });
|
||||
}
|
||||
// One kind, or every kind for the master "turn AI off" toggle. The revoke
|
||||
// and the purge of the regenerable text the consent covered commit
|
||||
// together (`withdrawConsent`).
|
||||
const kinds = kind ? [kind] : consentKindEnum.options;
|
||||
const { revoked, purged } = await withdrawConsent(user.id, kinds);
|
||||
|
||||
for (const { kind: k, receipt } of revoked) {
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind, receiptId: revoked.id },
|
||||
details: { kind: k, receiptId: receipt.id },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, receiptId: revoked.id },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
|
||||
}
|
||||
|
||||
// No kind specified — master "AI deaktivieren" toggle. Revoke the
|
||||
// latest active row for every kind that currently has one.
|
||||
const revokedRows: Array<{
|
||||
kind: ConsentKind;
|
||||
receipt: ReturnType<typeof serialiseReceipt>;
|
||||
}> = [];
|
||||
for (const k of consentKindEnum.options) {
|
||||
const r = await revokeLatest(user.id, k);
|
||||
if (r) {
|
||||
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind: k, receiptId: r.id },
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (purged) {
|
||||
// Counts only, never content.
|
||||
auditLog("consent.ai.purge", {
|
||||
userId: user.id,
|
||||
details: { ...purged },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: {
|
||||
kinds_revoked: revokedRows.map((r) => r.kind),
|
||||
count: revokedRows.length,
|
||||
kinds_revoked: revoked.map((r) => r.kind),
|
||||
count: revoked.length,
|
||||
purged: purged !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: revokedRows });
|
||||
if (kind) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
|
||||
// toggle hammers this on every flip; returning 404 would surface as a
|
||||
// spurious error in the client.
|
||||
const receipt = revoked[0]?.receipt ?? null;
|
||||
return apiSuccess({
|
||||
kind,
|
||||
receipt: receipt ? serialiseReceipt(receipt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
revoked: revoked.map((r) => ({
|
||||
kind: r.kind,
|
||||
receipt: serialiseReceipt(r.receipt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
...(txResult.moved.priorCycleId
|
||||
? { priorCycleId: txResult.moved.priorCycleId }
|
||||
: {}),
|
||||
// A later start folded into this one (the same period, recorded in
|
||||
// the other order): name the row that stopped existing and its date.
|
||||
...(txResult.moved.absorbedCycleId
|
||||
? {
|
||||
absorbedCycleId: txResult.moved.absorbedCycleId,
|
||||
absorbedStartDate: txResult.moved.absorbedStartDate,
|
||||
}
|
||||
: {}),
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
priorEndDate: txResult.moved.priorEndDateBefore,
|
||||
|
||||
@@ -44,8 +44,8 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
|
||||
}));
|
||||
// The per-read briefing capability; its own behaviour is pinned in
|
||||
// `snapshot-read-briefing-capability.test.ts`.
|
||||
vi.mock("@/lib/ai/capabilities/record", () => ({
|
||||
aiCapabilityForRecord: async () => ({
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityToServe: async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
|
||||
@@ -34,17 +34,13 @@ vi.mock("@/lib/documents/content-index", () => ({
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {
|
||||
errorCode = "consent.ai.required" as const;
|
||||
surface: string;
|
||||
constructor(surface: string) {
|
||||
super("consent required");
|
||||
this.surface = surface;
|
||||
this.name = "ConsentRequiredError";
|
||||
}
|
||||
},
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
assertAiEgress: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -94,10 +90,9 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
|
||||
import {
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
@@ -162,7 +157,7 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
reserved: 600,
|
||||
} as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
||||
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
@@ -413,9 +408,44 @@ describe("POST — numeric grounding", () => {
|
||||
});
|
||||
|
||||
describe("POST — consent / budget gating", () => {
|
||||
it("403s when document egress consent is missing", async () => {
|
||||
vi.mocked(assertDocumentEgressConsent).mockRejectedValue(
|
||||
new ConsentRequiredError("insights"),
|
||||
it("403s when the pick needs a consent receipt the record does not hold", async () => {
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", "consent_required"),
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req("doc-1", { message: "hi" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "documentAi",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers under documentAi alone: the vault's chat asks nothing of the Coach", async () => {
|
||||
await frames(
|
||||
await POST(
|
||||
req("doc-1", { message: "hi" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
),
|
||||
);
|
||||
expect(requireAiCapability).toHaveBeenCalledTimes(1);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(assertAiEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses before loading anything when document reading is off", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
);
|
||||
const res = await POST(
|
||||
req("doc-1", { message: "hi" }) as never,
|
||||
@@ -423,7 +453,8 @@ describe("POST — consent / budget gating", () => {
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode ?? body.errorCode).toBe("consent.ai.required");
|
||||
expect(body.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
expect(loadDocumentChatText).not.toHaveBeenCalled();
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -76,6 +77,11 @@ export const POST = apiHandler(
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// A turn sends the document's text to a model. The provider and the
|
||||
// consent receipt are answered by the pick, for the provider actually used.
|
||||
// Reading the history (GET) is the person's own data and stays open.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { id } = await params;
|
||||
const document = await loadOwnedDocument(userId, id);
|
||||
if (!document) {
|
||||
@@ -213,6 +219,7 @@ export const POST = apiHandler(
|
||||
contractLocale,
|
||||
locale,
|
||||
signal: request.signal,
|
||||
alsoRequires: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -56,14 +56,14 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", async () =>
|
||||
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-27"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
@@ -117,6 +117,8 @@ import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
const tx = (
|
||||
prisma as unknown as {
|
||||
@@ -323,3 +325,39 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract — the documentAi capability", () => {
|
||||
it("refuses with the operator's code before the document is loaded", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
|
||||
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at the wire when the picked provider needs a receipt, before any slot is charged", async () => {
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", "consent_required"),
|
||||
} as never);
|
||||
vi.mocked(loadDocumentChatText).mockResolvedValue({
|
||||
text: "Glucose 95 mg/dL",
|
||||
} as never);
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("consent.ai.required");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,9 @@
|
||||
* Extraction is no longer part of upload: a document is stored first
|
||||
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
|
||||
* It carries the entire ingest gauntlet that upload used to run inline —
|
||||
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
|
||||
* → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
|
||||
* requireAiCapability("documentAi") → resolve document provider (local-first,
|
||||
* codex last) and re-check the capability for that pick, consent receipt
|
||||
* included → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
|
||||
* facts — but now against a row that already exists. Absent a provider this 422s
|
||||
* the ENHANCEMENT only; the stored document is untouched and remains filed.
|
||||
*
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -58,8 +59,8 @@ import {
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import {
|
||||
checkDocumentAiRateLimit,
|
||||
@@ -139,6 +140,10 @@ export const POST = apiHandler(
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Reading a document is model work. The provider and the consent receipt
|
||||
// are answered by the pick below, for the provider actually used.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
@@ -220,18 +225,7 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -337,18 +331,7 @@ async function handleStoredExtract(
|
||||
});
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -423,18 +406,7 @@ async function handleVisionExtract(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentVisionProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
@@ -35,18 +35,19 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", async () =>
|
||||
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
maybeAutoStageLabFacts: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ staged: false, reason: "not-lab" }),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
DocumentDescribeError,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -183,17 +183,12 @@ async function handleVisionIndex(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
// Transcribing the stored original is model work; indexing text the browser
|
||||
// already read (the text mode above) is not, and stays open with AI off so
|
||||
// search keeps working. The provider and the consent receipt are answered
|
||||
// by the pick, for the provider actually used.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
const pick = await requireDocumentVisionProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
@@ -27,14 +27,14 @@ vi.mock("@/lib/documents/assist", () => ({
|
||||
runDocumentAssist: vi.fn(),
|
||||
DocumentAssistError: class DocumentAssistError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", async () =>
|
||||
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -48,8 +48,8 @@ import {
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -67,6 +67,10 @@ export const POST = apiHandler(
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Reading a document is model work. The provider and the consent receipt
|
||||
// are answered by the pick below, for the provider actually used.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { id } = await params;
|
||||
const document = await loadOwnedDocument(user.id, id);
|
||||
if (!document) {
|
||||
@@ -125,18 +129,7 @@ async function handleTextSuggest(
|
||||
});
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -218,18 +211,7 @@ async function handleVisionSuggest(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentVisionProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
@@ -40,14 +40,14 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
documentSummaryBlockedCopy: () => "The summary was withheld.",
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", async () =>
|
||||
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -59,8 +59,8 @@ import {
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -203,6 +203,10 @@ export const POST = apiHandler(
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Reading a document is model work. The provider and the consent receipt
|
||||
// are answered by the pick below, for the provider actually used.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { id } = await params;
|
||||
const document = await loadOwnedDocument(user.id, id);
|
||||
if (!document) {
|
||||
@@ -321,19 +325,9 @@ async function handleTextSummary(
|
||||
return finishSummary(request, userId, document.id, "text", mode, result);
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
let pick: Awaited<ReturnType<typeof requireDocumentTextProvider>>;
|
||||
try {
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
pick = await requireDocumentTextProvider(userId);
|
||||
} catch (err) {
|
||||
// Refused before any dispatch — the slot goes back with the refusal.
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -394,17 +388,7 @@ async function handleVisionSummary(
|
||||
locale: Locale,
|
||||
persist: SummaryPersistenceTarget | null,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
const pick = await requireDocumentVisionProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
@@ -92,6 +92,14 @@ vi.mock("@/lib/jobs/document-index", () => ({
|
||||
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/document-summary", () => ({
|
||||
enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/jobs/document-thumbnail", () => ({
|
||||
enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
}));
|
||||
@@ -129,6 +137,8 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
|
||||
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
acquireDocumentUploadSlot,
|
||||
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
|
||||
@@ -547,3 +557,27 @@ describe("GET /api/documents/inbound (list)", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => {
|
||||
it("enqueues the background summary when document reading is open", async () => {
|
||||
const res = await post(mkUpload());
|
||||
expect(res.status).toBe(201);
|
||||
expect(getAiCapability).toHaveBeenCalledWith("documentAi");
|
||||
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
|
||||
});
|
||||
|
||||
it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => {
|
||||
vi.mocked(getAiCapability).mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const res = await post(mkUpload());
|
||||
// The upload is data: accepted whatever AI says.
|
||||
expect(res.status).toBe(201);
|
||||
expect(txCreate).toHaveBeenCalled();
|
||||
// The index job still runs its provider-free text-layer path.
|
||||
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
|
||||
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
|
||||
reason: capability.reason,
|
||||
pdfSupported: capability.pdfSupported,
|
||||
egress: capability.egress,
|
||||
aiReason: capability.ai.reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,11 +20,10 @@ vi.mock("@/lib/jobs/document-content-index-backfill", () => ({
|
||||
enqueueContentIndexBackfill: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
requireDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
@@ -54,7 +53,9 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -78,7 +79,14 @@ const req = () =>
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(requireAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(requireDocumentVisionProvider).mockResolvedValue(
|
||||
PICK.pick as never,
|
||||
);
|
||||
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true });
|
||||
// 7 live documents, 4 already indexed → 3 remain.
|
||||
vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never);
|
||||
@@ -114,3 +122,36 @@ describe("POST /api/documents/inbound/reindex", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/reindex — the documentAi capability", () => {
|
||||
it("answers the capability envelope and enqueues nothing when document reading is off", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValue(
|
||||
new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
);
|
||||
const res = await POST(req() as never);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the vault's own code when no provider can read documents", async () => {
|
||||
vi.mocked(requireDocumentVisionProvider).mockRejectedValue(
|
||||
new AiUnavailableError("documentAi", "no_provider", null, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
);
|
||||
const res = await POST(req() as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("documents.inbound.providerUnsupported");
|
||||
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* Enqueues a per-user job that indexes the caller's not-yet-indexed documents
|
||||
* (one provider transcription each, bounded + resumable). Gated on the module,
|
||||
* a configured vision provider, and the EXISTING AI consent (the worker
|
||||
* re-checks all three). The work runs off-request on pg-boss; this route only
|
||||
* the `documentAi` capability, a configured vision provider, and a document
|
||||
* consent receipt for that provider (the worker re-checks all of it). The work runs off-request on pg-boss; this route only
|
||||
* enqueues and returns immediately so the vault stays responsive.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -30,6 +30,10 @@ export const POST = apiHandler(async (request) => {
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// The backfill transcribes each document through a model. The provider and
|
||||
// the consent receipt are answered by the pick below.
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-reindex:${user.id}`,
|
||||
REINDEX_LIMIT_PER_HOUR,
|
||||
@@ -47,17 +51,8 @@ export const POST = apiHandler(async (request) => {
|
||||
|
||||
// Fail fast when the precondition is not met so the UI gets immediate
|
||||
// feedback rather than a silently no-op'd job.
|
||||
const { pick } = await resolveDocumentVisionProvider(user.id);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertDocumentEgressConsent({
|
||||
userId: user.id,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
// The worker re-checks all of it per run.
|
||||
await requireDocumentVisionProvider(user.id);
|
||||
|
||||
const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
@@ -446,10 +447,21 @@ async function processUpload(
|
||||
|
||||
// Summarise the freshly stored document in the background — but ONLY when the
|
||||
// `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
|
||||
// consent, and no-ops otherwise). The persisted summary shows on the detail
|
||||
// consent, and no-ops otherwise) and the `documentAi` capability is open for
|
||||
// this record. The upload itself never depends on AI: the document is stored
|
||||
// above whatever the capability says, and the index job still runs its
|
||||
// provider-free text-layer path. The persisted summary shows on the detail
|
||||
// view. Same fire-and-forget contract: the upload never blocks on or fails
|
||||
// because of it. Only fresh inserts reach here (a duplicate returns early).
|
||||
void enqueueDocumentSummary(user.id, document.id);
|
||||
const documentAi = await getAiCapability("documentAi");
|
||||
if (documentAi.available) {
|
||||
void enqueueDocumentSummary(user.id, document.id);
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "documents.summary.enqueueSkipped" },
|
||||
meta: { documentId: document.id, reason: documentAi.reason },
|
||||
});
|
||||
}
|
||||
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DOCUMENT_ACCEPTED_EXTENSIONS,
|
||||
resolveDocumentLimits,
|
||||
} from "@/lib/documents/upload-policy";
|
||||
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
|
||||
import { listDistinctTargets } from "@/lib/links";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -58,8 +58,10 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
// Whether an AI action can run for this caller (assist + indexing share
|
||||
// the same provider precondition). Honest availability so the UI never
|
||||
// offers what the endpoint would 422.
|
||||
resolveOcrCapability(user.id),
|
||||
// offers what the endpoint would refuse: the document provider order and
|
||||
// the `documentAi` capability for this record, the same answer the
|
||||
// capability probe gives.
|
||||
resolveDocumentAiCapability(user.id),
|
||||
// Content-index coverage: how many live documents are indexed …
|
||||
// Scope the count to LIVE documents: a soft-deleted document keeps its
|
||||
// index row (cascade fires on hard purge only), so an unscoped count
|
||||
|
||||
@@ -59,7 +59,8 @@ function routeFiles(tree: string): string[] {
|
||||
|
||||
const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort();
|
||||
|
||||
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
|
||||
const ANY_GATE =
|
||||
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
|
||||
|
||||
function code(path: string): string {
|
||||
return readFileSync(resolve(repoRoot, path), "utf8")
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
@@ -28,6 +29,11 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -43,6 +49,8 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { DELETE } from "../[documentId]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import {
|
||||
attachDocument,
|
||||
detachDocument,
|
||||
@@ -211,3 +219,50 @@ describe("DELETE detach", () => {
|
||||
expect(body.data.fenced).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachments — the coach and documentAi capabilities", () => {
|
||||
it("refuses an attach when the Coach is off for the record", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("coach", "user_disabled", "coach"),
|
||||
);
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await code(res)).toBe("module.disabled");
|
||||
expect(attachDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks both capabilities on attach", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null);
|
||||
await POST(postReq({ documentId: "doc-a" }) as never, postCtx() as never);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
|
||||
pickDecides: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a detach through with every capability closed: it removes the person's own data", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValue(
|
||||
new AiUnavailableError("coach", "operator_disabled"),
|
||||
);
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: true,
|
||||
messageCount: 0,
|
||||
attachmentIds: ["doc-a"],
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
|
||||
{ method: "DELETE" },
|
||||
) as never,
|
||||
delCtx() as never,
|
||||
);
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(requireAiCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -48,6 +49,12 @@ export const POST = apiHandler(
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Attaching prepares a fenced turn, which is Coach work over documents.
|
||||
// Nothing is sent here, so the provider and the consent receipt wait for
|
||||
// the turn. Detaching (DELETE) removes the person's own data and stays open.
|
||||
await requireAiCapability("coach", { pickDecides: true });
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { id: conversationId } = await params;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -10,6 +10,11 @@ import { NextRequest } from "next/server";
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/provider-order-mock")
|
||||
).openCapabilityGateMock(),
|
||||
);
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
@@ -54,6 +59,8 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import {
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
@@ -260,3 +267,50 @@ describe("fenced route — refusal", () => {
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — the coach and documentAi capabilities", () => {
|
||||
it("answers under both, leaving provider and consent to the pick", async () => {
|
||||
await POST(req({ message: "hi", attachmentIds: ["doc-a"] }) as never);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
|
||||
pickDecides: true,
|
||||
});
|
||||
// The turn re-checks the Coach for the picked provider too.
|
||||
expect(streamFencedReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alsoRequires: ["coach"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses with the Coach's code before any conversation is created", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("coach", "operator_disabled"),
|
||||
);
|
||||
const res = await POST(
|
||||
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("assistant.disabled.coach");
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when document reading is off even with the Coach on", async () => {
|
||||
vi.mocked(requireAiCapability)
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
);
|
||||
const res = await POST(
|
||||
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("assistant.disabled.documentAi");
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,15 @@
|
||||
* route via `@/lib/documents/fenced-chat` so the two cannot drift.
|
||||
*
|
||||
* THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health
|
||||
* snapshot; every document fenced as untrusted DATA; per-document egress consent;
|
||||
* snapshot; every document fenced as untrusted DATA; the `coach` and
|
||||
* `documentAi` capabilities, re-checked for the picked provider;
|
||||
* numeric grounding over the LIVE attachment union only. A plain tool
|
||||
* conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn
|
||||
* can never be appended into a tool thread's history.
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -56,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// A fenced turn is a Coach turn that reads documents: both capabilities must
|
||||
// be open. The provider and the consent receipt are answered by the pick,
|
||||
// for the provider actually used (a missing provider stays an SSE frame).
|
||||
await requireAiCapability("coach", { pickDecides: true });
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
@@ -224,5 +232,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
contractLocale,
|
||||
locale,
|
||||
signal: request.signal,
|
||||
alsoRequires: ["coach"],
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
|
||||
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
|
||||
* for the calling user's configured AI provider. The surface stays dark for
|
||||
* users with no provider configured and no local-OCR opt-in.
|
||||
* users with no provider configured and no local-OCR opt-in, and whenever the
|
||||
* `labsOcr` capability is closed (operator switch, labs module, sharing grant);
|
||||
* `ai` says which.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -25,6 +27,7 @@ export const GET = apiHandler(async () => {
|
||||
mode: capability.mode,
|
||||
reason: capability.reason,
|
||||
pdfSupported: capability.pdfSupported,
|
||||
aiReason: capability.ai.reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveTextProvider: vi.fn(),
|
||||
resolveVisionProvider: vi.fn(),
|
||||
requireLabsOcrProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/rasterize-pdf", () => ({
|
||||
rasterizePdf: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -60,10 +59,9 @@ import { POST } from "../route";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
@@ -94,12 +92,14 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "openai", instance: {} as never }],
|
||||
pick: {
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
},
|
||||
vi.mocked(requireAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
beforeEach(() => {
|
||||
// A non-Anthropic vision provider (codex): no native PDF block, so the
|
||||
// route must rasterize.
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "codex", instance: {} as never }],
|
||||
localOcrEnabled: false,
|
||||
pick: {
|
||||
entry: { providerType: "codex", instance: {} as never },
|
||||
providerType: "codex",
|
||||
pdfSupported: false,
|
||||
},
|
||||
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
|
||||
entry: { providerType: "codex", instance: {} as never },
|
||||
providerType: "codex",
|
||||
pdfSupported: false,
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — the labsOcr capability", () => {
|
||||
it("refuses before anything is read when the operator turned document reading off", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "operator_disabled"),
|
||||
);
|
||||
const error = await POST(textReq()).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(requireLabsOcrProvider).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => {
|
||||
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "consent_required"),
|
||||
);
|
||||
const error = await POST(textReq()).catch((e) => e);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "labsOcr",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for the vision pick on a multipart upload", async () => {
|
||||
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "no_provider", null, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
}),
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append("file", new File([new Uint8Array([1])], "a.png"));
|
||||
const error = await POST(
|
||||
new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}),
|
||||
).catch((e) => e);
|
||||
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision");
|
||||
expect(error.status).toBe(422);
|
||||
expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@
|
||||
* `labsLocalOcrEnabled` preference.
|
||||
*
|
||||
* Guards mirror the Coach's discipline in both modes:
|
||||
* requireAuth → resolve provider → assertConsentForChain → rate-limit
|
||||
* requireAuth → requireAiCapability("labsOcr") → resolve provider and
|
||||
* re-check the capability for that pick (`requireLabsOcrProvider`: the
|
||||
* operator's switch, the labs module, and a document consent receipt for a
|
||||
* pick that leaves the machine) → rate-limit
|
||||
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
|
||||
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
|
||||
* handed back when the scan fails before the provider is called.
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -48,10 +51,7 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import {
|
||||
BodyTooLargeError,
|
||||
@@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Reading a lab report is model work in both modes: vision sends the image,
|
||||
// text mode sends what the browser read off it. The provider and the consent
|
||||
// receipt are answered by the pick below, for the provider actually used.
|
||||
await requireAiCapability("labsOcr", { pickDecides: true });
|
||||
|
||||
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
|
||||
// multipart body is the native-vision image/PDF mode.
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
@@ -124,21 +129,10 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
// Any configured provider can structure the text.
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: { chainLength: chain.length, mode: "text" },
|
||||
});
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
// Consent gate — only the server-managed key trips this; BYOK / local / codex
|
||||
// egress is the user's own act (the toggle is the consent for local OCR).
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
// Any configured provider can structure the text. The pick is re-checked
|
||||
// against `labsOcr` for exactly that provider; one that leaves the machine
|
||||
// needs a document consent receipt.
|
||||
const pick = await requireLabsOcrProvider(userId, "text");
|
||||
|
||||
const rl = await checkLabsOcrRateLimit(userId);
|
||||
if (!rl.allowed) {
|
||||
@@ -244,20 +238,9 @@ async function handleVisionExtract(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
// 1. Resolve a vision-capable provider. 422 when none is configured.
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: { chainLength: chain.length },
|
||||
});
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Consent gate — server-managed egress of health data requires a receipt.
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
// 1-2. Resolve a vision-capable provider (422 when none is configured) and
|
||||
// re-check `labsOcr` for it, consent receipt included.
|
||||
const pick = await requireLabsOcrProvider(userId, "vision");
|
||||
|
||||
// 3. Rate-limit (vision calls are costly).
|
||||
const rl = await checkLabsOcrRateLimit(userId);
|
||||
|
||||
@@ -32,9 +32,6 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
|
||||
// `recomputeUserRollups` inline. Mock it so tests can assert whether
|
||||
// the inline fold fired without exercising the real populator.
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
@@ -66,7 +63,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -221,318 +218,50 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
expect(months.size).toBe(24);
|
||||
});
|
||||
|
||||
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
|
||||
// v1.4.36 W1 — Insights trends row routes the three daily chart
|
||||
// fetches through the persistent DAY buckets via `source=rollup`.
|
||||
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
|
||||
// path where the rollup has rows for the requested window.
|
||||
const buckets = Array.from({ length: 30 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||||
mean: 81 + i * 0.05,
|
||||
count: 5,
|
||||
// v1.11.1 — single source per day → collapse is identity.
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
it("source=rollup + aggregate=daily folds live in the user's zone, never the UTC-day rollup (#1026)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Asia/Kolkata",
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
// The resolver caches per user; drop what earlier cases left behind.
|
||||
invalidateUserTimezone("user-1");
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucket_start: new Date("2026-05-09T18:30:00.000Z"),
|
||||
avg: 8123,
|
||||
cnt: 96,
|
||||
min_value: 0,
|
||||
max_value: 400,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
"type=ACTIVITY_STEPS&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ type: string; value: number; count: number }>;
|
||||
meta: { aggregate?: string };
|
||||
measurements: Array<{
|
||||
measuredAt: string;
|
||||
value: number;
|
||||
minValue?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(json.data.measurements.length).toBe(30);
|
||||
// First bucket reflects the rollup mean — round-trip-stable.
|
||||
expect(json.data.measurements[0].type).toBe("WEIGHT");
|
||||
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
|
||||
expect(json.data.measurements[0].count).toBe(5);
|
||||
expect(json.data.meta.aggregate).toBe("daily");
|
||||
});
|
||||
|
||||
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
|
||||
// ACTIVITY_STEPS rollup row with sum_value populated. The route
|
||||
// must consume sumValue directly — NOT reconstruct from mean *
|
||||
// count. Use a sum that is NOT mean × count so the test
|
||||
// distinguishes the two paths.
|
||||
const buckets = Array.from({ length: 3 }, (_, i) => ({
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||||
mean: 2000, // would yield 10000 if multiplied by count=5
|
||||
count: 5,
|
||||
sumValue: 11000 + i * 250, // distinct from mean × count
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<{ value: number }> };
|
||||
};
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(json.data.measurements.map((m) => m.value)).toEqual([
|
||||
11000, 11250, 11500,
|
||||
]);
|
||||
});
|
||||
|
||||
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
|
||||
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
|
||||
// route falls back to mean × count so the chart never paints
|
||||
// a hole during the convergence window.
|
||||
const buckets = [
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1)),
|
||||
mean: 2000,
|
||||
count: 5,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<{ value: number }> };
|
||||
};
|
||||
expect(json.data.measurements[0].value).toBe(10000);
|
||||
});
|
||||
|
||||
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
|
||||
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
|
||||
// see a correct chart on their first render.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
|
||||
avg: 81 + i * 0.05,
|
||||
cnt: 2,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(7);
|
||||
});
|
||||
|
||||
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
|
||||
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
|
||||
// `total: 2` from the rollup branch even though `measurements`
|
||||
// carried ~30 distinct days. The chart tripped its `< 3 daily
|
||||
// points` empty-state because the route short-circuited on
|
||||
// `rollupRows.length > 0` and never reconciled with the live
|
||||
// table. The coverage-mismatch fallback runs an inline
|
||||
// `recomputeUserRollups` for the (user, type, DAY, window)
|
||||
// partition and re-reads so the request returns the converged
|
||||
// rows.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
const convergedRollup = Array.from({ length: 28 }, (_, i) => ({
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||||
mean: 128 + (i % 5),
|
||||
count: 2,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
// First read returns the sparse rows; second (post-fold) read
|
||||
// returns the converged rows.
|
||||
vi.mocked(prisma.measurementRollup.findMany)
|
||||
.mockResolvedValueOnce(sparseRollup as never)
|
||||
.mockResolvedValueOnce(convergedRollup as never);
|
||||
// Coverage probe — live `measurements` has 28 distinct days.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ days: BigInt(28) },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
// The inline fold fired exactly once with the (type, DAY, window)
|
||||
// bound so the partition write stays small.
|
||||
expect(recomputeUserRollups).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
types: ["BLOOD_PRESSURE_SYS"],
|
||||
granularities: ["DAY"],
|
||||
}),
|
||||
);
|
||||
// The route re-read the rollup after the fold so the response
|
||||
// carries the converged 28 buckets — well above the chart's
|
||||
// `< 3 daily points` empty-state threshold.
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(json.data.measurements.length).toBe(28);
|
||||
});
|
||||
|
||||
it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => {
|
||||
// Happy case: 30-day window, 28 rollup rows present. The route
|
||||
// must short-circuit on the first read and skip the probe so the
|
||||
// covered-tenant hot path stays a single indexed read.
|
||||
const buckets = Array.from({ length: 28 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||||
mean: 81 + i * 0.05,
|
||||
count: 2,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => {
|
||||
// A 5-day window with 2 rollup rows is plausibly genuine sparsity
|
||||
// for a user who measures BP every other day. The coverage
|
||||
// fallback gates on `windowDays >= 7` so the probe + inline fold
|
||||
// never fire on these short windows.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
sparseRollup as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
});
|
||||
|
||||
it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => {
|
||||
// Genuine sparsity: the user measured BP twice in 30 days. The
|
||||
// probe finds the same 2 distinct days in `measurements` so the
|
||||
// route returns the 2 rollup rows without firing the inline
|
||||
// fold. The chart's `< 3 daily points` empty-state is the right
|
||||
// surface for this case — the data really is sparse.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
sparseRollup as never,
|
||||
);
|
||||
// Probe agrees — 2 distinct days in `measurements` too.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ days: BigInt(2) },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Probe fired (suspicious-low rollup) but the inline fold did
|
||||
// not (live and rollup agree).
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
|
||||
// The zone travels as a bound parameter of the fold.
|
||||
const call = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(call.slice(1)).toContain("Asia/Kolkata");
|
||||
// Local midnight of 10 May in Kolkata; a SUM carries no spread band.
|
||||
expect(json.data.measurements[0]).toMatchObject({
|
||||
measuredAt: "2026-05-09T18:30:00.000Z",
|
||||
value: 8123,
|
||||
});
|
||||
expect(json.data.measurements[0].minValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a weekly series when the all-time window is under two years", async () => {
|
||||
|
||||
@@ -45,20 +45,21 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { readDailySeries } from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
readLiveBuckets,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
@@ -393,36 +394,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// The route's response is bounded by the `BUCKET_CAP` ceiling per
|
||||
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
|
||||
// account never paints an unbounded series.
|
||||
// v1.4.36 W1 — rollup-table read for daily aggregates.
|
||||
//
|
||||
// The Insights trends row fires three parallel
|
||||
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
|
||||
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
|
||||
// path scanned the measurements table each time — a full year window
|
||||
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
|
||||
// the persistent `measurement_rollups` DAY buckets drops each call
|
||||
// to a small indexed read against the ~5 k-row rollup table.
|
||||
//
|
||||
// Gating:
|
||||
// - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement`
|
||||
// constrains the enum to `["rollup"]`).
|
||||
// - We require `aggregate=daily` because the rollup populator only
|
||||
// keeps DAY buckets on the synchronous write hook; the WEEK /
|
||||
// MONTH paths still depend on the async pg-boss recomputes which
|
||||
// may lag behind a recent write.
|
||||
// - `type` is mandatory so the response shape mirrors the per-type
|
||||
// ask the chart code makes.
|
||||
// - When the rollup window returns zero rows we fall through to
|
||||
// the live `date_trunc` path so a brand-new account whose
|
||||
// populator hasn't caught up still sees a correct chart on its
|
||||
// first render.
|
||||
// `source=rollup` + `aggregate=daily` — the chart's series read. Windows up
|
||||
// to the DAY cap fold live into the user's local days (#1026); wider
|
||||
// windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is
|
||||
// mandatory so the response mirrors the per-type ask the chart makes.
|
||||
if (source === "rollup" && aggregate === "daily" && type && from && to) {
|
||||
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
|
||||
// probe + inline fold + live-SQL fallback) moved to the shared
|
||||
// `readDailySeries` reader so the batched series endpoint
|
||||
// (`/api/measurements/series-batch`) reads through the SAME path
|
||||
// instead of duplicating this SQL. Behaviour is unchanged: the rows
|
||||
// returned here are byte-identical with the pre-extraction branch.
|
||||
// Shared with the batched series endpoint, so both read the same rows.
|
||||
const cap = Math.min(limit, BUCKET_CAP.daily);
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const measurements = await readDailySeries({
|
||||
@@ -444,94 +421,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// `readDailySeries` already falls back to the live `date_trunc`
|
||||
// aggregate on a rollup-coverage miss, so an empty result here means
|
||||
// the window genuinely holds no rows. Fall through to the shared
|
||||
// live aggregate branch below to keep the response shape identical
|
||||
// for the truly-empty case.
|
||||
// An empty result means the window genuinely holds no rows. Fall
|
||||
// through to the shared live aggregate branch below to keep the
|
||||
// response shape identical for the truly-empty case.
|
||||
}
|
||||
|
||||
if (aggregate && aggregate !== "raw" && from && to) {
|
||||
const grain: AggregateGrain = aggregate;
|
||||
const grain: Exclude<AggregateGrain, "raw"> = aggregate;
|
||||
const cap = Math.min(limit, BUCKET_CAP[grain]);
|
||||
// Postgres `date_trunc` requires the unit argument to be a SQL
|
||||
// literal — it cannot be supplied as a prepared-statement parameter.
|
||||
// The previous `${truncUnit}` (bound) interpolation 500'd in
|
||||
// production for every grain; the grain string is restricted to
|
||||
// the `AggregateGrain` enum upstream via the Zod schema, so
|
||||
// injecting the mapped unit via `Prisma.raw` is safe. The mapping
|
||||
// also fixes the "weekly"/"monthly" passthrough — Postgres expects
|
||||
// the singular forms `week`/`month`.
|
||||
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
};
|
||||
const truncUnit = TRUNC_UNIT[grain];
|
||||
const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`);
|
||||
// Cumulative HK types (steps, active energy, flights, distance,
|
||||
// daylight) are partial-day increments — averaging across the day
|
||||
// understates the daily total by the per-bucket sample count.
|
||||
// SUM for those; AVG for spot metrics (BP, weight, pulse, BG,
|
||||
// body fat, mood, sleep). See R-A finding 2.
|
||||
const useSum =
|
||||
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
|
||||
const aggregator = useSum
|
||||
? Prisma.raw(`SUM(m."value")::double precision`)
|
||||
: Prisma.raw(`AVG(m."value")::double precision`);
|
||||
// v1.11.1 — collapse overlapping sources to the ladder-canonical reading
|
||||
// per (type, bucket) before aggregating, so this cold/uncovered fallback
|
||||
// agrees with the warm rollup path. `canon` picks the winning source for
|
||||
// each (type, bucket) via the per-user rank; the join keeps only its rows.
|
||||
// Buckets are cut at the user's own calendar boundaries (#1026), the
|
||||
// same day the raw list and the chart's short range use. Cumulative
|
||||
// types SUM per bucket, every other type averages; overlapping sources
|
||||
// collapse to the ladder-canonical one first.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const rankRaw = Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
|
||||
);
|
||||
const typeFilter = type
|
||||
? Prisma.sql`AND m."type" = ${type}::measurement_type`
|
||||
: Prisma.empty;
|
||||
const buckets = await prisma.$queryRaw<
|
||||
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
|
||||
>`
|
||||
WITH canon AS (
|
||||
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
|
||||
m."type" AS t,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
|
||||
m."source" AS canon
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
|
||||
${aggregator} AS avg,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM measurements m
|
||||
JOIN canon c
|
||||
ON c.t = m."type"
|
||||
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
GROUP BY m."type", bucket_start
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ${cap}
|
||||
`;
|
||||
|
||||
const measurements = buckets.map((b) => ({
|
||||
type: b.type,
|
||||
value: Number(b.avg),
|
||||
measuredAt: b.bucket_start.toISOString(),
|
||||
count: Number(b.cnt),
|
||||
}));
|
||||
const measurements = await readLiveBuckets({
|
||||
userId: user.id,
|
||||
type: (type as MeasurementType | undefined) ?? null,
|
||||
from,
|
||||
to,
|
||||
cap,
|
||||
priorityJson,
|
||||
grain,
|
||||
timeZone: await resolveUserTimezone(user.id),
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: measurements.length, type, aggregate: grain },
|
||||
|
||||
@@ -264,3 +264,165 @@ describe("GET /api/medications/[id]/dose-history", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #1028 — a dose logged for earlier today on a medication created later
|
||||
* the same day vanished from the trailing-window ledger while the full
|
||||
* history (which has no window) showed it. The window floor was clamped to
|
||||
* the medication's creation instant and the same clamped floor filtered the
|
||||
* intakes, so any dose recorded with a time before the moment the medication
|
||||
* was added fell out of "Last 90 days". The creation floor exists to keep
|
||||
* the ledger from minting phantom slots before the medication existed; it
|
||||
* says nothing about which recorded doses belong in the window.
|
||||
*
|
||||
* Pinned in a positive and a negative UTC offset so a zone-dependent floor
|
||||
* cannot hide behind one of them.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => {
|
||||
for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) {
|
||||
it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => {
|
||||
const today = new Date("2026-09-22T12:00:00Z");
|
||||
const now = localHmAsUtc(today, tz, 16, 30);
|
||||
const createdAt = localHmAsUtc(today, tz, 16, 20);
|
||||
const takenAt = localHmAsUtc(today, tz, 14, 0);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
scheduleRevisions: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "evt-earlier-today",
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
attributionSource: "AUTO",
|
||||
doseTaken: null,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
|
||||
// Exactly what the Verlauf tab sends: the trailing 90 days to now.
|
||||
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
|
||||
const to = now.toISOString();
|
||||
const res = await GET(
|
||||
getReq(
|
||||
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
const ids = (
|
||||
json.data.rows as Array<{ intake: { id: string | null } | null }>
|
||||
).map((r) => r.intake?.id);
|
||||
expect(ids).toContain("evt-earlier-today");
|
||||
// The response reports the window the recorded doses were read
|
||||
// over: the requested start, not the medication's creation.
|
||||
expect(json.data.from).toBe(from);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => {
|
||||
it("keeps a dose taken inside its window before the slot's own time", async () => {
|
||||
const tz = "Asia/Kolkata";
|
||||
const today = new Date("2026-09-22T12:00:00Z");
|
||||
const now = localHmAsUtc(today, tz, 19, 30);
|
||||
const slot = localHmAsUtc(today, tz, 20, 0);
|
||||
const takenAt = localHmAsUtc(today, tz, 19, 15);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
scheduleRevisions: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "20:00",
|
||||
windowEnd: "20:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["20:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "evt-early",
|
||||
scheduledFor: slot,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
attributionSource: "AUTO",
|
||||
doseTaken: null,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
|
||||
const res = await GET(
|
||||
getReq(`?from=${encodeURIComponent(from)}`),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
const json = await res.json();
|
||||
const rows = json.data.rows as Array<{
|
||||
status: string;
|
||||
intake: { id: string | null } | null;
|
||||
}>;
|
||||
// `to` reports how far recorded doses were read: up to the early
|
||||
// dose's slot, past the request's own now.
|
||||
expect(json.data.to).toBe(slot.toISOString());
|
||||
const early = rows.find((r) => r.intake?.id === "evt-early");
|
||||
expect(early?.status).toBe("taken_on_time");
|
||||
// The read horizon stretches only to the recorded anchor: no later
|
||||
// slot is minted as upcoming.
|
||||
expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -142,12 +142,18 @@ export const GET = apiHandler(
|
||||
const requestedFrom =
|
||||
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
|
||||
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
|
||||
// The window the caller asked for, capped only by the span limit. This is
|
||||
// the floor for recorded doses: a dose logged with a time before the
|
||||
// medication was added (created at 16:20, "taken at 14:00") is a real
|
||||
// record and belongs in the window, exactly as the unwindowed full
|
||||
// history shows it (#1028).
|
||||
const intakeFrom = new Date(
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
// The floor for minted slots additionally stops at the medication's
|
||||
// creation, so pre-existence days never mint phantom missed slots.
|
||||
const from = new Date(
|
||||
Math.max(
|
||||
requestedFrom.getTime(),
|
||||
medication.createdAt.getTime(),
|
||||
spanFloor.getTime(),
|
||||
),
|
||||
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
|
||||
);
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
@@ -182,6 +188,23 @@ export const GET = apiHandler(
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
|
||||
// A dose may be recorded before its slot's own time: the dose window opens
|
||||
// up to two hours early, and a skip can be logged ahead. Such a row is
|
||||
// stored on the slot's anchor, which still lies after `to`, so a window
|
||||
// that ends at `to` dropped a dose the user had just recorded until the
|
||||
// slot's clock time passed (#1028). Extend the read horizon to the latest
|
||||
// anchor of a recorded row (taken or skipped) within a day of `to`, and no
|
||||
// further: pending reminder rows record nothing and never stretch it.
|
||||
const horizon = new Date(
|
||||
mapped.reduce((latest, e) => {
|
||||
const t = e.scheduledFor.getTime();
|
||||
const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to);
|
||||
return recorded && t > latest && t <= to.getTime() + DAY_MS
|
||||
? t
|
||||
: latest;
|
||||
}, to.getTime()),
|
||||
);
|
||||
|
||||
const bandMedication: BandMinterMedication = {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
@@ -223,7 +246,7 @@ export const GET = apiHandler(
|
||||
revisions: medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from, to },
|
||||
range: { from, to: horizon },
|
||||
now,
|
||||
intakeInstants,
|
||||
});
|
||||
@@ -241,7 +264,7 @@ export const GET = apiHandler(
|
||||
// partition, so filter on the stored anchor (a take snapped to an
|
||||
// out-of-window slot is excluded, matching the compliance read).
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
|
||||
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
scheduledFor: e.scheduledFor,
|
||||
@@ -290,9 +313,13 @@ export const GET = apiHandler(
|
||||
meta: { row_count: serialized.length, family },
|
||||
});
|
||||
|
||||
// The window the recorded doses were read over (#1028): from the
|
||||
// requested start (slots additionally start at the medication's
|
||||
// creation) to the read horizon, which reaches past `to` only for a dose
|
||||
// recorded ahead of its slot.
|
||||
return apiSuccess({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
from: intakeFrom.toISOString(),
|
||||
to: horizon.toISOString(),
|
||||
family,
|
||||
hasExpectedSlots: bands.length > 0,
|
||||
rows: serialized,
|
||||
|
||||
@@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
AssistantDisabledError: class extends Error {},
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
assertAiEgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// v1.12.1 — free-text medication extraction egresses PHI to the operator's
|
||||
// server-managed key, so it now passes through the consent gate. Mock it to a
|
||||
// no-op here; the gate's own fail-closed behaviour is covered by
|
||||
// `consent-guard.test.ts`.
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider-runner", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/ai/provider-runner")
|
||||
@@ -93,7 +87,9 @@ import {
|
||||
runRawCompletionWithFallback,
|
||||
AllProvidersFailedError,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/extract", {
|
||||
@@ -110,6 +106,12 @@ const AUTH_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
@@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => {
|
||||
doseUnit: "mg",
|
||||
cadenceKind: "everyNWeeks",
|
||||
});
|
||||
// The PHI free-text egress is gated on an active consent receipt for the
|
||||
// coach surface before the provider chain runs.
|
||||
expect(assertConsentForChain).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ surface: "coach" }),
|
||||
// The PHI free-text egress is re-checked against the capability for the
|
||||
// whole chain the runner may cascade through, before it runs.
|
||||
expect(assertAiEgress).toHaveBeenCalledWith(
|
||||
"medicationExtract",
|
||||
"user-1",
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/budget/i);
|
||||
// The code a client branches on, the same string the body always carried.
|
||||
expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" });
|
||||
// The refusal happens BEFORE any upstream spend.
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
|
||||
type: "none",
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
|
||||
expect(res.status).toBe(503);
|
||||
// The status stays 503 (a shipped client may branch on it) and gains the
|
||||
// code that says what it means.
|
||||
const error = await POST(
|
||||
postReq({ text: "Mounjaro 5mg weekly" }) as never,
|
||||
).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.status).toBe(503);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
capability: "medicationExtract",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 502 when the provider returns unparseable JSON", async () => {
|
||||
@@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/extract — the medicationExtract capability", () => {
|
||||
it("answers under medicationExtract, keeping 503 for no provider", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", {
|
||||
noProvider: { errorCode: "ai.provider.none", status: 503 },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses before the body is read when the operator turned it off", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("medicationExtract", "operator_disabled"),
|
||||
);
|
||||
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
|
||||
(e) => e,
|
||||
);
|
||||
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at the wire without reserving budget or calling a provider", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
vi.mocked(assertAiEgress).mockRejectedValueOnce(
|
||||
new AiUnavailableError("medicationExtract", "consent_required"),
|
||||
);
|
||||
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
|
||||
(e) => e,
|
||||
);
|
||||
expect(error.meta.errorCode).toBe("consent.ai.required");
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,16 @@
|
||||
* Behaviour:
|
||||
*
|
||||
* 1. requireAuth() — cookie OR Bearer.
|
||||
* 2. requireAssistantSurface("coach") — operator can disable.
|
||||
* 2. requireAiCapability("medicationExtract") — the operator's "Reading
|
||||
* documents" switch (and the master), provider-work authority for the
|
||||
* record, a configured provider (503 `ai.provider.none` otherwise; the
|
||||
* status predates the envelope and stays until no shipped client reads
|
||||
* it), and a document-class consent receipt: free-text medication input
|
||||
* is health data, and any provider that leaves the machine needs one.
|
||||
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
|
||||
* 4. resolveProviderChain() — fall back through providers.
|
||||
* 4. resolveProviderChain() — fall back through providers,
|
||||
* then `assertAiEgress` re-checks the capability for exactly that chain
|
||||
* immediately before anything is sent.
|
||||
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
|
||||
* capped against the COST OWNER: a self-hoster on their own key or a
|
||||
* local model is measured against the user-plan ceiling, not the
|
||||
@@ -56,7 +63,13 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
AiUnavailableError,
|
||||
type NoProviderRefusal,
|
||||
} from "@/lib/ai/capabilities/refusal";
|
||||
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
@@ -64,7 +77,6 @@ import {
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -80,6 +92,19 @@ import {
|
||||
type MedicationExtractionResult,
|
||||
} from "@/lib/ai/coach/medication-extract-prompt";
|
||||
|
||||
/**
|
||||
* How the extractor refuses with no provider. 503 predates the capability
|
||||
* envelope; it stays until no shipped client is known to branch on it, and the
|
||||
* code says what it means either way.
|
||||
*/
|
||||
const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = {
|
||||
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
|
||||
status: 503,
|
||||
};
|
||||
|
||||
/** The budget refusal's code; the same string the body always carried. */
|
||||
const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded";
|
||||
|
||||
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
@@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null {
|
||||
|
||||
async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// Same operator-disable gate the Coach SSE route honours — when
|
||||
// "coach" is off, the overlay also disappears so the wizard simply
|
||||
// falls back to the manual path.
|
||||
await requireAssistantSurface("coach");
|
||||
// Medication text extraction is its own capability under the operator's
|
||||
// "Reading documents" switch. When it is closed the wizard keeps its
|
||||
// manual path; nothing here is data the wizard needs.
|
||||
await requireAiCapability("medicationExtract", {
|
||||
noProvider: MEDICATION_EXTRACT_NO_PROVIDER,
|
||||
});
|
||||
|
||||
const userId = auth.user.id;
|
||||
|
||||
@@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") {
|
||||
annotate({ action: { name: "medications.extract.no-provider" } });
|
||||
return apiError("No AI provider configured", 503);
|
||||
throw new AiUnavailableError(
|
||||
"medicationExtract",
|
||||
"no_provider",
|
||||
null,
|
||||
MEDICATION_EXTRACT_NO_PROVIDER,
|
||||
);
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
// Free-text medication input is PHI. If the resolved chain could egress
|
||||
// via the operator's server-managed key, an active consent receipt is
|
||||
// required first — same gate as the Coach (which shares this chain).
|
||||
await assertConsentForChain({ userId, chain, surface: "coach" });
|
||||
// The wire. The runner may cascade through every entry, so each one is
|
||||
// named: any entry that leaves the machine needs a document-class consent
|
||||
// receipt, and a switch turned off since the gate above answered stops the
|
||||
// call here.
|
||||
await assertAiEgress(
|
||||
"medicationExtract",
|
||||
userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
|
||||
const today = todayOverride ?? buildDateKey();
|
||||
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
|
||||
@@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
action: { name: "medications.extract.budget-exceeded" },
|
||||
meta: { totalAfter: reservation.totalAfter },
|
||||
});
|
||||
return apiError("coach.budget.exceeded", 429);
|
||||
return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, {
|
||||
errorCode: MEDICATION_EXTRACT_BUDGET_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
let completion;
|
||||
|
||||
Reference in new issue
Block a user