Merge branch 'v143/labs' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:06:51 +02:00
commit 74fa4e10b6
96 files changed
+6992 -626

No files matched your search

+1 -1
View File
@@ -89,7 +89,7 @@ const SERVER_FEATURES = {
// form work; until then a client must not send it (contract §0.6).
cumulativeHourBuckets: false,
syncTriggerBackfill: false,
biomarkerMerge: false,
biomarkerMerge: true,
lifeCircumstances: false,
} as const;
@@ -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]", () => {
+92 -10
View File
@@ -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);
}
+50 -39
View File
@@ -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));
},
);
+12 -35
View File
@@ -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])),
);
},
);
+6 -1
View File
@@ -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),
),
);
},
@@ -86,6 +86,8 @@ vi.mock("@/lib/db", () => ({
// helper now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
biomarkerAlias: { findMany: vi.fn() },
biomarkerMerge: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
@@ -135,6 +137,8 @@ beforeEach(() => {
// `resetAllMocks` clears the inline defaults, and the full-backup payload
// builder reads these unconditionally.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue(
[] as never,
);
@@ -67,6 +67,8 @@ vi.mock("@/lib/db", () => ({
cycleDayLog: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
biomarkerAlias: { findMany: vi.fn() },
biomarkerMerge: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
@@ -205,6 +207,8 @@ beforeEach(() => {
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
@@ -77,6 +77,8 @@ vi.mock("@/lib/db", () => ({
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
allergy: { findMany: vi.fn().mockResolvedValue([]) },
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
@@ -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);
+93 -15
View File
@@ -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({