From 422778f679732bec0689db5da48045d95dfe81b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:57:16 +0200 Subject: [PATCH] feat(documents): module gate, nav, and review-then-confirm UI Add the opt-in inboundDocuments module (off by default, like the MCP endpoint): ingesting a clinical document egresses it to the OCR/vision provider, so the surface ships dark and the user turns it on from the Modules hub. The born-gated /documents page carries the prominent non-diagnostic disclaimer (we read, we don't interpret; nothing is saved until you approve it; only your clinician can interpret these results), uploads a document, and walks each extracted fact: source provenance and confidence are shown, low-confidence facts are blocked from approval until edited, and the confirm step commits the approved facts. --- src/app/documents/page.tsx | 45 ++ .../documents/inbound-documents-view.tsx | 577 ++++++++++++++++++ src/components/layout/nav-model.ts | 12 + src/components/settings/modules-section.tsx | 3 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/modules/__tests__/gate.test.ts | 42 +- src/lib/modules/registry.ts | 16 + src/lib/query-keys/documents.ts | 12 + src/lib/query-keys/index.ts | 2 + .../doctor-report-sections.test.ts | 1 + 10 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 src/app/documents/page.tsx create mode 100644 src/components/documents/inbound-documents-view.tsx create mode 100644 src/lib/query-keys/documents.ts diff --git a/src/app/documents/page.tsx b/src/app/documents/page.tsx new file mode 100644 index 000000000..553c73770 --- /dev/null +++ b/src/app/documents/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { InboundDocumentsView } from "@/components/documents/inbound-documents-view"; + +/** + * v1.25.0 (W-DOCS-IN) — inbound clinical documents entry. + * + * Born-gated on the resolved `modules.inboundDocuments` flag from + * `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the + * operator server-wide kill-switch). An unauthenticated visitor is bounced to + * login; an authenticated account without the module opted in is bounced home + * (the nav entry is already hidden for them, so this only catches a direct URL + * hit). Every `/api/documents/inbound/*` route also enforces the gate + * server-side, so this is a UX redirect, not the security boundary. + */ +export default function DocumentsPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.inboundDocuments === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx new file mode 100644 index 000000000..b95ec9a62 --- /dev/null +++ b/src/components/documents/inbound-documents-view.tsx @@ -0,0 +1,577 @@ +"use client"; + +/** + * v1.25.0 (W-DOCS-IN) — inbound clinical documents review-then-confirm UI. + * + * Upload a doctor report / discharge letter, then review the STRUCTURED FACTS + * the OCR/vision provider transcribed before any of them reaches the + * structured stores. The screen reproduces what the document stated — it never + * interprets. A low-confidence fact fails closed: it cannot be approved until + * the user edits it. The non-diagnostic disclaimer is shown prominently. + */ +import { useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { AlertTriangle, FileScan, Loader2, Upload } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Separator } from "@/components/ui/separator"; +import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + INBOUND_DOCUMENT_KINDS, + type ConditionFactData, + type ExtractedFactDto, + type InboundDocumentDetailDto, + type InboundDocumentDto, + type InboundDocumentKindValue, + type MedicationStatementFactData, + type ObservationFactData, +} from "@/lib/validations/inbound-documents"; + +type ListResponse = { documents: InboundDocumentDto[] }; +type Decision = "approve" | "reject"; + +export function InboundDocumentsView() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const fileRef = useRef(null); + const [kind, setKind] = useState("DOCTOR_REPORT"); + const [selectedId, setSelectedId] = useState(null); + + const list = useQuery({ + queryKey: queryKeys.inboundDocuments(), + queryFn: () => + apiFetch("/api/documents/inbound", { method: "GET" }), + }); + + const upload = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData(); + fd.append("file", file); + fd.append("kind", kind); + return apiFetch("/api/documents/inbound", { + method: "POST", + body: fd, + signal: null, + }); + }, + onSuccess: (doc) => { + toast.success(t("documents.toast.uploaded")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + setSelectedId(doc.id); + if (fileRef.current) fileRef.current.value = ""; + }, + onError: () => toast.error(t("documents.toast.uploadFailed")), + }); + + return ( +
+
+ +
+

{t("documents.title")}

+

+ {t("documents.subtitle")} +

+
+
+ + {/* Non-diagnostic disclaimer — most prominent, always visible. */} + + + +
+ + {t("documents.disclaimer.title")} + + + {t("documents.disclaimer.body")} + +
+
+
+ + {/* Upload */} + + + + {t("documents.upload.label")} + + + +
+ + + setKind(e.target.value as InboundDocumentKindValue) + } + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+ + +
+
+ + {/* Document list */} +
+ {list.isLoading ? ( +
+ +
+ ) : !list.data || list.data.documents.length === 0 ? ( +

+ {t("documents.list.empty")} +

+ ) : ( + list.data.documents.map((doc) => ( + + )) + )} +
+ + {selectedId ? ( + setSelectedId(null)} + /> + ) : null} +
+ ); +} + +function DocumentReview({ + documentId, + onClosed, +}: { + documentId: string; + onClosed: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [decisions, setDecisions] = useState>({}); + + const detail = useQuery({ + queryKey: queryKeys.inboundDocument(documentId), + queryFn: () => + apiFetch( + `/api/documents/inbound/${documentId}`, + { method: "GET" }, + ), + }); + + const confirm = useMutation({ + mutationFn: () => + apiPost(`/api/documents/inbound/${documentId}/confirm`, { + decisions: Object.entries(decisions).map(([factId, action]) => ({ + factId, + action, + })), + }), + onSuccess: () => { + toast.success(t("documents.toast.confirmed")); + setDecisions({}); + void invalidateKeys(queryClient, [queryKeys.documents()]); + }, + onError: () => toast.error(t("documents.toast.confirmFailed")), + }); + + const discard = useMutation({ + mutationFn: () => apiDelete(`/api/documents/inbound/${documentId}`), + onSuccess: () => { + toast.success(t("documents.toast.discarded")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + onClosed(); + }, + }); + + if (detail.isLoading) { + return ( +
+ +
+ ); + } + if (!detail.data) return null; + + const pending = detail.data.facts.filter((f) => f.status === "PENDING"); + const decidedCount = Object.keys(decisions).length; + + return ( + + + + {t("documents.review.title")} + + + + + {pending.length === 0 ? ( +

+ {t("documents.empty.noFacts")} +

+ ) : ( + pending.map((fact) => ( + + setDecisions((prev) => { + const next = { ...prev }; + if (d === null) delete next[fact.id]; + else next[fact.id] = d; + return next; + }) + } + /> + )) + )} + + {pending.length > 0 ? ( + <> + + + + ) : null} +
+
+ ); +} + +function FactCard({ + documentId, + fact, + decision, + onDecision, +}: { + documentId: string; + fact: ExtractedFactDto; + decision: Decision | undefined; + onDecision: (d: Decision | null) => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + + const summary = factSummary(fact); + + const save = useMutation({ + mutationFn: (payload: unknown) => + apiPatch( + `/api/documents/inbound/${documentId}/facts/${fact.id}`, + payload, + ), + onSuccess: () => { + toast.success(t("documents.toast.saved")); + setEditing(false); + void invalidateKeys(queryClient, [queryKeys.inboundDocument(documentId)]); + }, + }); + + return ( +
+
+
+
+ + {t(`documents.factType.${fact.factType}`)} + + {fact.needsReview ? ( + + {t("documents.review.needsReview")} + + ) : null} + + {Math.round(fact.confidence * 100)}% + +
+

{summary}

+ {fact.provenance.sourceText ? ( +

+ “{fact.provenance.sourceText}” +

+ ) : null} +
+
+ + +
+
+ +
+ +
+ + {editing ? ( + save.mutate(payload)} + /> + ) : null} +
+ ); +} + +function FactEditForm({ + fact, + pending, + onSubmit, +}: { + fact: ExtractedFactDto; + pending: boolean; + onSubmit: (payload: unknown) => void; +}) { + const { t } = useTranslations(); + + if (fact.factType === "CONDITION") { + const d = fact.data as ConditionFactData; + return ( + + onSubmit({ + factType: "CONDITION", + label: vals.label, + onsetDate: date, + }) + } + /> + ); + } + if (fact.factType === "OBSERVATION") { + const d = fact.data as ObservationFactData; + return ( + { + const numeric = Number(vals.value); + const isNum = vals.value.trim() !== "" && !Number.isNaN(numeric); + onSubmit({ + factType: "OBSERVATION", + label: vals.label, + value: isNum ? numeric : null, + valueText: isNum ? null : vals.value || null, + unit: vals.unit || null, + effectiveDate: date, + }); + }} + /> + ); + } + const d = fact.data as MedicationStatementFactData; + return ( + + onSubmit({ + factType: "MEDICATION_STATEMENT", + name: vals.name, + dose: vals.dose || null, + effectiveDate: date, + }) + } + /> + ); +} + +function EditFields({ + fields, + dateValue, + pending, + onSubmit, +}: { + fields: { key: string; label: string; value: string }[]; + dateValue: string | null; + pending: boolean; + onSubmit: (vals: Record, date: string | null) => void; +}) { + const { t } = useTranslations(); + const [vals, setVals] = useState>(() => + Object.fromEntries(fields.map((f) => [f.key, f.value])), + ); + const [date, setDate] = useState(dateValue ?? ""); + + return ( +
+ {fields.map((f) => ( +
+ + + setVals((p) => ({ ...p, [f.key]: e.target.value })) + } + /> +
+ ))} +
+ + setDate(e.target.value)} + /> +
+ +
+ ); +} + +/** A one-line transcription summary of a staged fact (no interpretation). */ +function factSummary(fact: ExtractedFactDto): string { + if (fact.factType === "OBSERVATION") { + const d = fact.data as ObservationFactData; + const v = + d.value !== null + ? `${d.value}${d.unit ? ` ${d.unit}` : ""}` + : d.valueText; + return v ? `${d.label}: ${v}` : d.label; + } + if (fact.factType === "MEDICATION_STATEMENT") { + const d = fact.data as MedicationStatementFactData; + return d.dose ? `${d.name} — ${d.dose}` : d.name; + } + const d = fact.data as ConditionFactData; + return d.label; +} diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 0a2444840..01d81ad3a 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -2,6 +2,7 @@ import { Activity, Bell, Droplets, + FileScan, FlaskConical, Home, Lightbulb, @@ -137,6 +138,17 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on // a workout row, `/insights/recovery` always present), so neither is a // top-level `NAV_DESTINATIONS` entry any more. + // v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical + // spine after Illness. Born-gated: `requiresModule: "inboundDocuments"` + // reads the opt-in key from the resolved module map, so the entry is absent + // until the account turns the module on from the Modules hub. + { + href: "/documents", + tKey: "nav.documents", + icon: FileScan, + tourId: "nav-documents", + requiresModule: "inboundDocuments", + }, { href: "/insights", tKey: "nav.insights", diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index f6c5a5f43..4eb744df3 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -31,6 +31,7 @@ import { CalendarHeart, Droplet, Dumbbell, + FileScan, FileText, MessageCircleHeart, Moon, @@ -74,6 +75,8 @@ const MODULE_ICONS: Record = { doctorReport: FileText, // v1.22.0 — the remote MCP endpoint (opt-in connectivity module). mcp: Plug, + // v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in). + inboundDocuments: FileScan, }; export function ModulesSection() { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index d1939cf09..0d911888c 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -84,6 +84,7 @@ function moduleMap( medications: true, doctorReport: true, mcp: true, + inboundDocuments: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 5b76f000c..2bdbc532f 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -352,15 +352,49 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => { }); describe("registry — opt-in marker", () => { - it("marks only the mcp module opt-in (every other module is default-on)", () => { - expect(isOptInModule("mcp")).toBe(true); + // The opt-in modules ship dark because they open an egress/attack surface: + // `mcp` (remote external-assistant endpoint) and `inboundDocuments` + // (sends an uploaded clinical document to the OCR/vision provider). Every + // other module is the default-on disabled-allowlist. + const OPT_IN_KEYS = new Set(["mcp", "inboundDocuments"]); + + it("marks only the egress-surface modules opt-in (every other is default-on)", () => { for (const key of MODULE_KEYS) { - if (key === "mcp") continue; - expect(isOptInModule(key)).toBe(false); + expect(isOptInModule(key)).toBe(OPT_IN_KEYS.has(key)); } }); }); +describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)", () => { + it("is OFF when no preference is recorded", () => { + expect( + resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE), + ).toBe(false); + }); + + it("turns ON only on an explicit true (opt-in)", () => { + expect( + resolveModuleEnabled( + "inboundDocuments", + inputs({ modulePreferences: { inboundDocuments: true } }), + false, + ALL_AVAILABLE, + ), + ).toBe(true); + }); + + it("operator-off short-circuits even when the user opted in", () => { + expect( + resolveModuleEnabled( + "inboundDocuments", + inputs({ modulePreferences: { inboundDocuments: true } }), + false, + operator({ inboundDocuments: false }), + ), + ).toBe(false); + }); +}); + describe("resolveOperatorAvailability — disabled allowlist (default-available)", () => { it("treats null / non-object / array as all-available", () => { for (const raw of [null, undefined, "nope", ["mood"]]) { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 0ae94722a..35dddaa67 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -123,6 +123,13 @@ export const MODULE_KEYS = [ // (operator-availability layer, PATCH plumbing, the Modules hub toggle) // reuses the existing module machinery unchanged. "mcp", + // v1.25.0 (W-DOCS-IN) — inbound clinical documents (`/documents/inbound`). + // Like `mcp` this is OPT-IN (off by default): ingesting a doctor report / + // discharge letter sends the document to the configured OCR/vision provider, + // so the surface ships dark and the user turns it on deliberately. The + // `optIn` marker on its registry entry inverts the per-user default; + // everything else reuses the existing module machinery unchanged. + "inboundDocuments", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -265,6 +272,15 @@ export const MODULE_REGISTRY: Readonly> = // must be turned on deliberately (ADR-007 / REQ-OPS-1). optIn: true, }, + inboundDocuments: { + key: "inboundDocuments", + labelKey: "modules.inboundDocuments.label", + descriptionKey: "modules.inboundDocuments.description", + category: "tracking", + // Off by default: ingesting a clinical document egresses it to the + // configured OCR/vision provider, so the user turns it on deliberately. + optIn: true, + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts new file mode 100644 index 000000000..46289f42e --- /dev/null +++ b/src/lib/query-keys/documents.ts @@ -0,0 +1,12 @@ +/** + * Query keys — inbound clinical documents (v1.25, W-DOCS-IN): the document + * list and a single document's review detail (document + staged facts). + * Part of the centralized factory; aggregated in `./index.ts`. Any inbound + * write (upload / edit / confirm / discard) invalidates the `["documents"]` + * prefix so the list and the open review repaint in lockstep. + */ +export const documentKeys = { + documents: () => ["documents"] as const, + inboundDocuments: () => ["documents", "inbound"] as const, + inboundDocument: (id: string) => ["documents", "inbound", id] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 8b40d9320..3df27afcb 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -21,6 +21,7 @@ import { coachKeys } from "./coach"; import { familyHistoryKeys } from "./family-history"; import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; +import { documentKeys } from "./documents"; import { illnessKeys } from "./illness"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; @@ -52,6 +53,7 @@ export const queryKeys = { ...mentalHealthKeys, ...allergyKeys, ...familyHistoryKeys, + ...documentKeys, }; /** diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 574237115..0dc17b600 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -318,6 +318,7 @@ describe("doctor-report — module enable/disable gating", () => { medications: true, doctorReport: true, mcp: true, + inboundDocuments: true, } as const; it("includes every module's data when all modules are enabled", async () => {