mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
98ca0b9d61
commit
422778f679
10 files changed
+707
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"]]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user