feat(documents): bidirectional labs ↔ documents cross-link

Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 00:01:45 +02:00
1 parent 502783a3ea
commit 73de8f6a52
10 files changed
+790 -13

No files matched your search

+19 -9
View File
@@ -108,6 +108,10 @@ export function OcrReviewDialog({
const inputRef = useRef<HTMLInputElement | null>(null);
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
// S9 — in vision mode the picked file is retained so, on commit, it can be
// filed into the Documents vault and cross-linked to the committed labs. Text
// mode keeps the image on-device, so nothing is retained there.
const [pickedFile, setPickedFile] = useState<File | null>(null);
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
@@ -126,6 +130,7 @@ export function OcrReviewDialog({
function reset() {
setStage("pick");
setRows([]);
setPickedFile(null);
extract.reset();
commit.reset();
}
@@ -136,6 +141,8 @@ export function OcrReviewDialog({
}
function onFilePicked(file: File) {
// Retain the source only for vision mode (text mode never sends the image).
setPickedFile(mode === "vision" ? file : null);
extract.mutate(file, {
onSuccess: (data) => {
const seeded = seedReviewRows(data.rows, data.reportDate);
@@ -159,16 +166,19 @@ export function OcrReviewDialog({
toast.error(t("labs.ocr.nothingToSave"));
return;
}
commit.mutate(payload, {
onSuccess: (result) => {
toast.success(
t("labs.ocr.savedToast", { count: result.inserted.length }),
);
onCommitted();
handleClose(false);
commit.mutate(
{ rows: payload, file: pickedFile },
{
onSuccess: (result) => {
toast.success(
t("labs.ocr.savedToast", { count: result.inserted.length }),
);
onCommitted();
handleClose(false);
},
onError: () => toast.error(t("labs.ocr.saveFailed")),
},
onError: () => toast.error(t("labs.ocr.saveFailed")),
});
);
}
const busy = extract.isPending || commit.isPending;
+44 -4
View File
@@ -115,15 +115,55 @@ export function useUpdateLabsLocalOcr() {
});
}
/** The commit payload: the confirmed rows plus, in vision mode, the source file. */
export interface OcrCommitInput {
rows: OcrCommitRowInput[];
/**
* S9 — the scanned file, threaded from the vision-mode extract. When present
* (and the documents module is on) it is filed into the Documents vault and
* the committed labs are cross-linked to it. Absent in text mode, where the
* image stays on-device.
*/
file?: File | null;
}
/**
* S9 — file the scanned bytes into the Documents vault (kind LAB_RESULT) via the
* existing upload endpoint (encrypted at rest, EXIF-stripped, thumbnailed,
* sha256-deduped). Best-effort: a module-off account 403s and a re-scan dedupes,
* so any failure resolves to `undefined` and the commit proceeds unlinked.
*/
async function fileScanToVault(file: File): Promise<string | undefined> {
const form = new FormData();
form.append("file", file);
form.append("kind", "LAB_RESULT");
const doc = await apiFetch<{ id: string }>("/api/documents/inbound", {
method: "POST",
body: form,
});
return doc?.id;
}
/** Commit the confirmed rows and invalidate the dependent reads. */
export function useOcrCommit() {
const queryClient = useQueryClient();
return useMutation<OcrCommitResult, Error, OcrCommitRowInput[]>({
mutationFn: (rows: OcrCommitRowInput[]) =>
apiPost<OcrCommitResult>("/api/labs/ocr/commit", { rows }),
onSuccess: () => {
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
mutationFn: async ({ rows, file }: OcrCommitInput) => {
const documentId = file
? await fileScanToVault(file).catch(() => undefined)
: undefined;
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
rows,
...(documentId ? { documentId } : {}),
});
},
onSuccess: (_result, { file }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
// A scan filed into the vault adds a document — refresh its lists.
if (file) {
queryClient.invalidateQueries({ queryKey: queryKeys.documents() });
}
},
});
}