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 (
+
+
+
+ {/* Non-diagnostic disclaimer — most prominent, always visible. */}
+
+
+
+
+
+ {t("documents.disclaimer.title")}
+
+
+ {t("documents.disclaimer.body")}
+
+
+
+
+
+ {/* Upload */}
+
+
+
+ {t("documents.upload.label")}
+
+
+
+
+ {t("documents.upload.kindLabel")}
+
+ setKind(e.target.value as InboundDocumentKindValue)
+ }
+ >
+ {INBOUND_DOCUMENT_KINDS.map((k) => (
+
+ {t(`documents.kind.${k}`)}
+
+ ))}
+
+
+
+ {
+ const file = fileRef.current?.files?.[0];
+ if (file) upload.mutate(file);
+ }}
+ disabled={upload.isPending}
+ >
+ {upload.isPending ? (
+
+ ) : (
+
+ )}
+ {upload.isPending
+ ? t("documents.upload.uploading")
+ : t("documents.upload.button")}
+
+
+
+
+ {/* Document list */}
+
+ {list.isLoading ? (
+
+
+
+ ) : !list.data || list.data.documents.length === 0 ? (
+
+ {t("documents.list.empty")}
+
+ ) : (
+ list.data.documents.map((doc) => (
+
+ 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"
+ >
+
+ {doc.filename ?? t(`documents.kind.${doc.kind}`)}
+
+
+
+ {t(`documents.status.${doc.status}`)}
+
+ {doc.pendingCount > 0 ? (
+ {doc.pendingCount}
+ ) : null}
+
+
+ ))
+ )}
+
+
+ {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")}
+
+ discard.mutate()}
+ disabled={discard.isPending}
+ >
+ {t("documents.review.discard")}
+
+
+
+ {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 ? (
+ <>
+
+ confirm.mutate()}
+ disabled={confirm.isPending || decidedCount === 0}
+ >
+ {confirm.isPending
+ ? t("documents.review.confirming")
+ : t("documents.review.confirm")}
+
+ >
+ ) : 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}
+
+
+
+ onDecision(decision === "approve" ? null : "approve")
+ }
+ >
+ {t("documents.review.approve")}
+
+ onDecision(decision === "reject" ? null : "reject")}
+ >
+ {t("documents.review.reject")}
+
+
+
+
+
+ setEditing((v) => !v)}>
+ {editing ? t("documents.review.cancel") : t("documents.review.edit")}
+
+
+
+ {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) => (
+
+
+ {f.label}
+
+
+ setVals((p) => ({ ...p, [f.key]: e.target.value }))
+ }
+ />
+
+ ))}
+
+
+ {t("documents.fields.date")}
+
+ setDate(e.target.value)}
+ />
+
+
onSubmit(vals, date.trim() === "" ? null : date)}
+ >
+ {t("documents.review.save")}
+
+
+ );
+}
+
+/** 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 () => {