fix(labs): match scanned duplicates on the reading's calendar day

The scan commit and the review screen looked for an existing reading in
the UTC day of the scanned date. A hand-entered morning draw east of UTC
sits on the previous UTC day, so scanning the same report wrote the
reading a second time. Both checks now compare calendar days: a date-only
reading's stated date, or the user's day of a hand-entered instant.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:37:59 +02:00
1 parent 9662a3d104
commit a67ba9ee66
5 files changed
+173 -23

No files matched your search

@@ -21,6 +21,11 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
resolveUserTimezone: vi.fn(async () => tzMock.zone),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -84,6 +89,7 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
tzMock.zone = "UTC";
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
@@ -133,3 +139,62 @@ describe("POST /api/labs/ocr/commit dedup", () => {
expect(body.data.skipped).toHaveLength(0);
});
});
describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
/**
* A database stand-in that honours the `takenAt` range the route asks for,
* so the test sees what the real query would return.
*/
function liveReadings(rows: Array<{ value: number; takenAt: Date }>) {
vi.mocked(prisma.labResult.findMany).mockImplementation(((args: {
where: { takenAt: { gte?: Date; lt?: Date; lte?: Date } };
}) => {
const { gte, lt, lte } = args.where.takenAt;
return Promise.resolve(
rows
.filter(
(r) =>
(!gte || r.takenAt >= gte) &&
(!lt || r.takenAt < lt) &&
(!lte || r.takenAt <= lte),
)
.map((r) => ({ ...r, valueText: null })),
);
}) as never);
}
it("skips a scanned reading already entered by hand on the same local day east of UTC", async () => {
tzMock.zone = "Asia/Tokyo";
// Entered by hand: 2026-06-10 08:00 in Tokyo, the 9th in UTC.
liveReadings([
{ value: 95, takenAt: new Date("2026-06-09T23:00:00.000Z") },
]);
// The scan of the same report states the 10th (stored at noon UTC).
const res = await POST(
postReq({
rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }],
}),
);
const body = (await res.json()) as {
data: { skipped: Array<{ reason: string }> };
};
expect(body.data.skipped).toEqual([
{ analyte: "Glucose", reason: "duplicate" },
]);
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("writes a reading from the neighbouring local day", async () => {
tzMock.zone = "Asia/Tokyo";
// 2026-06-09 08:00 in Tokyo: a different day from the scanned 10th.
liveReadings([
{ value: 95, takenAt: new Date("2026-06-08T23:00:00.000Z") },
]);
await POST(
postReq({
rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }],
}),
);
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
});
});
+25 -16
View File
@@ -47,29 +47,37 @@ import {
type OcrCommitRow,
type OcrSkippedRowDto,
} from "@/lib/validations/labs-ocr";
import {
labReadingDay,
labReadingDaySearchRange,
} from "@/lib/labs/reading-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows));
/** True when a live reading already records this analyte+day+value. */
/**
* True when a live reading already records this analyte+day+value. The day
* is the reading's calendar day (`labReadingDay`), not its UTC day: a
* hand-entered morning draw east of UTC sits on the previous UTC day, and the
* UTC window let the scan of the same report write it again.
*/
async function isDuplicate(
userId: string,
row: OcrCommitRow,
tz: string,
): Promise<boolean> {
const dayStart = new Date(row.takenAt);
dayStart.setUTCHours(0, 0, 0, 0);
const dayEnd = new Date(row.takenAt);
dayEnd.setUTCHours(23, 59, 59, 999);
const candidates = await prisma.labResult.findMany({
const day = labReadingDay(new Date(row.takenAt), tz);
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
takenAt: { gte: dayStart, lte: dayEnd },
takenAt: labReadingDaySearchRange(day),
},
select: { value: true, valueText: true },
take: 25,
select: { value: true, valueText: true, takenAt: true },
take: 100,
});
const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day);
for (const c of candidates) {
if (row.value !== undefined && c.value !== null && c.value === row.value) {
return true;
@@ -86,15 +94,15 @@ async function isDuplicate(
}
/**
* 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
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + the
* reading's calendar 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 {
function inBatchKey(row: OcrCommitRow, tz: string): string {
const analyte = row.analyte.trim().toLowerCase();
const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
const dayKey = labReadingDay(new Date(row.takenAt), tz);
const valuePart =
row.value !== undefined
? `n:${row.value}`
@@ -145,9 +153,10 @@ async function commitOcrRows(request: NextRequest) {
// is benign and self-healing — the manual lab-write path mints the same way.
const writtenInBatch = new Set<string>();
const tz = await resolveUserTimezone(user.id);
for (const row of parsed.data.rows) {
const key = inBatchKey(row);
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
const key = inBatchKey(row, tz);
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) {
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
continue;
}