mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -1,14 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prisma = {
|
||||
biomarker: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
// v1.43 — the marker's other spellings, read and replaced with it.
|
||||
biomarkerAlias: {
|
||||
findMany: vi.fn(async () => []),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
biomarkerMerge: { findFirst: vi.fn(async () => null) },
|
||||
labResult: { deleteMany: vi.fn() },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
};
|
||||
// `$transaction` is set in `beforeEach`: the batch form for the delete, the
|
||||
// callback form for the edit.
|
||||
return { prisma: { ...prisma, $transaction: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -82,9 +96,13 @@ beforeEach(() => {
|
||||
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
|
||||
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
ops: Promise<unknown>[],
|
||||
) => Promise.all(ops)) as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: unknown) => Promise<unknown>)(prisma)
|
||||
: Promise.all(arg as Promise<unknown>[])) as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/biomarkers/[id]", () => {
|
||||
|
||||
@@ -7,19 +7,32 @@
|
||||
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
|
||||
* are deleted. A pair of units without a known conversion refuses the whole
|
||||
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
|
||||
* answers the same result and writes nothing. There is no undo.
|
||||
* answers the same result and writes nothing, refusals included, so the
|
||||
* preview can show them. There is no undo. See `src/lib/labs/biomarker-merge.ts`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
|
||||
* record-write ceiling. Until the merge lands the stub resolves the caller's
|
||||
* own record only.
|
||||
* Authority: `requireRecordAuth("manage", "labs")`, the same as the edit and
|
||||
* the delete beside it, because a merge rewrites and deletes markers. The
|
||||
* shared record-write ceiling is charged to the ACTOR (C1). Each deleted
|
||||
* source files what was destroyed (C3): its id and how many readings it took
|
||||
* along, never its name; the name stays in the merge ledger.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { destroyedDetails } from "@/lib/sharing/audit-details";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -32,10 +45,10 @@ async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
@@ -58,5 +71,74 @@ async function mergeBiomarkers(
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("labs.biomarker.merge");
|
||||
const outcome = await runMerge({
|
||||
userId: user.id,
|
||||
targetId: id,
|
||||
sourceIds: parsed.data.sourceIds,
|
||||
dryRun: parsed.data.dryRun,
|
||||
});
|
||||
|
||||
if (outcome.kind === "notFound") {
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
ids: outcome.ids,
|
||||
});
|
||||
}
|
||||
if (outcome.kind === "unitMismatch") {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.merge.unit-mismatch" },
|
||||
meta: { biomarkerId: id, refused: outcome.sources.length },
|
||||
});
|
||||
return apiError(
|
||||
"Some readings are in a unit that cannot be converted to the target's unit",
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.biomarker.merge.unitMismatch",
|
||||
sources: outcome.sources,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = outcome;
|
||||
annotate({
|
||||
action: {
|
||||
name: result.dryRun
|
||||
? "labs.biomarker.merge.preview"
|
||||
: "labs.biomarker.merge",
|
||||
},
|
||||
meta: {
|
||||
biomarkerId: id,
|
||||
sources: result.sources.length,
|
||||
movedReadings: result.movedReadings,
|
||||
convertedReadings: result.convertedReadings,
|
||||
},
|
||||
});
|
||||
if (result.dryRun) return apiSuccess(result);
|
||||
|
||||
invalidateUserHealthScore(user.id);
|
||||
const ipAddress = getClientIp(request);
|
||||
for (const source of result.sources) {
|
||||
await auditLog("labs.biomarker.merge", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
// IDs and numbers only. The source's name is not filed here; the merge
|
||||
// ledger keeps it with the row this audit points at.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
mergeId: result.mergeId,
|
||||
...destroyedDetails({
|
||||
model: "Biomarker",
|
||||
id: source.id,
|
||||
label: null,
|
||||
effectiveAt: new Date(),
|
||||
extra: {
|
||||
readingCount: source.readingCount,
|
||||
converted: source.conversion === "converted",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
}
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
aliasNamesByMarker,
|
||||
findAliasConflict,
|
||||
replaceAliases,
|
||||
} from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { mergedInto } from "@/lib/labs/biomarker-merge";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -38,37 +42,6 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
@@ -76,15 +49,23 @@ export const GET = apiHandler(
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
// v1.43 — a marker merged into another names where it went, so a
|
||||
// client holding the old id (a cached screen, a bookmark) can follow.
|
||||
const into = await mergedInto(user.id, id);
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
...(into ? { mergedInto: into } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, [row]);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.get" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(row));
|
||||
return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? []));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -171,6 +152,20 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the aliases, when sent, become the marker's whole set. A
|
||||
// spelling another marker already answers to (its name or one of its
|
||||
// aliases) is refused rather than silently moved.
|
||||
if (d.aliases !== undefined) {
|
||||
const conflict = await findAliasConflict(prisma, user.id, id, d.aliases);
|
||||
if (conflict) {
|
||||
return apiError("Another biomarker already answers to this name", 409, {
|
||||
errorCode: "labs.biomarker.alias.taken",
|
||||
alias: conflict.alias,
|
||||
biomarkerId: conflict.biomarkerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `context` / `panel` / a bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -212,7 +207,17 @@ export const PUT = apiHandler(
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await prisma.biomarker.update({ where: { id }, data });
|
||||
const aliasesBefore =
|
||||
(await aliasNamesByMarker(prisma, user.id, [existing])).get(id) ?? [];
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.biomarker.update({ where: { id }, data });
|
||||
if (d.aliases !== undefined) {
|
||||
await replaceAliases(tx, user.id, row, d.aliases);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const aliasesAfter =
|
||||
(await aliasNamesByMarker(prisma, user.id, [updated])).get(id) ?? [];
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("biomarker.update", {
|
||||
@@ -241,7 +246,13 @@ export const PUT = apiHandler(
|
||||
panel: updated.panel,
|
||||
hidden: updated.hidden,
|
||||
},
|
||||
redacted: d.context !== undefined ? ["context"] : [],
|
||||
redacted: [
|
||||
...(d.context !== undefined ? ["context"] : []),
|
||||
// Named, not quoted: the spellings are the person's own text.
|
||||
...(aliasesBefore.join("\n") !== aliasesAfter.join("\n")
|
||||
? ["aliases"]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -251,7 +262,7 @@ export const PUT = apiHandler(
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(updated));
|
||||
return apiSuccess(serialiseBiomarker(updated, aliasesAfter));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
decryptContextSoft,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -45,35 +44,6 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
// a bad-key / malformed row) so one un-decryptable row never 500s the whole
|
||||
// catalog — the single-resource GET is the authoritative read for the
|
||||
// plaintext context. This bounds the fail-closed blast radius to one marker.
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: decryptContextSoft(row.contextEncrypted),
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
@@ -84,12 +54,19 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: [{ hidden: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
// v1.43 — every marker's other spellings, one read for the whole list.
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, rows);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.list" },
|
||||
meta: { total: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
|
||||
return apiSuccess({
|
||||
biomarkers: rows.map((row) =>
|
||||
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
|
||||
@@ -179,5 +156,5 @@ async function postBiomarker(request: NextRequest) {
|
||||
meta: { biomarkerId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(created), 201);
|
||||
return apiSuccess(serialiseBiomarker(created, [], { soft: true }), 201);
|
||||
}
|
||||
@@ -100,7 +100,12 @@ async function confirmDocument(
|
||||
}
|
||||
|
||||
try {
|
||||
const ref = await commitApprovedFact(user.id, fact);
|
||||
// v1.43 (D1223) — an OBSERVATION may name the marker the person chose
|
||||
// on the review screen; the label is then remembered as its alias.
|
||||
const ref = await commitApprovedFact(user.id, fact, {
|
||||
biomarkerId: decision.biomarkerId,
|
||||
rememberAlias: decision.rememberAlias,
|
||||
});
|
||||
await prisma.extractedFact.update({
|
||||
where: { id: fact.id },
|
||||
data: {
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/documents/store", () => ({
|
||||
encryptFactData: vi.fn(() => new Uint8Array([1])),
|
||||
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
|
||||
serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
|
||||
markerIndexForFacts: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
loadDocumentChatText: vi.fn(),
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
@@ -241,7 +244,19 @@ async function runNow(
|
||||
origin: { ipAddress: getClientIp(request), worker: false },
|
||||
});
|
||||
if (!outcome.ok) return outcomeResponse(outcome);
|
||||
return apiSuccess(serialiseDocumentDetail(outcome.data, outcome.data.facts));
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
outcome.data,
|
||||
outcome.data.facts,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
await markerIndexForFacts(userId, outcome.data.facts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptFactData, serialiseFact } from "@/lib/documents/store";
|
||||
import {
|
||||
encryptFactData,
|
||||
markerIndexForFacts,
|
||||
serialiseFact,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
@@ -125,6 +129,8 @@ export const PATCH = apiHandler(
|
||||
meta: { documentId: id, factId: fact.id, factType: fact.factType },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseFact(updated));
|
||||
return apiSuccess(
|
||||
serialiseFact(updated, await markerIndexForFacts(user.id, [updated])),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
replaceVaccinationLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -138,6 +141,8 @@ export const GET = apiHandler(
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
|
||||
// v1.43 — the marker each pending lab value resolves to, or choices.
|
||||
await markerIndexForFacts(user.id, document.facts),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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