mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user