From 4449b34ac7234be5ae982b7bf4e7284af37c4ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:05:49 +0200 Subject: [PATCH] feat(documents): gate document, lab and medication reading on their capabilities Reading a document, scanning a lab report and turning a medication description into a schedule follow documentAi, labsOcr and medicationExtract. Unavailable, the action is not offered. With only the document-reading consent missing, the place that reads asks for it and grants exactly that (ai_extraction), nothing wider. Refusals are read by meta.errorCode: a 403 is no longer always called a missing consent, and a spent daily budget no longer shows the rate-limit sentence. --- src/app/labs/page.tsx | 7 ++ .../ai/document-reading-consent-prompt.tsx | 55 +++++++++++++++ .../__tests__/document-ai-error-key.test.ts | 43 ++++++++++++ .../documents/document-detail-sheet.tsx | 26 +++++-- .../documents/use-document-assist.ts | 13 +++- .../__tests__/lab-biomarker-detail.test.tsx | 13 ++-- .../__tests__/ocr-capability-gate.test.ts | 35 +++++++++- .../labs/__tests__/ocr-review-dialog.test.tsx | 67 +++++++++++++++++- src/components/labs/ocr-review-dialog.tsx | 38 ++++++++-- src/components/labs/use-ocr-extract.ts | 23 +++++- .../natural-language-extractor-errors.test.ts | 45 ++++++++++++ .../scheduling/natural-language-extractor.tsx | 70 ++++++++++++++----- .../wizard/medication-wizard-dialog.tsx | 14 +++- .../use-grant-document-reading-consent.ts | 47 +++++++++++++ src/lib/query-keys/settings.ts | 10 ++- 15 files changed, 466 insertions(+), 40 deletions(-) create mode 100644 src/components/ai/document-reading-consent-prompt.tsx create mode 100644 src/components/documents/__tests__/document-ai-error-key.test.ts create mode 100644 src/components/medications/scheduling/__tests__/natural-language-extractor-errors.test.ts create mode 100644 src/hooks/use-grant-document-reading-consent.ts diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 65824629e..1899669fd 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -16,6 +16,7 @@ import { import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; +import { useAiCapability } from "@/hooks/use-ai-capability"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; import { shouldProbeOcrCapability, @@ -64,6 +65,10 @@ export default function LabsPage() { // explicit `false`. Every `/api/labs/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. const enabled = user?.modules?.labs !== false; + // Scanning a lab report follows the `labsOcr` capability on + // `/api/auth/me`; with only the document-reading consent missing, the scan + // stays offered and the dialog asks for it. + const labsOcr = useAiCapability("labsOcr"); const ocrCapability = useOcrCapability( shouldProbeOcrCapability({ isAuthenticated, @@ -71,6 +76,7 @@ export default function LabsPage() { labsEnabled: enabled, mounted, ownRecord: !inSharedRecord, + labsOcr, }), ); @@ -216,6 +222,7 @@ export default function LabsPage() { onOpenChange={setScanOpen} mode={ocrCapability.data.mode} pdfSupported={ocrCapability.data.pdfSupported} + consentRequired={labsOcr.reason === "consent_required"} onCommitted={() => { queryClient.invalidateQueries({ queryKey: queryKeys.labResults(), diff --git a/src/components/ai/document-reading-consent-prompt.tsx b/src/components/ai/document-reading-consent-prompt.tsx new file mode 100644 index 000000000..5cca04d21 --- /dev/null +++ b/src/components/ai/document-reading-consent-prompt.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useGrantDocumentReadingConsent } from "@/hooks/use-grant-document-reading-consent"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The consent question, asked where a document is about to be read. + * + * Shown in place of a document-reading action while its capability reports + * `consent_required`: the chain would send the document to a service outside + * this server and no receipt covers that yet. One sentence says what would + * happen, one button grants exactly that (`ai_extraction`), and the action + * appears once `/api/auth/me` confirms it. Withdrawing stays in Settings → AI. + */ +export function DocumentReadingConsentPrompt({ + className, +}: { + className?: string; +}) { + const { t } = useTranslations(); + const grant = useGrantDocumentReadingConsent(); + return ( +
+

+

+ + {grant.isError ? ( +

+ {t("documents.readingConsent.error")} +

+ ) : null} +
+ ); +} diff --git a/src/components/documents/__tests__/document-ai-error-key.test.ts b/src/components/documents/__tests__/document-ai-error-key.test.ts new file mode 100644 index 000000000..190dbd7ae --- /dev/null +++ b/src/components/documents/__tests__/document-ai-error-key.test.ts @@ -0,0 +1,43 @@ +/** + * A refused document read names its reason. Before the capability refusals + * had codes of their own, a missing consent, the operator's switch and a + * missing provider all fell through to the generic "couldn't read" line, and + * the one thing the reader could fix (the consent) was never said. + */ +import { describe, expect, it } from "vitest"; + +import { ApiError } from "@/lib/api/api-fetch"; + +import { documentAiErrorKey } from "../use-document-assist"; + +const refusal = (errorCode: string, status = 403) => + new ApiError("refused", status, { errorCode }); + +describe("documentAiErrorKey", () => { + it("maps each capability refusal to its own sentence", () => { + expect(documentAiErrorKey(refusal("consent.ai.required"))).toBe( + "documents.assist.errorConsent", + ); + expect(documentAiErrorKey(refusal("assistant.disabled.documentAi"))).toBe( + "documents.assist.errorUnavailable", + ); + expect(documentAiErrorKey(refusal("assistant.disabled.enabled"))).toBe( + "documents.assist.errorUnavailable", + ); + expect(documentAiErrorKey(refusal("ai.record.notPermitted"))).toBe( + "documents.assist.errorUnavailable", + ); + expect(documentAiErrorKey(refusal("ai.provider.none", 422))).toBe( + "documents.assist.errorProvider", + ); + }); + + it("keeps the existing route codes", () => { + expect( + documentAiErrorKey(refusal("documents.inbound.budgetExceeded", 429)), + ).toBe("documents.assist.errorBudget"); + expect(documentAiErrorKey(refusal("something.else", 500))).toBe( + "documents.assist.errorGeneric", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 1ee941f67..c1b216982 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -62,6 +62,8 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useAiCapability } from "@/hooks/use-ai-capability"; +import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { @@ -389,8 +391,15 @@ export function DocumentDetailSheet({ // not, and both of these routes stay on the caller's own authentication. A // delegate would fire two queries that must 403 in order to compute an // affordance every branch below already withholds on `canManageDocuments`. + // Reading a document with AI follows the `documentAi` capability on + // `/api/auth/me` (the operator's "Reading documents" switch, the provider, + // the document-reading consent). Unavailable, the actions are simply not + // offered and the probe never fires; a missing consent is asked for in + // place instead. + const documentAi = useAiCapability("documentAi"); + const coach = useAiCapability("coach"); const capability = useDocumentAiCapability( - canManageDocuments && open && documentId !== null, + canManageDocuments && open && documentId !== null && documentAi.available, ); // When auto-read is ON, reading happens automatically on upload — the manual // per-document AI action row is redundant and collapses away. @@ -558,9 +567,12 @@ export function DocumentDetailSheet({ // The affordance gate falls back to the capability probe when the sheet is // rendered without the usage-derived props (deep link before usage loads). - const aiEnabled = assistAvailable ?? capability.data?.available ?? false; + const aiEnabled = + documentAi.available && + (assistAvailable ?? capability.data?.available ?? false); const indexEnabled = - contentIndexEnabled ?? capability.data?.available ?? false; + documentAi.available && + (contentIndexEnabled ?? capability.data?.available ?? false); const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false; const aiMode = capability.data?.mode === "text" ? "text" : "vision"; const aiTarget: DocumentAiTarget | null = doc @@ -742,7 +754,7 @@ export function DocumentDetailSheet({ inside somebody else's record (the shell mounts no drawer there), and `/documents` is a shared-record destination, so this is the Coach button a delegate actually meets. */} - {aiEnabled && launch ? ( + {aiEnabled && coach.available && launch ? ( // v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL // coach conversation scoped to this document in the SIDE // DRAWER (the maximize control there expands it to the full @@ -856,7 +868,11 @@ export function DocumentDetailSheet({ onGenerate={generateStoredSummary} /> - {canManageDocuments && ( + {canManageDocuments && documentAi.reason === "consent_required" ? ( + + ) : null} + + {canManageDocuments && documentAi.available && ( ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); vi.mock("@/lib/api/api-fetch", () => ({ apiGet: () => new Promise(() => {}), apiDelete: vi.fn(), @@ -31,10 +40,6 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), })); -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ insightStatus: true }), -})); - import { LabBiomarkerDetail } from "../lab-biomarker-detail"; const BIOMARKER_ID = "bm-1"; diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts index 6d94d478f..2bf8e62e1 100644 --- a/src/components/labs/__tests__/ocr-capability-gate.test.ts +++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts @@ -10,7 +10,8 @@ describe("shouldProbeOcrCapability", () => { labsEnabled: true, mounted: true, ownRecord: true, - }; + labsOcr: { available: true, reason: null, onDeviceAllowed: true }, + } as const; expect(shouldProbeOcrCapability(ownerControl)).toBe(true); expect( @@ -26,4 +27,36 @@ describe("shouldProbeOcrCapability", () => { false, ); }); + + it("follows the labsOcr capability: probes when available or when only consent is missing", () => { + const base = { + isAuthenticated: true, + isLoading: false, + labsEnabled: true, + mounted: true, + ownRecord: true, + }; + const state = (reason: string | null) => + ({ + available: reason === null, + reason, + onDeviceAllowed: false, + }) as Parameters[0]["labsOcr"]; + + // Consent is asked for inside the scan dialog, so the scan stays offered. + expect( + shouldProbeOcrCapability({ ...base, labsOcr: state("consent_required") }), + ).toBe(true); + for (const reason of [ + "operator_disabled", + "no_provider", + "module_disabled", + "not_permitted_for_record", + "check_failed", + ]) { + expect( + shouldProbeOcrCapability({ ...base, labsOcr: state(reason) }), + ).toBe(false); + } + }); }); diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 29a3f5337..1ef88b1c9 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -5,7 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; -import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog"; +import { ApiError } from "@/lib/api/api-fetch"; + +import { + extractErrorMessage, + handleFilePickerChange, + OcrReviewDialog, +} from "../ocr-review-dialog"; const hookState = vi.hoisted(() => ({ extractPending: false })); @@ -44,13 +50,25 @@ vi.mock("../use-ocr-extract", () => ({ }), })); +vi.mock("@/hooks/use-grant-document-reading-consent", () => ({ + useGrantDocumentReadingConsent: () => ({ + isPending: false, + isError: false, + mutate: vi.fn(), + }), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); function render( locale: "en" | "de" = "en", - options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {}, + options: { + mode?: "vision" | "text"; + pdfSupported?: boolean; + consentRequired?: boolean; + } = {}, ): string { return renderToStaticMarkup( @@ -59,6 +77,7 @@ function render( onOpenChange={() => {}} mode={options.mode ?? "vision"} pdfSupported={options.pdfSupported ?? true} + consentRequired={options.consentRequired} onCommitted={() => {}} /> , @@ -138,3 +157,47 @@ describe(" file picker", () => { expect(input.value).toBe(""); }); }); + +describe(" document-reading consent", () => { + it("asks for the reading consent in place of the picker while it is missing", () => { + const html = render("en", { consentRequired: true }); + expect(html).toContain('data-slot="document-reading-consent"'); + expect(html).not.toMatch(/]*type="file"/); + }); + + it("shows the picker once consent is in place", () => { + const html = render("en", { consentRequired: false }); + expect(html).not.toContain('data-slot="document-reading-consent"'); + expect(html).toMatch(/]*type="file"/); + }); +}); + +describe("extractErrorMessage", () => { + const t = (key: string) => key; + const refusal = (errorCode: string, status = 403) => + new ApiError("refused", status, { errorCode }); + + it("reads the refusal by its code, not by the status", () => { + expect(extractErrorMessage(refusal("consent.ai.required"), t)).toBe( + "labs.ocr.consentRequired", + ); + expect( + extractErrorMessage(refusal("assistant.disabled.documentAi"), t), + ).toBe("labs.ocr.aiUnavailable"); + expect(extractErrorMessage(refusal("ai.record.notPermitted"), t)).toBe( + "labs.ocr.aiUnavailable", + ); + expect(extractErrorMessage(refusal("module.disabled"), t)).toBe( + "labs.ocr.aiUnavailable", + ); + expect(extractErrorMessage(refusal("ai.provider.none", 422), t)).toBe( + "labs.ocr.providerUnsupported", + ); + }); + + it("never calls an unrelated 403 a missing consent", () => { + expect(extractErrorMessage(new ApiError("forbidden", 403), t)).toBe( + "labs.ocr.extractFailed", + ); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index e64c33577..b17162702 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -9,6 +9,7 @@ * confirms. Reuses the labs design language via `ResponsiveSheet` + the labs * row editor. */ +import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt"; import { useId, useMemo, useState } from "react"; import { Loader2, ScanLine, Upload } from "lucide-react"; @@ -81,8 +82,15 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { }; } -/** Translate an extract failure into a friendly, error-code-aware message. */ -function extractErrorMessage(err: unknown, t: (key: string) => string): string { +/** + * Translate an extract failure into a friendly, error-code-aware message. + * Refusals are read by `meta.errorCode`: a 403 is a missing consent only when + * the code says so, never by its status alone. + */ +export function extractErrorMessage( + err: unknown, + t: (key: string) => string, +): string { if (err instanceof ApiError) { const code = typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null; @@ -99,10 +107,23 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string { return t("labs.ocr.fileType"); case "labs.ocr.pdfNeedsAnthropic": return t("labs.ocr.pdfNeedsAnthropic"); + case "consent.ai.required": + return t("labs.ocr.consentRequired"); + case "ai.provider.none": + return t("labs.ocr.providerUnsupported"); default: break; } - if (err.status === 403) return t("labs.ocr.consentRequired"); + // The operator's switch, the module, somebody else's record: scanning is + // not available here right now, whatever the exact layer. + if ( + code !== null && + (code.startsWith("assistant.disabled.") || + code === "ai.record.notPermitted" || + code === "module.disabled") + ) { + return t("labs.ocr.aiUnavailable"); + } } return t("labs.ocr.extractFailed"); } @@ -112,6 +133,7 @@ export function OcrReviewDialog({ onOpenChange, mode, pdfSupported, + consentRequired = false, onCommitted, }: { open: boolean; @@ -119,6 +141,12 @@ export function OcrReviewDialog({ /** Native vision vs in-browser local OCR. Text mode is image-only. */ mode: OcrMode; pdfSupported: boolean; + /** + * The `labsOcr` capability is missing only the document-reading consent. + * The pick step asks for it instead of offering the file picker; once it is + * granted `/api/auth/me` refreshes and the picker appears. + */ + consentRequired?: boolean; onCommitted: () => void; }) { const { t } = useTranslations(); @@ -270,7 +298,9 @@ export function OcrReviewDialog({ ) : undefined } > - {stage === "pick" ? ( + {stage === "pick" && consentRequired ? ( + + ) : stage === "pick" ? (
({ + data: null, + error, + ...(errorCode ? { meta: { errorCode } } : {}), +}); + +describe("classifyExtractFailure", () => { + it("reads the code before the status", () => { + expect( + classifyExtractFailure(429, envelope("coach.budget.exceeded")).kind, + ).toBe("budget"); + expect(classifyExtractFailure(429, envelope()).kind).toBe("rateLimit"); + }); + + it("names a missing provider, a missing consent and a switched-off reader", () => { + expect(classifyExtractFailure(503, envelope("ai.provider.none")).kind).toBe( + "noProvider", + ); + expect( + classifyExtractFailure(403, envelope("consent.ai.required")).kind, + ).toBe("consent"); + expect( + classifyExtractFailure(403, envelope("assistant.disabled.documentAi")) + .kind, + ).toBe("unavailable"); + expect( + classifyExtractFailure(403, envelope("ai.record.notPermitted")).kind, + ).toBe("unavailable"); + }); + + it("keeps a bare 503 as no provider and anything else as a network failure", () => { + expect(classifyExtractFailure(503, null).kind).toBe("noProvider"); + expect(classifyExtractFailure(500, envelope()).kind).toBe("network"); + }); +}); diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx index 8aad94571..0824067a5 100644 --- a/src/components/medications/scheduling/natural-language-extractor.tsx +++ b/src/components/medications/scheduling/natural-language-extractor.tsx @@ -46,6 +46,7 @@ import { Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useTranslations } from "@/lib/i18n/context"; @@ -77,6 +78,12 @@ export interface NaturalLanguageExtractorProps { * point at a mock server. Defaults to the production route. */ endpoint?: string; + /** + * The `medicationExtract` capability is missing only the document-reading + * consent. The sheet asks for it above the text field and holds the submit + * until `/api/auth/me` reports it granted. + */ + consentRequired?: boolean; } type DialogError = @@ -85,8 +92,43 @@ type DialogError = | { kind: "rateLimit" } | { kind: "noProvider" } | { kind: "budget" } + | { kind: "consent" } + | { kind: "unavailable" } | { kind: "network"; message?: string }; +interface ExtractFailureBody { + error?: string | null; + meta?: { errorCode?: unknown } | null; +} + +/** + * Classify a failed extract response. The refusal's `meta.errorCode` decides + * first, because a status is shared by different reasons: a 429 is either + * the per-minute limit or the spent daily budget, and a 403 is a missing + * consent, the operator's switch or somebody else's record. + */ +export function classifyExtractFailure( + status: number, + body: ExtractFailureBody | null, +): DialogError { + const code = + typeof body?.meta?.errorCode === "string" ? body.meta.errorCode : null; + if (code === "coach.budget.exceeded") return { kind: "budget" }; + if (code === "ai.provider.none") return { kind: "noProvider" }; + if (code === "consent.ai.required") return { kind: "consent" }; + if ( + code !== null && + (code.startsWith("assistant.disabled.") || + code === "ai.record.notPermitted" || + code === "module.disabled") + ) { + return { kind: "unavailable" }; + } + if (status === 429) return { kind: "rateLimit" }; + if (status === 503) return { kind: "noProvider" }; + return { kind: "network", message: body?.error ?? undefined }; +} + const MAX_TEXT_LENGTH = 2000; export function NaturalLanguageExtractor({ @@ -95,6 +137,7 @@ export function NaturalLanguageExtractor({ onPrefill, locale, endpoint = "/api/medications/extract", + consentRequired = false, }: NaturalLanguageExtractorProps) { const { t } = useTranslations(); const textareaId = useId(); @@ -143,23 +186,11 @@ export function NaturalLanguageExtractor({ signal: controller.signal, }); - if (res.status === 429) { - setError({ kind: "rateLimit" }); - return; - } - if (res.status === 503) { - setError({ kind: "noProvider" }); - return; - } if (!res.ok) { - const body = (await res.json().catch(() => null)) as { - error?: string; - } | null; - if (body?.error && /budget/i.test(body.error)) { - setError({ kind: "budget" }); - return; - } - setError({ kind: "network", message: body?.error }); + const body = (await res + .json() + .catch(() => null)) as ExtractFailureBody | null; + setError(classifyExtractFailure(res.status, body)); return; } @@ -228,7 +259,7 @@ export function NaturalLanguageExtractor({