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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:57:16 +02:00
1 parent 98ca0b9d61
commit 422778f679
10 files changed
+707 -4

No files matched your search

+45
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <InboundDocumentsView />;
}
@@ -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<HTMLInputElement>(null);
const [kind, setKind] = useState<InboundDocumentKindValue>("DOCTOR_REPORT");
const [selectedId, setSelectedId] = useState<string | null>(null);
const list = useQuery({
queryKey: queryKeys.inboundDocuments(),
queryFn: () =>
apiFetch<ListResponse>("/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<InboundDocumentDto>("/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 (
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-4">
<header className="flex items-center gap-3">
<FileScan className="text-primary h-6 w-6" aria-hidden />
<div>
<h1 className="text-xl font-semibold">{t("documents.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("documents.subtitle")}
</p>
</div>
</header>
{/* Non-diagnostic disclaimer — most prominent, always visible. */}
<Card className="border-amber-300 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<CardHeader className="flex-row items-start gap-3 space-y-0">
<AlertTriangle
className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400"
aria-hidden
/>
<div>
<CardTitle className="text-base">
{t("documents.disclaimer.title")}
</CardTitle>
<CardDescription className="text-amber-900/80 dark:text-amber-200/80">
{t("documents.disclaimer.body")}
</CardDescription>
</div>
</CardHeader>
</Card>
{/* Upload */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("documents.upload.label")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind">{t("documents.upload.kindLabel")}</Label>
<NativeSelect
id="doc-kind"
value={kind}
onChange={(e) =>
setKind(e.target.value as InboundDocumentKindValue)
}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<Input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="max-w-xs"
disabled={upload.isPending}
/>
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
>
{upload.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{upload.isPending
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
</CardContent>
</Card>
{/* Document list */}
<section className="flex flex-col gap-2">
{list.isLoading ? (
<div className="flex h-24 items-center justify-center">
<Loader2
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
) : !list.data || list.data.documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
</p>
) : (
list.data.documents.map((doc) => (
<button
key={doc.id}
type="button"
onClick={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
className="hover:bg-accent flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm"
>
<span className="truncate">
{doc.filename ?? t(`documents.kind.${doc.kind}`)}
</span>
<span className="flex items-center gap-2">
<Badge variant="secondary">
{t(`documents.status.${doc.status}`)}
</Badge>
{doc.pendingCount > 0 ? (
<Badge variant="outline">{doc.pendingCount}</Badge>
) : null}
</span>
</button>
))
)}
</section>
{selectedId ? (
<DocumentReview
documentId={selectedId}
onClosed={() => setSelectedId(null)}
/>
) : null}
</div>
);
}
function DocumentReview({
documentId,
onClosed,
}: {
documentId: string;
onClosed: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId),
queryFn: () =>
apiFetch<InboundDocumentDetailDto>(
`/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 (
<div className="flex h-24 items-center justify-center">
<Loader2
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
);
}
if (!detail.data) return null;
const pending = detail.data.facts.filter((f) => f.status === "PENDING");
const decidedCount = Object.keys(decisions).length;
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">
{t("documents.review.title")}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.review.discard")}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{pending.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.empty.noFacts")}
</p>
) : (
pending.map((fact) => (
<FactCard
key={fact.id}
documentId={documentId}
fact={fact}
decision={decisions[fact.id]}
onDecision={(d) =>
setDecisions((prev) => {
const next = { ...prev };
if (d === null) delete next[fact.id];
else next[fact.id] = d;
return next;
})
}
/>
))
)}
{pending.length > 0 ? (
<>
<Separator />
<Button
onClick={() => confirm.mutate()}
disabled={confirm.isPending || decidedCount === 0}
>
{confirm.isPending
? t("documents.review.confirming")
: t("documents.review.confirm")}
</Button>
</>
) : null}
</CardContent>
</Card>
);
}
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 (
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline">
{t(`documents.factType.${fact.factType}`)}
</Badge>
{fact.needsReview ? (
<Badge variant="destructive">
{t("documents.review.needsReview")}
</Badge>
) : null}
<span className="text-muted-foreground text-xs">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="mt-1 truncate text-sm font-medium">{summary}</p>
{fact.provenance.sourceText ? (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs italic">
“{fact.provenance.sourceText}”
</p>
) : null}
</div>
<div className="flex shrink-0 gap-1">
<Button
variant={decision === "approve" ? "default" : "outline"}
size="sm"
disabled={fact.needsReview}
onClick={() =>
onDecision(decision === "approve" ? null : "approve")
}
>
{t("documents.review.approve")}
</Button>
<Button
variant={decision === "reject" ? "destructive" : "outline"}
size="sm"
onClick={() => onDecision(decision === "reject" ? null : "reject")}
>
{t("documents.review.reject")}
</Button>
</div>
</div>
<div className="mt-2">
<Button variant="ghost" size="xs" onClick={() => setEditing((v) => !v)}>
{editing ? t("documents.review.cancel") : t("documents.review.edit")}
</Button>
</div>
{editing ? (
<FactEditForm
fact={fact}
pending={save.isPending}
onSubmit={(payload) => save.mutate(payload)}
/>
) : null}
</div>
);
}
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 (
<EditFields
pending={pending}
fields={[
{ key: "label", label: t("documents.fields.label"), value: d.label },
]}
dateValue={d.onsetDate}
onSubmit={(vals, date) =>
onSubmit({
factType: "CONDITION",
label: vals.label,
onsetDate: date,
})
}
/>
);
}
if (fact.factType === "OBSERVATION") {
const d = fact.data as ObservationFactData;
return (
<EditFields
pending={pending}
fields={[
{ key: "label", label: t("documents.fields.label"), value: d.label },
{
key: "value",
label: t("documents.fields.value"),
value: d.value !== null ? String(d.value) : (d.valueText ?? ""),
},
{
key: "unit",
label: t("documents.fields.unit"),
value: d.unit ?? "",
},
]}
dateValue={d.effectiveDate}
onSubmit={(vals, date) => {
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 (
<EditFields
pending={pending}
fields={[
{ key: "name", label: t("documents.fields.label"), value: d.name },
{ key: "dose", label: t("documents.fields.dose"), value: d.dose ?? "" },
]}
dateValue={d.effectiveDate}
onSubmit={(vals, date) =>
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<string, string>, date: string | null) => void;
}) {
const { t } = useTranslations();
const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.map((f) => [f.key, f.value])),
);
const [date, setDate] = useState<string>(dateValue ?? "");
return (
<div className="mt-2 flex flex-col gap-2 border-t pt-2">
{fields.map((f) => (
<div key={f.key} className="flex flex-col gap-1">
<Label htmlFor={`edit-${f.key}`} className="text-xs">
{f.label}
</Label>
<Input
id={`edit-${f.key}`}
value={vals[f.key] ?? ""}
onChange={(e) =>
setVals((p) => ({ ...p, [f.key]: e.target.value }))
}
/>
</div>
))}
<div className="flex flex-col gap-1">
<Label htmlFor="edit-date" className="text-xs">
{t("documents.fields.date")}
</Label>
<Input
id="edit-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<Button
size="sm"
disabled={pending}
onClick={() => onSubmit(vals, date.trim() === "" ? null : date)}
>
{t("documents.review.save")}
</Button>
</div>
);
}
/** 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;
}
+12
View File
@@ -2,6 +2,7 @@ import {
Activity,
Bell,
Droplets,
FileScan,
FlaskConical,
Home,
Lightbulb,
@@ -137,6 +138,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
// 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",
@@ -31,6 +31,7 @@ import {
CalendarHeart,
Droplet,
Dumbbell,
FileScan,
FileText,
MessageCircleHeart,
Moon,
@@ -74,6 +75,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
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() {
@@ -84,6 +84,7 @@ function moduleMap(
medications: true,
doctorReport: true,
mcp: true,
inboundDocuments: true,
};
return { ...base, ...overrides };
}
+38 -4
View File
@@ -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"]]) {
+16
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
// 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<string> = new Set(MODULE_KEYS);
+12
View File
@@ -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,
};
+2
View File
@@ -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,
};
/**
@@ -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 () => {