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

+29
View File
@@ -26,6 +26,10 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { serialiseLabResult } from "@/lib/labs/serialise";
import {
linkOcrLabsToVaultDocument,
type InsertedLabForLink,
} from "@/lib/labs/vault-link";
import { annotate } from "@/lib/logging/context";
import {
ocrCommitSchema,
@@ -121,6 +125,9 @@ async function commitOcrRows(request: NextRequest) {
const inserted: ReturnType<typeof serialiseLabResult>[] = [];
const skipped: OcrSkippedRowDto[] = [];
// S9 — the rows actually written, kept for the vault cross-link so a re-commit
// (every row a duplicate → nothing inserted) links nothing new.
const linkable: InsertedLabForLink[] = [];
// Tracks the keys already written in THIS request so an in-document duplicate
// is caught even before the prior row is visible to a live query. The
// mint-then-create on a mid-row failure can leave an orphan biomarker, which
@@ -165,6 +172,28 @@ async function commitOcrRows(request: NextRequest) {
writtenInBatch.add(key);
inserted.push(serialiseLabResult(created, biomarker));
linkable.push({
labResultId: created.id,
analyte: created.analyte,
value: created.value,
valueText: created.valueText,
unit: created.unit,
referenceLow: created.referenceLow,
referenceHigh: created.referenceHigh,
takenAt: created.takenAt,
});
}
// S9 — cross-link the freshly inserted labs to the vault document the client
// filed the scanned bytes into. Best-effort: the labs are the authoritative
// write, so a link failure (module off, foreign / missing document) never
// fails the commit. Owner + module checks live inside the linker.
if (parsed.data.documentId && linkable.length > 0) {
await linkOcrLabsToVaultDocument(
user.id,
parsed.data.documentId,
linkable,
).catch(() => {});
}
await auditLog("labs.ocr.commit", {