mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
This commit is contained in:
1 parent
a3ce6af9a3
commit
81be73fca8
42 files changed
+1645
-423
No files matched your search
@@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
// v1.43 — the account's markers and aliases, loaded once per commit.
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn(), upsert: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -107,6 +110,8 @@ beforeEach(() => {
|
||||
// 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);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
let nextId = 0;
|
||||
vi.mocked(prisma.labResult.create).mockImplementation((() =>
|
||||
Promise.resolve({ id: `lr-${++nextId}` })) as never);
|
||||
|
||||
@@ -34,6 +34,13 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
|
||||
import {
|
||||
addAliasToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
type IndexedMarker,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
@@ -69,13 +76,20 @@ async function isDuplicate(
|
||||
userId: string,
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
/** The marker the row resolves to, when it resolves to one already. */
|
||||
biomarkerId: string | null,
|
||||
): Promise<boolean> {
|
||||
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" },
|
||||
// The printed name, or a reading already filed under the marker this
|
||||
// name resolves to: an earlier report may have spelled it another way.
|
||||
OR: [
|
||||
{ analyte: { equals: row.analyte.trim(), mode: "insensitive" } },
|
||||
...(biomarkerId ? [{ biomarkerId }] : []),
|
||||
],
|
||||
takenAt: labReadingDaySearchRange(day),
|
||||
},
|
||||
select: {
|
||||
@@ -123,8 +137,15 @@ async function isDuplicate(
|
||||
* 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, tz: string): string {
|
||||
const analyte = row.analyte.trim().toLowerCase();
|
||||
function inBatchKey(
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
biomarkerId: string | null,
|
||||
): string {
|
||||
// Two spellings of one marker in one document are one marker (D1223).
|
||||
const analyte = biomarkerId
|
||||
? `bm:${biomarkerId}`
|
||||
: row.analyte.trim().toLowerCase();
|
||||
const dayKey = labReadingDay(new Date(row.takenAt), tz);
|
||||
const valuePart =
|
||||
row.value !== undefined
|
||||
@@ -188,14 +209,59 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const writtenInBatch = new Set<string>();
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
// v1.43 (D1223) — the account's markers and aliases, loaded once: every row
|
||||
// resolves its name against them (name, alias, catalog abbreviation) the
|
||||
// way the review did, and a marker minted for one row is found by the next.
|
||||
const markerIndex = await loadMarkerIndex(prisma, user.id);
|
||||
let aliasesRemembered = 0;
|
||||
for (const row of parsed.data.rows) {
|
||||
const key = inBatchKey(row, tz);
|
||||
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) {
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
|
||||
// The marker the person picked on the review screen, if they picked one.
|
||||
let chosen: IndexedMarker | null = null;
|
||||
if (row.biomarkerId) {
|
||||
chosen =
|
||||
markerIndex.markers.find((m) => m.id === row.biomarkerId) ?? null;
|
||||
if (!chosen) {
|
||||
skipped.push({
|
||||
analyte: row.analyte.trim(),
|
||||
reason: "unknown_biomarker",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// The choice is remembered, so the next report spelled this way
|
||||
// matches on its own.
|
||||
if (row.rememberAlias !== false) {
|
||||
const wrote = await rememberImportAlias(
|
||||
prisma,
|
||||
user.id,
|
||||
chosen,
|
||||
row.analyte,
|
||||
);
|
||||
if (wrote) {
|
||||
addAliasToIndex(markerIndex, chosen.id, row.analyte);
|
||||
aliasesRemembered += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedId =
|
||||
chosen?.id ??
|
||||
resolveInIndex(
|
||||
markerIndex,
|
||||
row.analyte,
|
||||
isQualitative ? null : (row.unit ?? null),
|
||||
)?.marker.id ??
|
||||
null;
|
||||
|
||||
const key = inBatchKey(row, tz, resolvedId);
|
||||
if (
|
||||
writtenInBatch.has(key) ||
|
||||
(await isDuplicate(user.id, row, tz, resolvedId))
|
||||
) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
// The window the scan read off the report. The confirmed numeric bounds
|
||||
// win over a re-parse of the string (the human may have corrected them on
|
||||
// the review screen); the string rides along either way.
|
||||
@@ -203,14 +269,20 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const sourceLow = row.referenceLow ?? printed?.low ?? null;
|
||||
const sourceHigh = row.referenceHigh ?? printed?.high ?? null;
|
||||
const sourceText = printed?.text ?? null;
|
||||
const biomarker = await resolveOrMintBiomarker(user.id, {
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
const biomarker = chosen
|
||||
? chosen
|
||||
: await resolveOrMintBiomarker(
|
||||
user.id,
|
||||
{
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
},
|
||||
{ index: markerIndex },
|
||||
);
|
||||
|
||||
// The marker's unit is the one the reading is stored under. A row that
|
||||
// states another unit is converted when the marker's analyte is known and
|
||||
@@ -273,6 +345,8 @@ async function commitOcrRows(request: NextRequest) {
|
||||
});
|
||||
|
||||
writtenInBatch.add(key);
|
||||
// A freshly minted marker is now the row's; key the batch on it too.
|
||||
writtenInBatch.add(inBatchKey(row, tz, biomarker.id));
|
||||
inserted.push(serialiseLabResult(created, biomarker));
|
||||
linkable.push({
|
||||
labResultId: created.id,
|
||||
@@ -319,7 +393,11 @@ async function commitOcrRows(request: NextRequest) {
|
||||
await auditLog("labs.ocr.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count: inserted.length, skipped: skipped.length },
|
||||
details: {
|
||||
count: inserted.length,
|
||||
skipped: skipped.length,
|
||||
aliasesRemembered,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user