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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:05:49 +02:00
1 parent 48d97ef70d
commit 4449b34ac7
15 files changed
+466 -40

No files matched your search

+7
View File
@@ -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(),
@@ -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 (
<div
data-slot="document-reading-consent"
className={
className ??
"border-border flex flex-col items-start gap-2 rounded-lg border border-dashed px-3 py-2.5"
}
>
<p className="text-foreground flex items-start gap-2 text-sm">
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>{t("documents.readingConsent.body")}</span>
</p>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={grant.isPending}
onClick={() => grant.mutate()}
data-slot="document-reading-consent-grant"
>
{t("documents.readingConsent.grant")}
</Button>
{grant.isError ? (
<p role="alert" className="text-destructive text-xs">
{t("documents.readingConsent.error")}
</p>
) : null}
</div>
);
}
@@ -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",
);
});
});
@@ -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" ? (
<DocumentReadingConsentPrompt />
) : null}
{canManageDocuments && documentAi.available && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
@@ -200,8 +200,19 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorProvider";
case "documents.inbound.notIndexed":
return "documents.assist.errorNotIndexed";
case "consent.ai.required":
return "documents.assist.errorConsent";
case "ai.provider.none":
return "documents.assist.errorProvider";
case "ai.record.notPermitted":
case "module.disabled":
return "documents.assist.errorUnavailable";
default:
return "documents.assist.errorGeneric";
// The operator switched reading off (`assistant.disabled.documentAi`,
// or the master `assistant.disabled.enabled`).
return code.startsWith("assistant.disabled.")
? "documents.assist.errorUnavailable"
: "documents.assist.errorGeneric";
}
}
return "documents.assist.errorGeneric";
@@ -17,6 +17,15 @@ import type {
* the strip, the chart placeholder, and the assessment card all paint.
*/
// AI surfaces paint only while their capability is available; these tests
// cover the painted surface, so every capability reads available here.
vi.mock("@/hooks/use-ai-capability", () => ({
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";
@@ -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<typeof shouldProbeOcrCapability>[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);
}
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
@@ -59,6 +77,7 @@ function render(
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
@@ -138,3 +157,47 @@ describe("<OcrReviewDialog> file picker", () => {
expect(input.value).toBe("");
});
});
describe("<OcrReviewDialog> 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(/<input[^>]*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(/<input[^>]*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",
);
});
});
+34 -4
View File
@@ -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 ? (
<DocumentReadingConsentPrompt />
) : stage === "pick" ? (
<div className="relative py-2">
<input
id={pickerId}
+21 -2
View File
@@ -11,6 +11,7 @@
* - `useOcrCommit()` — writes the user-confirmed rows and invalidates the
* labs + biomarker query keys.
*/
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -41,13 +42,22 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
inserted: LabResultDto[];
}
/** The capability route resolves the caller's own provider configuration. */
/**
* The capability route resolves the caller's own provider configuration.
*
* It only runs while the `labsOcr` capability on `/api/auth/me` could let a
* scan happen: available, or missing nothing but the document-reading
* consent, which the scan dialog asks for in place. Any other reason (the
* operator's switch, no provider, the module, somebody else's record) means
* no scan is offered, so there is nothing to probe.
*/
export function shouldProbeOcrCapability({
isAuthenticated,
isLoading,
labsEnabled,
mounted,
ownRecord,
labsOcr,
}: {
isAuthenticated: boolean;
isLoading: boolean;
@@ -55,8 +65,17 @@ export function shouldProbeOcrCapability({
mounted: boolean;
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
ownRecord: boolean;
labsOcr: AiCapabilityState;
}): boolean {
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
const aiOffered = labsOcr.available || labsOcr.reason === "consent_required";
return (
isAuthenticated &&
!isLoading &&
labsEnabled &&
mounted &&
ownRecord &&
aiOffered
);
}
/** Capability probe — refetched when the scan dialog opens. */
@@ -0,0 +1,45 @@
/**
* The medication text reader says why it could not read, from the refusal's
* code. Before, a spent daily budget (429 `coach.budget.exceeded`) showed the
* rate-limit sentence, and a switched-off reader or a missing consent showed
* "check your connection".
*/
import { describe, expect, it } from "vitest";
import { classifyExtractFailure } from "../natural-language-extractor";
const envelope = (errorCode?: string, error = "refused") => ({
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");
});
});
@@ -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({
<Button
type="button"
onClick={handleSubmit}
disabled={busy || overLimit}
disabled={busy || overLimit || consentRequired}
aria-busy={busy || undefined}
className="min-h-11 sm:min-h-9"
>
@@ -248,6 +279,7 @@ export function NaturalLanguageExtractor({
}
>
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
{consentRequired ? <DocumentReadingConsentPrompt /> : null}
<div>
<Label htmlFor={textareaId} className="sr-only">
{t("medications.scheduling.naturalLanguage.title")}
@@ -348,6 +380,10 @@ function resolveErrorMessage(
return t("medications.scheduling.naturalLanguage.error.noProvider");
case "budget":
return t("medications.scheduling.naturalLanguage.error.budget");
case "consent":
return t("medications.scheduling.naturalLanguage.error.consent");
case "unavailable":
return t("medications.scheduling.naturalLanguage.error.unavailable");
case "network":
default:
return t("medications.scheduling.naturalLanguage.error.network");
@@ -30,6 +30,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { toast } from "sonner";
import {
Bell,
@@ -127,6 +128,14 @@ function WizardDialogShell({
navigateOnCreate = true,
}: MedicationWizardDialogProps) {
const { t, locale } = useTranslations();
// The "describe it in words" reader follows the `medicationExtract`
// capability: offered while available, and while only the document-reading
// consent is missing (the sheet asks for it). Otherwise the button is not
// there at all, and the manual steps are the whole wizard.
const medicationExtract = useAiCapability("medicationExtract");
const extractOffered =
medicationExtract.available ||
medicationExtract.reason === "consent_required";
const router = useRouter();
const queryClient = useQueryClient();
@@ -489,7 +498,7 @@ function WizardDialogShell({
lastLabel={t("medications.wizard.nav.jumpLast")}
srLabel={stepOf}
/>
{step === 1 && mode === "create" && (
{step === 1 && mode === "create" && extractOffered && (
<div className="flex justify-end">
<Button
type="button"
@@ -612,7 +621,7 @@ function WizardDialogShell({
{/* Natural-language extractor — same dialog the v1.5.3 page mounted.
Only available on the create path; edit flows already know
what the medication looks like. */}
{mode === "create" && (
{mode === "create" && extractOffered && (
<NaturalLanguageExtractor
open={nlOpen}
onClose={() => setNlOpen(false)}
@@ -620,6 +629,7 @@ function WizardDialogShell({
applyPartial(extractorToWizardPartial(partial));
}}
locale={locale}
consentRequired={medicationExtract.reason === "consent_required"}
/>
)}
</>
@@ -0,0 +1,47 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* Grant the consent for reading documents (`ai_extraction`) from the place
* that needs it.
*
* Sending a document, a lab report scan or a typed medication description to
* an AI service outside this server needs its own receipt. The narrow kind
* covers exactly that and never the Coach or AI analysis, so asking for it
* where a document is being read asks for no more than the reader is about to
* do. The tap on the prompt IS the consent act; the receipt records it with
* its source, the same way the web settings record a full grant.
*
* On success `/api/auth/me` is refetched, because the capabilities it
* publishes (`documentAi`, `labsOcr`, `medicationExtract`) are what every
* reading surface renders from.
*/
export function useGrantDocumentReadingConsent() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.aiExtractionConsentGrant(),
mutationFn: async () => {
const signedAt = new Date().toISOString();
return apiPost("/api/consent/ai", {
kind: "ai_extraction",
signedAt,
artefact: JSON.stringify({
source: "web",
kind: "ai_extraction",
grantedAt: signedAt,
note: "In-app affirmative consent to reading documents, granted via the web client.",
}),
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
void queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
});
},
});
}
+8 -2
View File
@@ -62,8 +62,6 @@ export const settingsKeys = {
/** v1.11.0 — owner's clinician share links (Settings → Sharing). */
shareLinks: () => ["share-links"] as const,
featureFlags: () => ["feature-flags"] as const,
/**
* The account's standing AI consent receipt (`GET /api/consent/ai/latest`).
* Keyed by kind because the endpoint answers per kind and a shared key
@@ -71,4 +69,12 @@ export const settingsKeys = {
*/
aiConsentReceipt: (kind: string) =>
["consent", "ai", "latest", kind] as const,
/**
* Mutation key for granting the document-reading consent (`ai_extraction`)
* from the place that asks for it: a document read, a lab scan, the
* medication text reader.
*/
aiExtractionConsentGrant: () =>
["consent", "ai", "grant", "ai_extraction"] as const,
};