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:
Marc-André Bombeck committed 2026-10-15 22:22:40 +02:00
1 parent a3ce6af9a3
commit 81be73fca8
42 files changed
+1645 -423

No files matched your search

+333 -229
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -9905,11 +9905,11 @@ model BiomarkerAlias {
id String @id @default(cuid())
userId String @map("user_id")
biomarkerId String @map("biomarker_id")
/// @pending-consumer(#v143-B3) The alias as written, shown on the marker.
/// The alias as written, shown on the marker.
name String @db.VarChar(120)
/// @pending-consumer(#v143-B3) `foldForMatch(name)`, the match key; unique per account.
/// `foldForMatch(name)`, the match key; unique per account.
nameFolded String @map("name_folded") @db.VarChar(120)
/// @pending-consumer(#v143-B3) `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
/// `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
source String
createdAt DateTime @default(now()) @map("created_at")
@@ -9927,15 +9927,15 @@ model BiomarkerAlias {
model BiomarkerMerge {
id String @id @default(cuid())
userId String @map("user_id")
/// @pending-consumer(#v143-B3) The marker the source was folded into.
/// The marker the source was folded into.
targetBiomarkerId String @map("target_biomarker_id")
/// @pending-consumer(#v143-B3) Id of the deleted source marker (no FK: the row is gone).
/// Id of the deleted source marker (no FK: the row is gone).
sourceBiomarkerId String @map("source_biomarker_id")
/// @pending-consumer(#v143-B3) The source marker's name at merge time.
/// The source marker's name at merge time.
sourceName String @map("source_name") @db.VarChar(120)
/// @pending-consumer(#v143-B3) How many readings moved from the source.
/// How many readings moved from the source.
readingCount Int @map("reading_count")
/// @pending-consumer(#v143-B3) When the merge ran.
/// When the merge ran.
mergedAt DateTime @default(now()) @map("merged_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -40,7 +40,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
@@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 24;
const REGISTER_SIZE = 23;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
@@ -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),
),
);
},
@@ -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({
@@ -205,6 +205,9 @@ describe("a reading in another unit than its marker's", () => {
markerUnit,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
});
const [same, other, fresh] = seedReviewRows(
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
@@ -271,6 +274,9 @@ describe("a reading in another unit that can be converted", () => {
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
};
const [converts, blocked] = seedReviewRows(
[dto, { ...dto, markerAnalyteKey: null }],
+14 -5
View File
@@ -34,8 +34,9 @@ import type {
/**
* Structured lab results over the window, reduced to the latest reading per
* analyte with a count, so the report is a concise panel rather than a raw
* dump. Analyte keys fold case-insensitively.
* marker with a count, so the report is a concise panel rather than a raw
* dump. Readings group by their biomarker and carry its name; a legacy
* unlinked reading groups by its own name, case-insensitively.
*/
export async function loadLabResults(
userId: string,
@@ -58,6 +59,8 @@ export async function loadLabResults(
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: { select: { name: true, panel: true } },
},
});
const byAnalyte = new Map<
@@ -65,7 +68,13 @@ export async function loadLabResults(
NonNullable<DoctorReportData["labResults"]>[number]
>();
for (const r of rows) {
const key = r.analyte.toLowerCase();
// One line per marker: readings filed under one biomarker are one series
// whatever each report printed (a merge keeps the printed `analyte` on
// every reading), and the marker's own name heads it. A legacy unlinked
// reading still groups by its name.
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const prev = byAnalyte.get(key);
// The row already stamps the catalog band it was written under; the
// reading's own report window, when present, is what the physician read
@@ -77,8 +86,8 @@ export async function loadLabResults(
r,
);
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
panel: r.biomarker?.panel ?? r.panel,
analyte: r.biomarker?.name ?? r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
+52 -9
View File
@@ -27,7 +27,11 @@ import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
import {
resolveOrMintBiomarker,
type MintedBiomarker,
} from "@/lib/labs/biomarker-store";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
isUnreadableRange,
@@ -67,9 +71,19 @@ function statedDateOrNow(date: string | null): Date {
return new Date();
}
/**
* v1.43 (D1223) — the marker the person picked for an OBSERVATION on the
* review screen, and whether to remember the label as its alias.
*/
export interface ObservationChoice {
biomarkerId?: string;
rememberAlias?: boolean;
}
async function commitObservation(
userId: string,
data: ObservationFactData,
choice: ObservationChoice = {},
): Promise<CommittedRecordRef> {
const isQualitative = typeof data.value !== "number";
// A numeric reading needs a unit so the catalog has something to mint with —
@@ -121,13 +135,41 @@ async function commitObservation(
});
}
const biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// The marker the person chose wins; otherwise the label resolves through
// the account's names, aliases and the catalog, or a marker is minted.
let biomarker: MintedBiomarker;
if (choice.biomarkerId) {
const chosen = await prisma.biomarker.findFirst({
where: { id: choice.biomarkerId, userId },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
},
});
if (!chosen) {
throw new FactCommitError(
"observation.unknownBiomarker",
"The chosen marker does not exist",
);
}
if (choice.rememberAlias !== false) {
await rememberImportAlias(prisma, userId, chosen, data.label);
}
biomarker = chosen;
} else {
biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
}
// A unit that differs from an EXISTING marker's is converted when the
// marker's analyte is known and the registry has the pair (a document states
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
@@ -274,12 +316,13 @@ async function commitMedication(
export async function commitApprovedFact(
userId: string,
fact: ExtractedFact,
choice: ObservationChoice = {},
): Promise<CommittedRecordRef> {
// Decrypt the staged payload at confirm time to write it into the normal
// structured store. Fail-closed: a bad key id throws and aborts the commit.
const data = decryptFactData(fact.dataEncrypted);
if (fact.factType === "OBSERVATION") {
return commitObservation(userId, data as ObservationFactData);
return commitObservation(userId, data as ObservationFactData, choice);
}
if (fact.factType === "CONDITION") {
return commitCondition(userId, data as ConditionFactData);
+48 -5
View File
@@ -16,7 +16,13 @@ import type {
InboundDocument,
InboundDocumentStatus,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { servingClassFor } from "@/lib/documents/upload-policy";
import {
loadMarkerIndex,
matchForReview,
type MarkerIndex,
} from "@/lib/labs/biomarker-resolve";
import {
toDocumentSourceSystem,
toIndexOutcome,
@@ -32,6 +38,7 @@ import type {
FactProvenance,
InboundDocumentDetailDto,
InboundDocumentDto,
ObservationFactData,
InboundDocumentKindValue,
InboundFactType,
} from "@/lib/validations/inbound-documents";
@@ -254,19 +261,54 @@ export function serialiseDocument(
};
}
/** Map a persisted staged-fact row to the review DTO. */
export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
return {
/**
* Map a persisted staged-fact row to the review DTO.
*
* v1.43 (D1223) — with the account's `markerIndex`, a pending OBSERVATION
* also says which marker its label resolves to (`biomarkerId`, `matchKind`)
* or offers up to three (`suggestions`), the same resolution the confirm
* applies. Without an index, or for any other fact, the fields are absent.
*/
export function serialiseFact(
fact: ExtractedFact,
markerIndex?: MarkerIndex | null,
): ExtractedFactDto {
const data = decryptFactData(fact.dataEncrypted);
const dto: ExtractedFactDto = {
id: fact.id,
factType: fact.factType as InboundFactType,
status: fact.status as ExtractedFactStatus,
confidence: fact.confidence,
needsReview: fact.needsReview,
data: decryptFactData(fact.dataEncrypted),
data,
provenance: decryptFactProvenance(fact.provenanceEncrypted),
committedRecordId: fact.committedRecordId,
committedRecordType: fact.committedRecordType,
};
if (markerIndex && fact.factType === "OBSERVATION") {
const observation = data as ObservationFactData;
const isNumeric = typeof observation.value === "number";
const { match, suggestions } = matchForReview(
markerIndex,
observation.label ?? "",
isNumeric ? (observation.unit ?? null) : null,
);
dto.biomarkerId = match?.marker.id ?? null;
dto.matchKind = match?.matchKind ?? null;
dto.suggestions = suggestions;
}
return dto;
}
/** The marker index a review needs, or null when no fact would use it. */
export async function markerIndexForFacts(
userId: string,
facts: readonly Pick<ExtractedFact, "factType" | "status">[],
): Promise<MarkerIndex | null> {
const needed = facts.some(
(f) => f.factType === "OBSERVATION" && f.status === "PENDING",
);
return needed ? loadMarkerIndex(prisma, userId) : null;
}
/** Assemble the detail DTO (document + its staged facts + links). */
@@ -279,6 +321,7 @@ export function serialiseDocumentDetail(
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
vaccinationLinks: DocumentVaccinationLinkDto[] | null = [],
markerIndex: MarkerIndex | null = null,
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
@@ -327,7 +370,7 @@ export function serialiseDocumentDetail(
hasThumbnail,
encounterLinks,
),
facts: facts.map(serialiseFact),
facts: facts.map((fact) => serialiseFact(fact, markerIndex)),
vaccinationLinks,
summary,
summaryGeneratedAt: doc.summaryGeneratedAt
@@ -73,6 +73,8 @@ function buildMockPrisma() {
},
]),
},
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: {
findMany: vi.fn().mockResolvedValue([
{
@@ -160,6 +160,8 @@ function sourceClient() {
// Records section: empty, exercised by its own suite.
labResult: empty,
biomarker: empty,
biomarkerAlias: empty,
biomarkerMerge: empty,
illnessEpisode: empty,
allergy: empty,
familyHistoryEntry: empty,
+8 -4
View File
@@ -576,6 +576,14 @@ export const TWO_ENDED_MODELS = [
// questionnaire over every module decision taken in Settings since. It is
// carried and asserted, not defaulted.
"OnboardingRecord",
// v1.43 (D1223) — a marker's other spellings ride on the marker as
// `aliases[]`, and the merge ledger travels as its own list whose target is
// resolved by id or by name like a lab result's marker. Without the aliases a
// restored account is asked again at the next scan which marker each
// spelling belongs to; without the ledger a link to a merged marker reads as
// a plain 404 instead of leading to the marker it went into.
"BiomarkerAlias",
"BiomarkerMerge",
] as const;
/** One model claimed to travel both ways. */
@@ -614,10 +622,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// v1.43 — the new tables are declared here with the schema and move to
// TWO_ENDED_MODELS when their section writer and restore branch land.
BiomarkerAlias:
"Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.",
BiomarkerMerge:
"The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.",
MedicationIngredient:
"What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.",
DawarichDay:
+77
View File
@@ -126,10 +126,38 @@ export interface BiomarkerBackupEntry {
context: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
* v1.43 (D1223) — the other spellings the marker answers to, with where
* each came from. Absent on files written before them.
*/
aliases?: BiomarkerAliasBackupEntry[];
createdAt?: string;
updatedAt?: string;
}
export interface BiomarkerAliasBackupEntry {
/** Present in canonical DR payloads. */
id?: string;
name: string;
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
createdAt?: string;
}
/**
* v1.43 (D1223) — one marker folded into another. The target travels by id
* in a disaster-recovery file and by name in both, the way a lab result's
* marker does; the source is gone, so its id and name are the record.
*/
export interface BiomarkerMergeBackupEntry {
id?: string;
targetBiomarkerId?: string;
targetName: string;
sourceBiomarkerId: string;
sourceName: string;
readingCount: number;
mergedAt: string;
}
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
@@ -237,6 +265,7 @@ type CanonicalAllergy = AllergyDTO & {
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
biomarkerMerges: BiomarkerMergeBackupEntry[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
@@ -286,6 +315,8 @@ export async function buildRecordsBackupSection(
PrismaClient,
| "labResult"
| "biomarker"
| "biomarkerAlias"
| "biomarkerMerge"
| "illnessEpisode"
| "allergy"
| "familyHistoryEntry"
@@ -351,6 +382,8 @@ export async function buildRecordsBackupSection(
const [
labResultRows,
biomarkerRows,
biomarkerAliasRows,
biomarkerMergeRows,
episodeRows,
allergyRows,
familyRows,
@@ -366,6 +399,22 @@ export async function buildRecordsBackupSection(
where: { userId },
orderBy: { name: "asc" },
}),
prisma.biomarkerAlias.findMany({
where: { userId },
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
select: {
id: true,
biomarkerId: true,
name: true,
source: true,
createdAt: true,
},
}),
prisma.biomarkerMerge.findMany({
where: { userId },
orderBy: { mergedAt: "asc" },
include: { target: { select: { name: true } } },
}),
prisma.illnessEpisode.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
@@ -429,6 +478,32 @@ export async function buildRecordsBackupSection(
biomarkerName: r.biomarker?.name ?? null,
}));
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
for (const alias of biomarkerAliasRows) {
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
list.push({
name: alias.name,
source: alias.source as BiomarkerAliasBackupEntry["source"],
...(disasterRecovery
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
: {}),
});
aliasesByMarker.set(alias.biomarkerId, list);
}
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
(m) => ({
...(disasterRecovery
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
: {}),
targetName: m.target.name,
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt.toISOString(),
}),
);
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
@@ -449,6 +524,7 @@ export async function buildRecordsBackupSection(
decryptContextFromBytes,
),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
@@ -598,6 +674,7 @@ export async function buildRecordsBackupSection(
return {
labResults,
biomarkers,
biomarkerMerges,
illnessEpisodes,
allergies,
familyHistory,
+71
View File
@@ -72,6 +72,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import type { BackupMeasurement } from "@/lib/validations/backup";
import {
isCompatibleSchemaVersion,
@@ -2076,6 +2077,76 @@ export async function restoreBackup(
});
biomarkerByName.set(biomarker.name, created.id);
restoredBiomarkerIds.add(created.id);
// v1.43 (D1223) — the other spellings, folded the way the resolver
// reads them, so the next scan matches them again.
const aliasRows = (biomarker.aliases ?? [])
.map((alias) => ({
id: alias.id,
name: alias.name.trim().slice(0, 120),
nameFolded: foldForMatch(alias.name).slice(0, 120),
source: alias.source,
createdAt: alias.createdAt,
}))
.filter(
(alias) =>
alias.nameFolded !== "" &&
alias.nameFolded !== foldForMatch(biomarker.name),
);
if (aliasRows.length > 0) {
await tx.biomarkerAlias.createMany({
data: aliasRows.map((alias) => ({
...(alias.id ? { id: alias.id } : {}),
userId: ownerId,
biomarkerId: created.id,
name: alias.name,
nameFolded: alias.nameFolded,
source: alias.source,
...(alias.createdAt
? { createdAt: new Date(alias.createdAt) }
: {}),
})),
// One spelling answers to one marker per account; a file that
// names it twice keeps the first.
skipDuplicates: true,
});
}
}
// v1.43 (D1223) — the merge ledger, against the markers just written:
// by id where the file carries one this restore kept, by name
// otherwise. A row whose target the file does not carry has nothing
// to point at and is left out (counted on the event).
let unresolvedMerges = 0;
const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => {
const targetId =
merge.targetBiomarkerId &&
restoredBiomarkerIds.has(merge.targetBiomarkerId)
? merge.targetBiomarkerId
: biomarkerByName.get(merge.targetName);
if (!targetId) {
unresolvedMerges += 1;
return [];
}
return [
{
...(merge.id ? { id: merge.id } : {}),
userId: ownerId,
targetBiomarkerId: targetId,
sourceBiomarkerId: merge.sourceBiomarkerId,
sourceName: merge.sourceName,
readingCount: merge.readingCount,
mergedAt: new Date(merge.mergedAt),
},
];
});
if (mergeRows.length > 0) {
await tx.biomarkerMerge.createMany({ data: mergeRows });
}
if (unresolvedMerges > 0) {
annotate({
action: { name: "backup.restore.biomarkerMerge.unresolved" },
meta: { count: unresolvedMerges },
});
}
// Collected for the staged facts further down: an approved fact's
+3 -3
View File
@@ -35,9 +35,9 @@ export function labObservations(
const observations: FhirObservation[] = [];
for (const lab of data.labResults ?? []) {
// Forward-compat: when the DTO carries a resolved canonical analyte name,
// thread it as the third arg so the catalog name wins over the free-text
// `analyte`. Absent today.
// `analyte` is the marker's own name (v1.43): readings of one biomarker
// are one Observation whatever each report printed, so two spellings
// merged into one marker never reach a receiver as two analytes.
const coding = resolveLabCoding(lab.analyte, lab.unit);
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
const code = coding
+56
View File
@@ -0,0 +1,56 @@
/**
* The `Biomarker` DTO, one shape for the list, the detail, the edit and the
* merge result. The encrypted context bytes are never echoed: `hasContext`
* flags them and `context` carries the plaintext. `aliases` are the other
* spellings the marker answers to (v1.43), sorted, without its own name.
*/
import {
decryptContextFromBytes,
decryptContextSoft,
} from "@/lib/labs/biomarker-store";
export interface BiomarkerDtoRow {
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;
}
/**
* `soft` decrypts fail-soft (the list: one bad row must not 500 the catalog);
* otherwise a decrypt failure throws so a key gap surfaces on the detail.
*/
export function serialiseBiomarker(
row: BiomarkerDtoRow,
aliases: readonly string[],
options: { soft?: boolean } = {},
) {
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: options.soft
? decryptContextSoft(row.contextEncrypted)
: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
hidden: row.hidden,
aliases: [...aliases],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export type BiomarkerDto = ReturnType<typeof serialiseBiomarker>;
+388
View File
@@ -0,0 +1,388 @@
/**
* Merge markers into one (D1223): "Alk.P" and "Alkaline Phosphatase" become
* one marker with one history.
*
* The plan and the write are one function, so a dry run answers exactly what
* the merge would do: it reads the same rows, converts the same values and
* returns the same numbers, and only the write step is skipped.
*
* What a merge does, in one transaction:
* - every reading of every source moves to the target. A reading in a unit
* other than the target's is converted with the target's analyte
* (`convertLabValue`), together with the windows stamped on it; the value
* and unit the report printed stay in `sourceValue` / `sourceUnit` (a
* reading that was already converted keeps its original pair). The
* reading's own `analyte` stays the name that was printed.
* - a pair of units the registry cannot convert refuses the WHOLE merge;
* nothing moves, not even the sources that would convert;
* - medication efficacy targets follow their marker;
* - each source's name and its aliases become aliases of the target
* (`MERGE`), unless the target's name or another marker already answers
* to that spelling;
* - one `biomarker_merges` row per source, and the rows that already pointed
* at a source now point at the target, so an id merged twice still finds
* where it went;
* - the sources are deleted.
*
* There is no undo. The ledger keeps what a later split would need to know
* (which marker, which name, how many readings).
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { matchAnalyteKey } from "./analyte-match";
import {
BIOMARKER_ALIAS_MAX_LENGTH,
aliasNamesByMarker,
} from "./biomarker-aliases";
import { serialiseBiomarker, type BiomarkerDto } from "./biomarker-dto";
import {
convertLabRange,
convertLabValue,
type ConversionRefusal,
} from "./unit-conversion/convert";
import { sameLabUnit } from "./unit-normalise";
export type MergeConversion = "none" | "converted" | "refused";
export interface MergeSourceSummary {
id: string;
name: string;
unit: string;
readingCount: number;
conversion: MergeConversion;
refusal: ConversionRefusal | null;
}
export interface BiomarkerMergeResult {
dryRun: boolean;
target: BiomarkerDto;
sources: MergeSourceSummary[];
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
mergeId: string | null;
}
export type BiomarkerMergeOutcome =
| { kind: "ok"; result: BiomarkerMergeResult }
| { kind: "notFound"; ids: string[] }
| {
kind: "unitMismatch";
sources: Array<{
id: string;
unit: string;
targetUnit: string;
reason: ConversionRefusal;
}>;
};
interface ReadingUpdate {
id: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceValue: number;
sourceUnit: string;
}
/** Thrown inside the transaction to roll it back with the refusal. */
class MergeRefused extends Error {
constructor(readonly outcome: BiomarkerMergeOutcome) {
super("merge refused");
}
}
export async function mergeBiomarkers(input: {
userId: string;
targetId: string;
sourceIds: readonly string[];
dryRun: boolean;
}): Promise<BiomarkerMergeOutcome> {
try {
return await prisma.$transaction(
(tx) => runMerge(tx, input),
// A source with a long history updates row by row where it converts.
{ timeout: 60_000, maxWait: 10_000 },
);
} catch (err) {
if (err instanceof MergeRefused) return err.outcome;
throw err;
}
}
async function runMerge(
tx: Prisma.TransactionClient,
input: {
userId: string;
targetId: string;
sourceIds: readonly string[];
dryRun: boolean;
},
): Promise<BiomarkerMergeOutcome> {
const { userId, targetId, sourceIds, dryRun } = input;
const markers = await tx.biomarker.findMany({
where: { userId, id: { in: [targetId, ...sourceIds] } },
});
const byId = new Map(markers.map((m) => [m.id, m]));
const missing = [targetId, ...sourceIds].filter((id) => !byId.has(id));
if (missing.length > 0) return { kind: "notFound", ids: missing };
const target = byId.get(targetId)!;
const sources = sourceIds.map((id) => byId.get(id)!);
// The target's analyte, derived the way every write path derives it, so a
// stale stored key cannot pick the wrong factor.
const analyteKey = matchAnalyteKey({ name: target.name, unit: target.unit });
const readings = await tx.labResult.findMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
select: {
id: true,
biomarkerId: true,
value: true,
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceValue: true,
sourceUnit: true,
deletedAt: true,
},
});
const summaries: MergeSourceSummary[] = [];
const updates: ReadingUpdate[] = [];
let movedReadings = 0;
// Counted like `readingCount`: the live readings a person sees. A tombstoned
// reading moves and converts with them, so an undo brings it back right.
let convertedLive = 0;
for (const source of sources) {
const own = readings.filter((r) => r.biomarkerId === source.id);
let converted = 0;
let refusal: ConversionRefusal | null = null;
for (const reading of own) {
// A qualitative reading, or one already in the target's unit, moves as
// it is.
if (reading.value === null || sameLabUnit(reading.unit, target.unit)) {
continue;
}
const value = convertLabValue({
analyteKey,
value: reading.value,
from: reading.unit,
to: target.unit,
});
const stamped = convertLabRange({
analyteKey,
low: reading.referenceLow,
high: reading.referenceHigh,
from: reading.unit,
to: target.unit,
});
const printed = convertLabRange({
analyteKey,
low: reading.sourceReferenceLow,
high: reading.sourceReferenceHigh,
from: reading.unit,
to: target.unit,
});
if (!value.ok || !stamped.ok || !printed.ok) {
refusal ??= !value.ok
? value.reason
: !stamped.ok
? stamped.reason
: !printed.ok
? printed.reason
: "unknown_unit";
continue;
}
converted += 1;
if (reading.deletedAt === null) convertedLive += 1;
updates.push({
id: reading.id,
value: value.value,
unit: target.unit,
referenceLow: stamped.low,
referenceHigh: stamped.high,
sourceReferenceLow: printed.low,
sourceReferenceHigh: printed.high,
// A reading converted on its way in keeps what the report printed.
sourceValue: reading.sourceValue ?? reading.value,
sourceUnit: reading.sourceUnit ?? reading.unit.trim(),
});
}
const live = own.filter((r) => r.deletedAt === null).length;
movedReadings += live;
summaries.push({
id: source.id,
name: source.name,
unit: source.unit,
readingCount: live,
conversion: refusal ? "refused" : converted > 0 ? "converted" : "none",
refusal,
});
}
const convertedReadings = convertedLive;
// The spellings the target gains: each source's name and its aliases, once
// each, except what the target's name already is or another marker (not a
// source) already answers to.
const sourceAliases = await tx.biomarkerAlias.findMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
select: { id: true, name: true, nameFolded: true },
});
const otherAliases = await tx.biomarkerAlias.findMany({
where: { userId, biomarkerId: { notIn: [...sourceIds] } },
select: { biomarkerId: true, nameFolded: true },
});
const blocked = new Set(
otherAliases
.filter((a) => a.biomarkerId !== targetId)
.map((a) => a.nameFolded),
);
const targetAliasFolds = new Set(
otherAliases
.filter((a) => a.biomarkerId === targetId)
.map((a) => a.nameFolded),
);
const otherMarkerFolds = new Set(
(
await tx.biomarker.findMany({
where: { userId, id: { notIn: [targetId, ...sourceIds] } },
select: { name: true },
})
).map((m) => foldForMatch(m.name)),
);
const targetFold = foldForMatch(target.name);
const added = new Map<string, string>();
const consider = (name: string) => {
const folded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
if (!folded || folded === targetFold) return;
if (targetAliasFolds.has(folded) || added.has(folded)) return;
if (blocked.has(folded) || otherMarkerFolds.has(folded)) return;
added.set(folded, name.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH));
};
for (const source of sources) consider(source.name);
for (const alias of sourceAliases) consider(alias.name);
const aliasesAdded = [...added.values()];
const refused = summaries.filter((s) => s.conversion === "refused");
if (!dryRun && refused.length > 0) {
throw new MergeRefused({
kind: "unitMismatch",
sources: refused.map((s) => ({
id: s.id,
unit: s.unit,
targetUnit: target.unit,
reason: s.refusal ?? "unknown_unit",
})),
});
}
let mergeId: string | null = null;
if (!dryRun) {
for (const update of updates) {
await tx.labResult.update({
where: { id: update.id },
data: {
biomarkerId: targetId,
value: update.value,
unit: update.unit,
referenceLow: update.referenceLow,
referenceHigh: update.referenceHigh,
sourceReferenceLow: update.sourceReferenceLow,
sourceReferenceHigh: update.sourceReferenceHigh,
sourceValue: update.sourceValue,
sourceUnit: update.sourceUnit,
},
});
}
await tx.labResult.updateMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
data: { biomarkerId: targetId },
});
await tx.medicationEfficacyTarget.updateMany({
where: { biomarkerId: { in: [...sourceIds] } },
data: { biomarkerId: targetId },
});
// The sources' own alias rows go with the sources; the spellings that
// survive are written again on the target below.
await tx.biomarkerAlias.deleteMany({
where: { userId, id: { in: sourceAliases.map((a) => a.id) } },
});
if (added.size > 0) {
await tx.biomarkerAlias.createMany({
data: [...added].map(([nameFolded, name]) => ({
userId,
biomarkerId: targetId,
name,
nameFolded,
source: "MERGE",
})),
});
}
await tx.biomarkerMerge.updateMany({
where: { userId, targetBiomarkerId: { in: [...sourceIds] } },
data: { targetBiomarkerId: targetId },
});
const now = new Date();
for (const summary of summaries) {
const row = await tx.biomarkerMerge.create({
data: {
userId,
targetBiomarkerId: targetId,
sourceBiomarkerId: summary.id,
sourceName: summary.name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
readingCount: summary.readingCount,
mergedAt: now,
},
select: { id: true },
});
mergeId ??= row.id;
}
await tx.biomarker.deleteMany({
where: { userId, id: { in: [...sourceIds] } },
});
}
const after = dryRun
? target
: await tx.biomarker.findUniqueOrThrow({ where: { id: targetId } });
const aliases = await aliasNamesByMarker(tx, userId, [after]);
return {
kind: "ok",
result: {
dryRun,
target: serialiseBiomarker(after, aliases.get(after.id) ?? []),
sources: summaries,
movedReadings,
convertedReadings,
aliasesAdded,
mergeId,
},
};
}
/**
* Where a merged marker went: the target of the newest merge that removed
* `id`, or null. Chains resolve because a merge re-points the rows that named
* its sources.
*/
export async function mergedInto(
userId: string,
id: string,
): Promise<string | null> {
const row = await prisma.biomarkerMerge.findFirst({
where: { userId, sourceBiomarkerId: id },
orderBy: { mergedAt: "desc" },
select: { targetBiomarkerId: true },
});
return row?.targetBiomarkerId ?? null;
}
+25 -7
View File
@@ -29,6 +29,11 @@ import {
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import {
loadMarkerIndex,
matchForReview,
type MarkerIndex,
} from "@/lib/labs/biomarker-resolve";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { annotate } from "@/lib/logging/context";
import {
@@ -138,16 +143,18 @@ export async function annotateRow(
row: ExtractedRow,
reportDate: string | null,
tz: string,
/** The account's markers and aliases, loaded once per extraction. */
markerIndex?: MarkerIndex,
): Promise<OcrExtractedRowDto> {
const analyte = row.analyte.trim();
const takenAt = normaliseDate(row.takenAt) ?? reportDate;
const printed = parseReferenceRange(row.referenceText, row.unit);
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
where: { userId, name: { equals: analyte, mode: "insensitive" } },
select: { id: true, name: true, unit: true },
});
// v1.43 (D1223) — the marker this name resolves to (name, alias, catalog),
// exactly as the commit resolves it, or up to three to choose from.
const index = markerIndex ?? (await loadMarkerIndex(prisma, userId));
const { match, suggestions } = matchForReview(index, analyte, row.unit);
const existingBiomarker = match?.marker ?? null;
// The analyte the marker is, so the review can say whether a row in another
// unit converts. Derived from the name and unit exactly as the commit derives
// it (a stored key is only that derivation, written down), so the review and
@@ -171,7 +178,12 @@ export async function annotateRow(
where: {
userId,
deletedAt: null,
analyte: { equals: analyte, mode: "insensitive" },
// The printed name, or any reading already filed under the marker it
// resolves to: an earlier report may have spelled it another way.
OR: [
{ analyte: { equals: analyte, mode: "insensitive" } },
...(existingBiomarker ? [{ biomarkerId: existingBiomarker.id }] : []),
],
takenAt: labReadingDaySearchRange(takenAt),
},
select: {
@@ -235,6 +247,9 @@ export async function annotateRow(
markerUnit: existingBiomarker?.unit ?? null,
markerAnalyteKey,
duplicateOf,
biomarkerId: existingBiomarker?.id ?? null,
matchKind: match?.matchKind ?? null,
suggestions,
};
}
@@ -322,11 +337,12 @@ export async function runOcrExtraction(
// hearing about it. A count only: the string is the user's document.
let unreadableRanges = 0;
const tz = await resolveUserTimezone(args.userId);
const markerIndex = await loadMarkerIndex(prisma, args.userId);
for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) {
if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) {
unreadableRanges += 1;
}
rows.push(await annotateRow(args.userId, row, reportDate, tz));
rows.push(await annotateRow(args.userId, row, reportDate, tz, markerIndex));
}
annotate({
@@ -337,6 +353,8 @@ export async function runOcrExtraction(
rows: rows.length,
duplicates: rows.filter((r) => r.duplicateOf !== null).length,
newBiomarkers: rows.filter((r) => r.biomarkerMatch === "new").length,
aliasMatches: rows.filter((r) => r.matchKind === "alias").length,
catalogMatches: rows.filter((r) => r.matchKind === "catalog").length,
unreadableRanges,
},
});
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
})),
},
labResult: { findMany: vi.fn(async () => [{ analyte: "LDL" }]) },
biomarker: { findMany: vi.fn(async () => []) },
encounter: {
findMany: vi.fn(async () => [
{
@@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn(), findFirst: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn(async () => []) },
// v1.30 (G1) — the nutrients pipeline.
nutrientIntakeDay: {
findMany: vi.fn(async () => []),
@@ -13,6 +14,9 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: vi.fn(),
}));
@@ -284,15 +288,25 @@ describe("argument completion (user-scoped, doubles as discovery)", () => {
});
it("completes analytes from ONLY the user's own lab set", async () => {
// One name per marker: the markers with readings, then the names of
// readings filed under no marker (v1.43).
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([
{ name: "LDL" },
] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{ analyte: "LDL" },
{ analyte: "HDL" },
{ analyte: "ldl" },
] as never);
const complete = template("lab").complete!.analyte;
const result = await complete(CTX, "ld");
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", results: { some: { deletedAt: null } } },
}),
);
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
where: { userId: "user-1", deletedAt: null, biomarkerId: null },
distinct: ["analyte"],
}),
);
+5
View File
@@ -27,6 +27,11 @@ const { labResult, measurement, user } = vi.hoisted(() => ({
// narrated sentence, which reads the stored preference.
user: { findUnique: vi.fn(async () => ({ locale: "de" })) },
}));
// v1.43 — a lab name resolves to a marker through the account's names and
// aliases; no marker resolves here, so the reads keep their text match.
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } }));
import {
+5
View File
@@ -23,6 +23,11 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/record-search")>()),
loadRecordCandidates: vi.fn(async () => []),
}));
// v1.43 — a lab name resolves to a marker through the account's names and
// aliases; no marker resolves here, so the reads keep their text match.
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
// v1.22.0 — `search` reads the record directly via Prisma; stub it so the
// registry-wide loops never reach a DB.
vi.mock("@/lib/db", () => ({
+36 -8
View File
@@ -26,6 +26,7 @@
* external-assistant boundary.
*/
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { resolveMedicationCategories } from "@/lib/medication-category";
import { dayKeyOfDate } from "@/lib/medications/course-window";
@@ -397,13 +398,35 @@ async function completeMetric(
async function userAnalytes(ctx: McpAuthContext): Promise<string[]> {
// A switched-off labs module lists and completes nothing (v1.39.3).
if (!(await isModuleEnabled(ctx.userId, "labs"))) return [];
const rows = await prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
});
return rows.map((r) => r.analyte);
// One name per marker (v1.43): readings filed under a biomarker list as the
// marker's name, whatever each report printed, so two spellings merged into
// one marker are one resource. A legacy unlinked reading keeps its own name.
const [markers, unlinked] = await Promise.all([
prisma.biomarker.findMany({
where: {
userId: ctx.userId,
results: { some: { deletedAt: null } },
},
select: { name: true },
orderBy: { name: "asc" },
take: 200,
}),
prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null, biomarkerId: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
}),
]);
const names = new Map<string, string>();
for (const name of [
...markers.map((m) => m.name),
...unlinked.map((r) => r.analyte),
]) {
const key = name.trim().toLowerCase();
if (!names.has(key)) names.set(key, name);
}
return [...names.values()].slice(0, 200);
}
async function completeAnalyte(
@@ -514,7 +537,12 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
if (!(await isModuleEnabled(ctx.userId, "labs"))) {
return MODULE_DISABLED;
}
const analyte = decodeURIComponent(firstVar(variables.analyte));
const asked = decodeURIComponent(firstVar(variables.analyte));
// An alias or a catalog abbreviation reads as the marker's own name.
const marker = asked.trim()
? await resolveMarkerForRead(ctx.userId, asked)
: null;
const analyte = marker?.name ?? asked;
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_labs",
+27 -11
View File
@@ -31,6 +31,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { windowWeighting } from "@/lib/measurements/day-statistic";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
@@ -1400,18 +1401,33 @@ export async function getLabHistory(
: LAB_HISTORY_MAX_LIMIT;
const limit = Math.min(LAB_HISTORY_MAX_LIMIT, rawLimit);
// Match the analyte either on the LabResult's own `analyte` column or on the
// linked Biomarker's canonical name. `take: limit + 1` peeks one row past the
// page to decide whether a `nextCursor` is warranted. Field-by-field `where`.
// A name that resolves to one marker (its name, an alias of the account, a
// catalog abbreviation) reads that marker's whole series, merged spellings
// included (v1.43). Anything else matches the analyte either on the
// LabResult's own `analyte` column, on the linked Biomarker's canonical name
// or on one of its aliases. `take: limit + 1` peeks one row past the page to
// decide whether a `nextCursor` is warranted. Field-by-field `where`.
const marker = await resolveMarkerForRead(userId, needle);
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
OR: [
{ analyte: { contains: needle, mode: "insensitive" } },
{ biomarker: { name: { contains: needle, mode: "insensitive" } } },
],
},
where: marker
? { userId, deletedAt: null, biomarkerId: marker.id }
: {
userId,
deletedAt: null,
OR: [
{ analyte: { contains: needle, mode: "insensitive" } },
{
biomarker: { name: { contains: needle, mode: "insensitive" } },
},
{
biomarker: {
aliases: {
some: { name: { contains: needle, mode: "insensitive" } },
},
},
},
],
},
orderBy: { takenAt: "desc" },
skip: offset,
take: limit + 1,
+13 -4
View File
@@ -20,6 +20,7 @@
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { annotate } from "@/lib/logging/context";
import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions";
import { readDayForTool } from "@/lib/ai/coach/tools/day-read";
@@ -1510,10 +1511,18 @@ function readLabs(
limit: LAB_HISTORY_MAX_LIMIT,
});
}
// Default: latest reading per biomarker via the Coach labs read.
return runCoachTool(ctx, "get_labs", {
...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}),
});
// Default: latest reading per biomarker via the Coach labs read. A name that
// resolves to one of the user's markers (an alias or a catalog abbreviation
// such as "GPT" for "ALT") is read as that marker's own name (v1.43).
return (async () => {
const asked = typeof args.analyte === "string" ? args.analyte : null;
const marker = asked?.trim()
? await resolveMarkerForRead(ctx.userId, asked)
: null;
return runCoachTool(ctx, "get_labs", {
...(asked !== null ? { analyte: marker?.name ?? asked } : {}),
});
})();
}
export const MCP_TOOLS: McpToolDefinition[] = [
+2
View File
@@ -216,9 +216,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"insights.generate.scopeChanged",
],
labs: [
"labs.biomarker.alias.taken",
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.merge.invalid",
"labs.biomarker.merge.unitMismatch",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
+19 -6
View File
@@ -29,7 +29,6 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -89,6 +88,14 @@ const conflict = {
},
};
const updateConflict = {
"409": {
description:
"`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; `meta.alias` names the spelling and `meta.biomarkerId` the marker that answers to it.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description:
@@ -184,7 +191,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Fetch a single biomarker",
description:
"Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.",
"Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
@@ -204,7 +211,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Edit a biomarker",
description:
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.",
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Since v1.43 `aliases` (0..50, each 1..120 characters) replaces the marker's set of other spellings: listed spellings stay or are added, unlisted ones are removed; omitting the field leaves them as they are. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
@@ -220,7 +227,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...conflict,
...updateConflict,
...notFound,
...stdResponses,
},
@@ -263,8 +270,10 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "The merge result (or the dry-run preview).",
description:
'The merge result, or the dry-run preview. A preview reports a source that cannot be converted with `conversion: "refused"` and its `refusal`; the same request without `dryRun` answers 422 `labs.biomarker.merge.unitMismatch`.',
content: {
"application/json": {
schema: dataEnvelope(
@@ -276,8 +285,12 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...notFound,
...stdResponses,
"422": {
description:
"`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in `details.issues[]`). `labs.biomarker.merge.unitMismatch`: a source holds readings in a unit the registry cannot convert to the target's; `meta.sources` lists each `{ id, unit, targetUnit, reason }` with `reason` one of `unknown_analyte`, `unknown_unit`, `not_finite`. Nothing was moved.",
content: { "application/json": { schema: errorEnvelope } },
},
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
+6 -4
View File
@@ -177,10 +177,12 @@ const commitResponse = z
skipped: z.array(
z.object({
analyte: z.string(),
reason: z.enum(["duplicate", "unit_mismatch"]).meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.",
}),
reason: z
.enum(["duplicate", "unit_mismatch", "unknown_biomarker"])
.meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`. `unknown_biomarker` (v1.43): the row named a `biomarkerId` that is not one of the record's markers.",
}),
}),
),
outcome: z.enum(["empty", "failed", "partial", "success"]).meta({
+29
View File
@@ -1134,11 +1134,38 @@ const biomarkerBackupSchema = z
// v1.42 (#1095) — the catalogue analyte the marker resolves to.
analyteKey: z.string().nullable().optional(),
context: z.string().nullable().optional(),
// v1.43 (D1223) — the other spellings the marker answers to. Absent on
// files written before them.
aliases: z
.array(
z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1).max(120),
source: z.enum(["MERGE", "IMPORT_CHOICE", "MANUAL"]),
createdAt: isoDateTime.optional(),
})
.passthrough(),
)
.optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
// v1.43 (D1223) — the ledger of markers merged into others.
const biomarkerMergeBackupSchema = z
.object({
id: z.string().min(1).optional(),
targetBiomarkerId: z.string().min(1).optional(),
targetName: z.string().min(1),
sourceBiomarkerId: z.string().min(1),
sourceName: z.string().min(1).max(120),
readingCount: z.number().int().min(0),
mergedAt: isoDateTime,
})
.passthrough();
const illnessSymptomBackupSchema = z
.object({
key: z.string().min(1),
@@ -2101,6 +2128,8 @@ export const backupPayloadSchema = z
// fields; portable exports retain the metadata-only subset.
labResults: z.array(labResultBackupSchema).default([]),
biomarkers: z.array(biomarkerBackupSchema).default([]),
// Defaulted: a file written before v1.43 carries no key.
biomarkerMerges: z.array(biomarkerMergeBackupSchema).default([]),
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
// Defaulted: a file written before v1.40 carries no key.
symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]),
+4
View File
@@ -75,6 +75,10 @@ export const updateBiomarkerSchema = z
// v1.22 — hide / unhide a marker. An omitted key leaves visibility
// untouched; `true` drops the marker from the active list + pickers.
hidden: z.boolean().optional(),
// v1.43 (D1223) — the other spellings this marker answers to, as a whole
// set: what is listed stays or is added, what is not is removed. Omitted
// leaves them untouched, so an older client's PUT keeps them.
aliases: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
})
.refine(
(d) =>
+21
View File
@@ -340,6 +340,18 @@ export interface ExtractedFactDto {
provenance: FactProvenance;
committedRecordId: string | null;
committedRecordType: string | null;
/**
* v1.43 (D1223), pending OBSERVATION facts only — the marker the label
* resolves to and how, or up to three markers to choose from.
*/
biomarkerId?: string | null;
matchKind?: "name" | "alias" | "catalog" | null;
suggestions?: Array<{
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}>;
}
/** One condition link on a document DTO (chip → `/illness/[id]`). */
@@ -602,6 +614,15 @@ export const inboundConfirmSchema = z.object({
z.object({
factId: z.string().trim().min(1).max(40),
action: z.enum(["approve", "reject"]),
/**
* v1.43 (D1223), OBSERVATION only — the marker the person chose for
* the fact; the reading is filed under it whatever the label says.
* An id that is not one of the record's markers fails the fact with
* `observation.unknownBiomarker`.
*/
biomarkerId: z.string().trim().min(1).max(64).optional(),
/** With `biomarkerId`: remember the label as an alias. Default true. */
rememberAlias: z.boolean().optional(),
}),
)
.min(1)
+28 -2
View File
@@ -138,6 +138,19 @@ export interface OcrExtractedRowDto {
*/
markerAnalyteKey: string | null;
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the row was matched to automatically: by its
* name, an alias of the account, or a catalog abbreviation. Null when none.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched; else empty. */
suggestions: Array<{
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}>;
}
export interface OcrExtractResponseDto {
@@ -190,6 +203,18 @@ export const ocrCommitRowSchema = z
.optional()
.or(z.literal("").transform(() => undefined)),
takenAt: takenAtField,
/**
* v1.43 (D1223) — the marker the person chose for this row on the review
* screen. The row is written under it whatever `analyte` says; an id that
* is not one of the record's markers skips the row (`unknown_biomarker`).
*/
biomarkerId: z.string().trim().min(1).max(64).optional(),
/**
* With `biomarkerId`: remember `analyte` as another spelling of that
* marker, so the next report with the same name matches on its own.
* Default true. Ignored when the name is the marker's own.
*/
rememberAlias: z.boolean().optional(),
})
// Numeric XOR qualitative: exactly one of value / valueText.
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
@@ -246,11 +271,12 @@ export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
/**
* A row the commit route did not write. `duplicate`: it matched a live reading
* at commit time. `unit_mismatch`: it states a unit other than the one its
* marker is tracked in.
* marker is tracked in. `unknown_biomarker`: the chosen `biomarkerId` is not
* one of the record's markers.
*/
export interface OcrSkippedRowDto {
analyte: string;
reason: "duplicate" | "unit_mismatch";
reason: "duplicate" | "unit_mismatch" | "unknown_biomarker";
}
export interface OcrCommitResponseDto {
@@ -244,6 +244,8 @@ const COUNT_BACK: Record<
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }),
BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }),
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
FamilyHistoryEntry: (p, userId) =>
p.familyHistoryEntry.count({ where: { userId } }),
@@ -738,6 +740,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
// Another spelling of the marker, and the ledger row of a marker that was
// merged into it: both ride the marker (D1223).
await prisma.biomarkerAlias.create({
data: {
userId: OWNER_ID,
biomarkerId: biomarker.id,
name: "Ferr.",
nameFolded: "ferr.",
source: "IMPORT_CHOICE",
},
});
await prisma.biomarkerMerge.create({
data: {
userId: OWNER_ID,
targetBiomarkerId: biomarker.id,
sourceBiomarkerId: "merged-away-marker",
sourceName: "Ferritin (Serum)",
readingCount: 3,
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,