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

+4
View File
@@ -8029,6 +8029,10 @@ paths:
required:
- analyte
- takenAt
documentId:
type: string
minLength: 1
maxLength: 64
required:
- rows
responses:
+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", {
+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() });
}
},
});
}
@@ -0,0 +1,217 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* S8 — auto-stage lab facts after the auto-index job. Pins Marc's sign-off
* (auto-STAGE, never auto-COMMIT), the double module gate, the reuse of the
* auto-index consent gate, the "looks like a lab" heuristic, idempotency (a
* non-STORED or already-staged document is left alone), and the guarded
* STORED→EXTRACTED transition that makes the S9 cross-link race-safe. No
* LabResult is ever written here — the facts land PENDING for human review.
*/
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/documents/index-document", () => ({
resolveIndexProvider: vi.fn(),
}));
vi.mock("@/lib/documents/content-index", () => ({
loadDocumentChatText: vi.fn(),
}));
vi.mock("@/lib/documents/extract", async () => {
const actual = await vi.importActual<
typeof import("@/lib/documents/extract")
>("@/lib/documents/extract");
return { ...actual, runInboundExtraction: vi.fn() };
});
vi.mock("@/lib/documents/store", () => ({
encryptFactData: vi.fn(() => new Uint8Array([1])),
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-16"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } },
}));
const updateMany = vi.fn();
const createMany = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: {
findFirst: (...a: unknown[]) => findFirst(...a),
},
$transaction: (fn: (tx: unknown) => unknown) =>
fn({
inboundDocument: { updateMany: (...a: unknown[]) => updateMany(...a) },
extractedFact: { createMany: (...a: unknown[]) => createMany(...a) },
}),
},
}));
import {
maybeAutoStageLabFacts,
looksLikeLabDocument,
} from "../auto-stage-labs";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveIndexProvider } from "@/lib/documents/index-document";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { runInboundExtraction } from "@/lib/documents/extract";
import { reserveBudget } from "@/lib/ai/coach/budget";
const mockModule = vi.mocked(isModuleEnabled);
const mockProvider = vi.mocked(resolveIndexProvider);
const mockText = vi.mocked(loadDocumentChatText);
const mockExtract = vi.mocked(runInboundExtraction);
const mockReserve = vi.mocked(reserveBudget);
const LAB_TEXT =
"Laborbefund. Hämoglobin 14.2 g/dl. Referenzbereich 13-17. LDL 120 mg/dl.";
function eligibleProvider() {
return {
chain: [],
pick: {
providerType: "anthropic",
entry: { providerType: "anthropic", instance: {} },
pdfSupported: true,
},
consentOk: true,
dailyCap: 1000,
} as unknown as Awaited<ReturnType<typeof resolveIndexProvider>>;
}
beforeEach(() => {
vi.clearAllMocks();
mockModule.mockResolvedValue(true);
findFirst.mockResolvedValue({
kind: "OTHER",
status: "STORED",
_count: { facts: 0 },
});
mockProvider.mockResolvedValue(eligibleProvider());
mockText.mockResolvedValue({ text: LAB_TEXT, source: "verbatim" });
mockReserve.mockResolvedValue({
allowed: true,
reserved: 10,
totalAfter: 10,
} as Awaited<ReturnType<typeof reserveBudget>>);
mockExtract.mockResolvedValue({
reportDate: "2026-07-10",
kind: "OTHER",
providerType: "anthropic",
facts: [
{
factType: "OBSERVATION",
confidence: 0.9,
needsReview: false,
data: {
label: "Hämoglobin",
code: null,
codeSystem: null,
value: 14.2,
valueText: null,
unit: "g/dl",
referenceLow: 13,
referenceHigh: 17,
effectiveDate: "2026-07-10",
},
provenance: { sourceText: "Hämoglobin 14.2", page: 0, confidence: 0.9 },
},
],
});
updateMany.mockResolvedValue({ count: 1 });
createMany.mockResolvedValue({ count: 1 });
});
describe("looksLikeLabDocument", () => {
it("matches a lab report with ≥2 signals", () => {
expect(looksLikeLabDocument(LAB_TEXT)).toBe(true);
});
it("rejects prose with at most one incidental signal", () => {
expect(
looksLikeLabDocument("Dear patient, please take 500 mg twice daily."),
).toBe(false);
});
});
describe("maybeAutoStageLabFacts", () => {
it("stages facts PENDING and never commits when gated in", async () => {
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: true, facts: 1 });
// Guarded STORED→EXTRACTED, then PENDING facts — no commit path touched.
expect(updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: "STORED" }),
data: expect.objectContaining({ status: "EXTRACTED" }),
}),
);
const created = createMany.mock.calls[0]![0] as {
data: { status: string }[];
};
expect(created.data.every((f) => f.status === "PENDING")).toBe(true);
});
it("skips (manual path only) when a module is off", async () => {
mockModule.mockImplementation(async (_u, key) => key !== "labs");
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: false, reason: "modules-off" });
expect(mockExtract).not.toHaveBeenCalled();
});
it("skips when the auto-read consent gate is not eligible", async () => {
mockProvider.mockResolvedValue({
...eligibleProvider(),
consentOk: false,
} as Awaited<ReturnType<typeof resolveIndexProvider>>);
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: false, reason: "not-eligible" });
expect(mockExtract).not.toHaveBeenCalled();
});
it("skips a non-lab document (no extraction call spent)", async () => {
mockText.mockResolvedValue({
text: "A short personal note with no clinical content.",
source: "verbatim",
});
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: false, reason: "not-lab" });
expect(mockExtract).not.toHaveBeenCalled();
});
it("auto-stages a LAB_RESULT-kind document even without keyword signals", async () => {
findFirst.mockResolvedValue({
kind: "LAB_RESULT",
status: "STORED",
_count: { facts: 0 },
});
mockText.mockResolvedValue({
text: "opaque scan text",
source: "verbatim",
});
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: true, facts: 1 });
});
it("is idempotent — a non-STORED / already-staged document is left alone", async () => {
findFirst.mockResolvedValue({
kind: "OTHER",
status: "EXTRACTED",
_count: { facts: 1 },
});
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: false, reason: "already-handled" });
expect(mockExtract).not.toHaveBeenCalled();
});
it("aborts without touching facts when another writer moved the row (race)", async () => {
updateMany.mockResolvedValue({ count: 0 });
const res = await maybeAutoStageLabFacts("u1", "d1");
expect(res).toEqual({ staged: false, reason: "raced" });
expect(createMany).not.toHaveBeenCalled();
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* S8 — auto-stage lab facts from a freshly indexed vault document.
*
* After the per-document auto-index job transcribes a stored document, a
* document that LOOKS LIKE a lab result is run through the SAME inbound
* extraction the manual "Extract" button uses — but the facts land PENDING for
* the existing human-review screen. Nothing is committed here: the user still
* taps one confirm before a value becomes a `LabResult`. Marc's sign-off is
* explicit — auto-STAGE, never auto-COMMIT.
*
* Gates (ALL must hold, otherwise the manual extract button is left untouched):
* - both the `inboundDocuments` AND `labs` modules are on for the user;
* - a usable document-order provider whose egress the `documentsAutoAiRead`
* consent gate permits — reuses `resolveIndexProvider`, so a local pick is
* always eligible and an external pick needs the opt-in, IDENTICAL to the
* auto-index external-egress gate;
* - the document is still STORED with no staged facts — so a re-index, the
* manual path, or the labs→vault cross-link (S9) never double-stages;
* - the transcribed text reads like a lab report (a cheap keyword heuristic,
* or the user filed it under kind LAB_RESULT), so an arbitrary photo never
* burns an extraction call.
*
* It reuses the transcription the auto-index already produced (the encrypted
* content index) and runs the extraction in TEXT mode, so no second vision call
* is spent. The staging transition is a guarded `STORED → EXTRACTED` update:
* if the labs-filing cross-link (S9) or the manual path moved the document
* first, the guard matches zero rows and the auto-stage aborts without touching
* any fact — the two writers can never corrupt each other's facts.
*/
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
} from "@/lib/ai/coach/budget";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveIndexProvider } from "@/lib/documents/index-document";
import {
InboundExtractError,
runInboundExtraction,
type InboundExtractionResult,
} from "@/lib/documents/extract";
import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
/**
* Lab-report signals used to decide whether a transcribed document is worth an
* extraction call. Each pattern is one class of evidence (a reference-range
* label, a lab unit, a common analyte name, a report header); requiring TWO
* distinct hits keeps a stray "mg" in a prose letter from tripping the auto
* extraction while still catching a real panel in German or English.
*/
const LAB_SIGNALS: readonly RegExp[] = [
/reference range|referenzbereich|normbereich|normal range|ref\.?-?bereich/i,
/\b(mg\/dl|mmol\/l|nmol\/l|µmol\/l|umol\/l|g\/dl|µg\/l|ug\/l|ng\/ml|pg\/ml|mmol\/mol|u\/l|iu\/l|mg\/l|\/µl|\/ul)\b/i,
/h(?:ä|ae)moglobin|hemoglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i,
/labor(?:befund|wert)?|laboratory|blutbild|\bbefund\b/i,
];
/** True when the transcribed text carries at least two lab-report signals. */
export function looksLikeLabDocument(text: string): boolean {
let hits = 0;
for (const signal of LAB_SIGNALS) {
if (signal.test(text) && ++hits >= 2) return true;
}
return false;
}
/** Why an auto-stage attempt did not stage — every branch leaves the manual path intact. */
export type AutoStageOutcome =
| { staged: true; facts: number }
| {
staged: false;
reason:
| "modules-off"
| "not-eligible"
| "already-handled"
| "no-text"
| "not-lab"
| "budget"
| "extract-failed"
| "raced";
};
/**
* Guarded `STORED → EXTRACTED` transition + PENDING fact creation in one
* transaction. Returns the staged count, or `null` when the document was no
* longer STORED (another writer moved it first) — the caller treats `null` as a
* clean no-op, never an error.
*/
async function stageFactsIfStored(
userId: string,
documentId: string,
result: InboundExtractionResult,
): Promise<number | null> {
return prisma.$transaction(async (tx) => {
const moved = await tx.inboundDocument.updateMany({
where: { id: documentId, userId, status: "STORED", deletedAt: null },
data: {
status: "EXTRACTED",
providerType: result.providerType,
reportDate: result.reportDate
? new Date(`${result.reportDate}T00:00:00.000Z`)
: null,
},
});
if (moved.count === 0) return null;
if (result.facts.length === 0) return 0;
await tx.extractedFact.createMany({
data: result.facts.map((f) => ({
documentId,
userId,
factType: f.factType,
status: "PENDING" as const,
confidence: f.confidence,
needsReview: f.needsReview,
dataEncrypted: encryptFactData(f.data),
provenanceEncrypted: encryptFactProvenance(f.provenance),
})),
});
return result.facts.length;
});
}
/**
* Auto-stage lab facts for one freshly indexed document (owner-scoped). Called
* fire-and-forget by the auto-index job after a successful index; every guard
* miss returns a tagged no-op so the manual extract button stays the fallback.
*/
export async function maybeAutoStageLabFacts(
userId: string,
documentId: string,
): Promise<AutoStageOutcome> {
const [inboundOn, labsOn] = await Promise.all([
isModuleEnabled(userId, "inboundDocuments"),
isModuleEnabled(userId, "labs"),
]);
if (!inboundOn || !labsOn) return { staged: false, reason: "modules-off" };
// Idempotency floor: only a still-STORED document with no facts is a
// candidate. A re-index, the manual extract path, or the S9 cross-link all
// leave a non-STORED / already-staged document alone.
const doc = await prisma.inboundDocument.findFirst({
where: { id: documentId, userId, deletedAt: null },
select: { kind: true, status: true, _count: { select: { facts: true } } },
});
if (!doc || doc.status !== "STORED" || doc._count.facts > 0) {
return { staged: false, reason: "already-handled" };
}
// Same provider + consent gate the auto-index external path uses.
const provider = await resolveIndexProvider(userId);
if (!provider.pick || !provider.consentOk) {
return { staged: false, reason: "not-eligible" };
}
// Reuse the transcription the auto-index just produced — no second vision call.
const chat = await loadDocumentChatText(userId, documentId);
if (!chat || !chat.text.trim()) return { staged: false, reason: "no-text" };
if (doc.kind !== "LAB_RESULT" && !looksLikeLabDocument(chat.text)) {
return { staged: false, reason: "not-lab" };
}
// A text-structuring pass, not a vision call — reserve the proportionate
// ceiling under the same daily cap the auto-index resolved.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
provider.dailyCap,
);
if (!reservation.allowed) return { staged: false, reason: "budget" };
let result: InboundExtractionResult;
try {
result = await runInboundExtraction({
provider: provider.pick.entry.instance,
providerType: provider.pick.providerType,
ocrText: chat.text,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
if (!(err instanceof InboundExtractError)) {
annotate({
action: { name: "documents.autoStage.failed" },
meta: { documentId, reason: "provider_error" },
});
}
return { staged: false, reason: "extract-failed" };
}
const staged = await stageFactsIfStored(userId, documentId, result);
if (staged === null) return { staged: false, reason: "raced" };
annotate({
action: { name: "documents.autoStage.labs" },
meta: {
documentId,
facts: staged,
provider: provider.pick.providerType,
byKind: doc.kind === "LAB_RESULT",
},
});
return { staged: true, facts: staged };
}
+10
View File
@@ -12,6 +12,7 @@
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { indexDocumentContent } from "@/lib/documents/index-document";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
@@ -44,6 +45,15 @@ export async function runDocumentIndex(
? { documentId, indexed: true, source: outcome.source }
: { documentId, indexed: false, reason: outcome.reason },
});
// S8 — once the document is indexed, auto-stage lab facts for a lab-looking
// document (both modules on + the auto-AI-read consent gate). Facts land
// PENDING for the existing human review; nothing is committed. Fully gated +
// idempotent inside; a failure here must never fail the index job, so it is
// swallowed to a no-op (the manual extract button remains the fallback).
if (outcome.indexed) {
await maybeAutoStageLabFacts(userId, documentId).catch(() => {});
}
}
/**
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* S9 — cross-link OCR-committed labs to their source vault document. Pins the
* module gate + ownership fail-closed (both directions), the APPROVED
* OBSERVATION provenance fact per inserted lab (committedRecordId/Type), the
* supersede-PENDING + mark-CONFIRMED transition, and idempotency (an empty
* insert links nothing).
*/
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/documents/store", () => ({
encryptFactData: vi.fn(() => new Uint8Array([1])),
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
}));
const findFirst = vi.fn();
const deleteMany = vi.fn();
const createMany = vi.fn();
const update = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: {
findFirst: (...a: unknown[]) => findFirst(...a),
},
$transaction: (fn: (tx: unknown) => unknown) =>
fn({
extractedFact: {
deleteMany: (...a: unknown[]) => deleteMany(...a),
createMany: (...a: unknown[]) => createMany(...a),
},
inboundDocument: { update: (...a: unknown[]) => update(...a) },
}),
},
}));
import { linkOcrLabsToVaultDocument } from "../vault-link";
import { isModuleEnabled } from "@/lib/modules/gate";
import { encryptFactData } from "@/lib/documents/store";
const mockModule = vi.mocked(isModuleEnabled);
const mockEncrypt = vi.mocked(encryptFactData);
const LABS = [
{
labResultId: "lab1",
analyte: "Hämoglobin",
value: 14.2,
valueText: null,
unit: "g/dl",
referenceLow: 13,
referenceHigh: 17,
takenAt: new Date("2026-07-10T12:00:00.000Z"),
},
];
beforeEach(() => {
vi.clearAllMocks();
mockModule.mockResolvedValue(true);
findFirst.mockResolvedValue({ id: "doc1" });
deleteMany.mockResolvedValue({ count: 0 });
createMany.mockResolvedValue({ count: 1 });
update.mockResolvedValue({});
});
describe("linkOcrLabsToVaultDocument", () => {
it("links each inserted lab as an APPROVED observation fact and confirms the doc", async () => {
const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS);
expect(res).toEqual({ linked: 1 });
// PENDING cross-fire facts superseded, then APPROVED provenance facts.
expect(deleteMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: "PENDING" }),
}),
);
const created = createMany.mock.calls[0]![0] as {
data: {
status: string;
factType: string;
committedRecordId: string;
committedRecordType: string;
}[];
};
expect(created.data[0]).toMatchObject({
status: "APPROVED",
factType: "OBSERVATION",
committedRecordId: "lab1",
committedRecordType: "labResult",
});
// The Observation payload carries the stated value verbatim.
expect(mockEncrypt).toHaveBeenCalledWith(
expect.objectContaining({
label: "Hämoglobin",
value: 14.2,
unit: "g/dl",
}),
);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: "CONFIRMED" } }),
);
});
it("links nothing when the documents module is off", async () => {
mockModule.mockResolvedValue(false);
const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS);
expect(res).toEqual({ linked: 0 });
expect(findFirst).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
it("fails closed on a foreign / missing document", async () => {
findFirst.mockResolvedValue(null);
const res = await linkOcrLabsToVaultDocument("u1", "doc1", LABS);
expect(res).toEqual({ linked: 0 });
expect(createMany).not.toHaveBeenCalled();
});
it("is a no-op with no inserted labs (idempotent re-commit)", async () => {
const res = await linkOcrLabsToVaultDocument("u1", "doc1", []);
expect(res).toEqual({ linked: 0 });
expect(mockModule).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* S9 — link labs committed from an OCR scan back to the vault document filed
* for that scan.
*
* When a user scans a lab report in the labs area with the `inboundDocuments`
* module on, the client files the uploaded bytes into the Documents vault
* (through the existing `POST /api/documents/inbound` upload — encrypted at
* rest, EXIF-stripped, thumbnailed, sha256-deduped) and threads the resulting
* document id to the OCR commit. This function makes the cross-reference: each
* committed `LabResult` gets an APPROVED `ExtractedFact` whose
* `committedRecordId` / `committedRecordType` point at it — the SAME provenance
* artefact the manual vault → labs path produces, so the two directions
* converge on one representation (a document with confirmed observation facts).
*
* Owner-scoped and module-gated: a document that is not the caller's, or a
* caller with the module off, links nothing. Idempotent by construction — the
* OCR commit only passes the rows it actually inserted (re-committing the same
* scan inserts zero rows, so zero facts are created). Any PENDING facts the
* auto-index cross-fire (S8) may have staged on the same row are superseded:
* the user just committed these labs, so the document's facts become exactly
* the committed set and the document is marked CONFIRMED.
*
* Best-effort: the OCR commit is the authoritative write; a failure to file the
* cross-link never fails the commit (the caller swallows it).
*/
import { prisma } from "@/lib/db";
import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
/** One inserted lab row to cross-link back to its source vault document. */
export interface InsertedLabForLink {
labResultId: string;
analyte: string;
value: number | null;
valueText: string | null;
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
}
/** Build the FHIR-staged Observation payload for a committed lab row. */
function observationFromLab(lab: InsertedLabForLink): ObservationFactData {
return {
label: lab.analyte,
// An OCR-scanned reading carries no coded system; the vault fact records the
// stated value only, never an inferred LOINC.
code: null,
codeSystem: null,
value: lab.value,
valueText: lab.valueText,
unit: lab.unit,
referenceLow: lab.referenceLow,
referenceHigh: lab.referenceHigh,
effectiveDate: lab.takenAt.toISOString().slice(0, 10),
};
}
/**
* Cross-link the inserted OCR labs to their source vault document. Returns the
* number of facts written (0 when the module is off, the document is not owned,
* or there is nothing to link).
*/
export async function linkOcrLabsToVaultDocument(
userId: string,
documentId: string,
labs: InsertedLabForLink[],
): Promise<{ linked: number }> {
if (labs.length === 0) return { linked: 0 };
// The cross-link fires only when the documents module is on for the user.
if (!(await isModuleEnabled(userId, "inboundDocuments"))) {
return { linked: 0 };
}
// Ownership fail-closed — the document must be a live row of the caller.
const doc = await prisma.inboundDocument.findFirst({
where: { id: documentId, userId, deletedAt: null },
select: { id: true },
});
if (!doc) return { linked: 0 };
await prisma.$transaction(async (tx) => {
// Supersede any PENDING facts the auto-index cross-fire staged on this row:
// the user just confirmed these exact labs, so they are the document's facts.
await tx.extractedFact.deleteMany({
where: { documentId, userId, status: "PENDING" },
});
await tx.extractedFact.createMany({
data: labs.map((lab) => ({
documentId,
userId,
factType: "OBSERVATION" as const,
status: "APPROVED" as const,
confidence: 1,
needsReview: false,
dataEncrypted: encryptFactData(observationFromLab(lab)),
provenanceEncrypted: encryptFactProvenance({
sourceText: "",
page: null,
confidence: 1,
}),
committedRecordId: lab.labResultId,
committedRecordType: "labResult",
})),
});
await tx.inboundDocument.update({
where: { id: documentId },
data: { status: "CONFIRMED" },
});
});
annotate({
action: { name: "labs.ocr.vaultLinked" },
meta: { documentId, linked: labs.length },
});
return { linked: labs.length };
}
+8
View File
@@ -179,6 +179,14 @@ export type OcrCommitRow = z.infer<typeof ocrCommitRowSchema>;
export const ocrCommitSchema = z.object({
rows: z.array(ocrCommitRowSchema).min(1).max(OCR_MAX_ROWS),
/**
* S9 — the vault document the client filed the scanned bytes into (kind
* LAB_RESULT), so the committed labs can be cross-linked back to their source
* (`inboundDocuments` module on). Optional: text-mode scans keep the image
* on-device and file nothing, and a module-off account never files. Ownership
* is verified server-side before any link is written.
*/
documentId: z.string().trim().min(1).max(64).optional(),
});
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;