fix(labs): dedup OCR commit rows in-memory, not via DB visibility

The per-row commit loop ran isDuplicate() then create() sequentially, so
two identical rows in one confirmed document only collapsed because row 1
autocommitted before row 2's live query. Track written keys in a
per-request seen-set keyed on analyte+day+value so in-batch dedup no
longer depends on DB visibility. Note the mint-then-create orphan on a
mid-row failure is benign and self-healing, matching the manual path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:58:07 +02:00
1 parent d6cb2f756f
commit 8c19dc51a5
2 files changed
+162 -1

No files matched your search

@@ -0,0 +1,135 @@
/**
* v1.18.9 — POST /api/labs/ocr/commit.
*
* Focus: dedup correctness. A row that duplicates a live reading is skipped,
* and — the regression this guards — two identical rows inside ONE confirmed
* document collapse to a single write even though neither is yet visible to a
* live query. The in-memory seen-set makes in-batch dedup independent of DB
* visibility, rather than relying on the prior row autocommitting first.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
labResult: {
findMany: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/labs/biomarker-store", () => ({
resolveOrMintBiomarker: vi.fn(),
}));
vi.mock("@/lib/labs/serialise", () => ({
serialiseLabResult: (created: { id: string }) => ({ id: created.id }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const TAKEN_AT = "2026-06-10T08:00:00.000Z";
function numericRow(value: number) {
return {
analyte: "Glucose",
value,
unit: "mg/dL",
takenAt: TAKEN_AT,
};
}
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/labs/ocr/commit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// No live reading collides at commit time — the in-batch path is what's
// under test, so the DB-visibility check returns empty for every row.
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
let nextId = 0;
vi.mocked(prisma.labResult.create).mockImplementation((() =>
Promise.resolve({ id: `lr-${++nextId}` })) as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
panel: null,
lowerBound: null,
upperBound: null,
} as never);
});
describe("POST /api/labs/ocr/commit dedup", () => {
it("collapses identical in-document rows to a single write", async () => {
const res = await POST(postReq({ rows: [numericRow(95), numericRow(95)] }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inserted: unknown[]; skipped: Array<{ reason: string }> };
};
// Only the first row writes; the second is skipped as an in-batch
// duplicate even though the first never became visible to a live query.
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
expect(body.data.inserted).toHaveLength(1);
expect(body.data.skipped).toEqual([
{ analyte: "Glucose", reason: "duplicate" },
]);
});
it("writes distinct values for the same analyte and day", async () => {
const res = await POST(
postReq({ rows: [numericRow(95), numericRow(110)] }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inserted: unknown[]; skipped: unknown[] };
};
expect(prisma.labResult.create).toHaveBeenCalledTimes(2);
expect(body.data.inserted).toHaveLength(2);
expect(body.data.skipped).toHaveLength(0);
});
});
+27 -1
View File
@@ -70,6 +70,25 @@ async function isDuplicate(
return false;
}
/**
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day
* + the value dimension that `isDuplicate` matches on. Two rows in ONE document
* that resolve to the same key are the same reading; only the first is written.
* Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
* on a prior row autocommitting before the next row's live query runs.
*/
function inBatchKey(row: OcrCommitRow): string {
const analyte = row.analyte.trim().toLowerCase();
const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
const valuePart =
row.value !== undefined
? `n:${row.value}`
: row.valueText !== undefined
? `t:${row.valueText.trim().toLowerCase()}`
: "∅";
return `${analyte}|${dayKey}|${valuePart}`;
}
async function commitOcrRows(request: NextRequest) {
const { user } = await requireAuth();
@@ -102,9 +121,15 @@ async function commitOcrRows(request: NextRequest) {
const inserted: ReturnType<typeof serialiseLabResult>[] = [];
const skipped: OcrSkippedRowDto[] = [];
// 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
// is benign and self-healing — the manual lab-write path mints the same way.
const writtenInBatch = new Set<string>();
for (const row of parsed.data.rows) {
if (await isDuplicate(user.id, row)) {
const key = inBatchKey(row);
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
continue;
}
@@ -138,6 +163,7 @@ async function commitOcrRows(request: NextRequest) {
},
});
writtenInBatch.add(key);
inserted.push(serialiseLabResult(created, biomarker));
}