mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'v143/labs' into release/v1.43.0
This commit is contained in:
commit
74fa4e10b6
96 files changed
+6992
-626
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
import { catalogKeyForName, catalogNameIndex } from "../analyte-match";
|
||||
import { ANALYTE_ALIASES, LAB_NAME_ALIASES } from "../biomarker-catalog";
|
||||
import {
|
||||
addAliasToIndex,
|
||||
matchForReview,
|
||||
nameSimilarity,
|
||||
resolveInIndex,
|
||||
suggestInIndex,
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "../biomarker-resolve";
|
||||
|
||||
let seq = 0;
|
||||
function marker(
|
||||
name: string,
|
||||
unit: string,
|
||||
extra: Partial<IndexedMarker> = {},
|
||||
) {
|
||||
seq += 1;
|
||||
return {
|
||||
id: `bm-${seq}`,
|
||||
name,
|
||||
unit,
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hidden: false,
|
||||
createdAt: new Date(Date.UTC(2026, 0, seq)),
|
||||
...extra,
|
||||
} satisfies IndexedMarker;
|
||||
}
|
||||
|
||||
function index(
|
||||
markers: IndexedMarker[],
|
||||
aliases: Array<{ biomarkerId: string; name: string }> = [],
|
||||
): MarkerIndex {
|
||||
return {
|
||||
markers,
|
||||
aliases: aliases.map((a) => ({ ...a, nameFolded: foldForMatch(a.name) })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("the catalog's shorthand (D1223)", () => {
|
||||
it.each([
|
||||
["Alk.P", "alkaline-phosphatase"],
|
||||
["Alk Phos", "alkaline-phosphatase"],
|
||||
["ALP", "alkaline-phosphatase"],
|
||||
["Alkalische Phosphatase (AP)", "alkaline-phosphatase"],
|
||||
["T.Bili", "bilirubin-total"],
|
||||
["T. Bili", "bilirubin-total"],
|
||||
["TBIL", "bilirubin-total"],
|
||||
["Bilirubin gesamt", "bilirubin-total"],
|
||||
["GPT", "alt"],
|
||||
["ALAT", "alt"],
|
||||
["AST(SGOT)", "ast"],
|
||||
["GOT", "ast"],
|
||||
["γ-GT", "ggt"],
|
||||
["Gamma-GT", "ggt"],
|
||||
["GGTP", "ggt"],
|
||||
["LDH", "ldh"],
|
||||
["CRP", "crp"],
|
||||
["hsCRP", "hs-crp"],
|
||||
["TSH basal", "tsh"],
|
||||
["fT3", "ft3"],
|
||||
["T4L", "ft4"],
|
||||
["HbA1c", "hba1c"],
|
||||
["eGFR", "egfr"],
|
||||
["Hb", "hemoglobin"],
|
||||
["Hkt", "hematocrit"],
|
||||
["MCV", "mcv"],
|
||||
["PLT", "platelets"],
|
||||
["Leuko", "wbc"],
|
||||
["Krea", "creatinine"],
|
||||
["25(OH)D", "vitamin-d"],
|
||||
["Ferr", "ferritin"],
|
||||
["B12", "vitamin-b12"],
|
||||
])("%s is %s", (name, key) => {
|
||||
expect(catalogKeyForName(name)).toBe(key);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Bilirubin (Total)", "bilirubin-total"],
|
||||
["Kreatinin (Serum)", "creatinine"],
|
||||
["GPT (ALT)", "alt"],
|
||||
])("%s, with its parenthetical, is %s", (name, key) => {
|
||||
expect(catalogKeyForName(name)).toBe(key);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["PCR"],
|
||||
["TG"],
|
||||
["T3"],
|
||||
["T4"],
|
||||
["Cr"],
|
||||
["Calcitriol"],
|
||||
["LDL-P"],
|
||||
["Calcium (ionisiert)"],
|
||||
["Glucose (urine)"],
|
||||
["GPT (AST)"],
|
||||
])("%s is no catalog name (ambiguous or another test)", (name) => {
|
||||
expect(catalogKeyForName(name)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the standard and the high-sensitivity CRP apart", () => {
|
||||
expect(catalogKeyForName("CRP")).not.toBe(catalogKeyForName("hs-CRP"));
|
||||
});
|
||||
|
||||
it("writes every name already folded", () => {
|
||||
for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) {
|
||||
for (const name of names) {
|
||||
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no name that two analytes claim", () => {
|
||||
expect(catalogNameIndex().ambiguous).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInIndex", () => {
|
||||
it("resolves the marker's own name, case aside", () => {
|
||||
const ldl = marker("LDL", "mg/dL");
|
||||
expect(resolveInIndex(index([ldl]), "ldl")).toEqual({
|
||||
marker: ldl,
|
||||
matchKind: "name",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the exact spelling over a case variant", () => {
|
||||
const a = marker("ldl", "mg/dL");
|
||||
const b = marker("LDL", "mg/dL");
|
||||
expect(resolveInIndex(index([a, b]), "LDL")?.marker).toBe(b);
|
||||
});
|
||||
|
||||
it("resolves an alias of the account, folded", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
const found = resolveInIndex(
|
||||
index([alp], [{ biomarkerId: alp.id, name: "Alk.P" }]),
|
||||
"ALK.P",
|
||||
);
|
||||
expect(found).toEqual({ marker: alp, matchKind: "alias" });
|
||||
});
|
||||
|
||||
it("resolves a catalog abbreviation to the account's marker of that analyte", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
expect(resolveInIndex(index([alp]), "Alk.P", "U/L")).toEqual({
|
||||
marker: alp,
|
||||
matchKind: "catalog",
|
||||
});
|
||||
// The discussion's own pair: a marker named with a neutral qualifier.
|
||||
const bili = marker("Bilirubin (Total)", "mg/dL");
|
||||
expect(resolveInIndex(index([bili]), "T.Bili", "mg/dL")).toEqual({
|
||||
marker: bili,
|
||||
matchKind: "catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises a marker by one of its aliases' catalog analyte", () => {
|
||||
const liver = marker("Leberwert 1", "U/L");
|
||||
const found = resolveInIndex(
|
||||
index([liver], [{ biomarkerId: liver.id, name: "ALT" }]),
|
||||
"GPT",
|
||||
"U/L",
|
||||
);
|
||||
expect(found).toEqual({ marker: liver, matchKind: "catalog" });
|
||||
});
|
||||
|
||||
it("converts across units in the catalog step where the registry can", () => {
|
||||
const glucose = marker("Glucose", "mg/dL", {
|
||||
analyteKey: "fasting-glucose",
|
||||
});
|
||||
expect(
|
||||
resolveInIndex(index([glucose]), "Blutzucker", "mmol/L")?.marker,
|
||||
).toBe(glucose);
|
||||
});
|
||||
|
||||
it("steps aside in the catalog step for a unit it cannot reconcile", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
expect(resolveInIndex(index([alt]), "GPT", "µkat/L")).toBeNull();
|
||||
// A qualitative reading or none stated always fits.
|
||||
expect(resolveInIndex(index([alt]), "GPT", null)?.marker).toBe(alt);
|
||||
});
|
||||
|
||||
it("lets the name and the alias win regardless of the unit", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
expect(resolveInIndex(index([alt]), "ALT", "µkat/L")?.matchKind).toBe(
|
||||
"name",
|
||||
);
|
||||
expect(
|
||||
resolveInIndex(
|
||||
index([alt], [{ biomarkerId: alt.id, name: "Leber ALT" }]),
|
||||
"Leber ALT",
|
||||
"µkat/L",
|
||||
)?.matchKind,
|
||||
).toBe("alias");
|
||||
});
|
||||
|
||||
it("prefers a visible marker over a hidden one in the catalog step", () => {
|
||||
const hidden = marker("ALT", "U/L", { hidden: true });
|
||||
const visible = marker("GPT (ALT)", "U/L");
|
||||
expect(
|
||||
resolveInIndex(index([hidden, visible]), "SGPT", "U/L")?.marker,
|
||||
).toBe(visible);
|
||||
});
|
||||
|
||||
it("resolves nothing for an unknown name", () => {
|
||||
expect(resolveInIndex(index([marker("ALT", "U/L")]), "Zinc")).toBeNull();
|
||||
expect(resolveInIndex(index([]), " ")).toBeNull();
|
||||
});
|
||||
|
||||
it("sees an alias added after the index was loaded", () => {
|
||||
const tsh = marker("TSH", "mIU/L");
|
||||
const idx = index([tsh]);
|
||||
addAliasToIndex(idx, tsh.id, "Thyreotropes Hormon");
|
||||
expect(resolveInIndex(idx, "thyreotropes hormon")?.matchKind).toBe("alias");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestions", () => {
|
||||
it("offers the same analyte first when its unit kept it from matching", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
const { match, suggestions } = matchForReview(
|
||||
index([alt, marker("AST", "U/L")]),
|
||||
"GPT",
|
||||
"µkat/L",
|
||||
);
|
||||
expect(match).toBeNull();
|
||||
expect(suggestions[0]).toEqual({
|
||||
biomarkerId: alt.id,
|
||||
name: "ALT",
|
||||
unit: "U/L",
|
||||
matchKind: "catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers similar names through the catalog's names for the marker", () => {
|
||||
const bili = marker("T.Bili", "mg/dL");
|
||||
const suggestions = suggestInIndex(index([bili]), "Bilirubin ges.");
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
biomarkerId: bili.id,
|
||||
name: "T.Bili",
|
||||
unit: "mg/dL",
|
||||
matchKind: "similar",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers shorthand against a full name", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
expect(suggestInIndex(index([alp]), "Alk. Phosph.")[0]?.biomarkerId).toBe(
|
||||
alp.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers at most three and never a hidden marker", () => {
|
||||
const markers = [
|
||||
marker("Vitamin B1", "µg/L"),
|
||||
marker("Vitamin B2", "µg/L"),
|
||||
marker("Vitamin B6", "µg/L"),
|
||||
marker("Vitamin B9", "µg/L"),
|
||||
marker("Vitamin B3", "µg/L", { hidden: true }),
|
||||
];
|
||||
const suggestions = suggestInIndex(index(markers), "Vitamin B5");
|
||||
expect(suggestions).toHaveLength(3);
|
||||
expect(suggestions.map((s) => s.name)).not.toContain("Vitamin B3");
|
||||
});
|
||||
|
||||
it("offers nothing unrelated and nothing when the name matched", () => {
|
||||
const ldl = marker("LDL", "mg/dL");
|
||||
expect(suggestInIndex(index([ldl]), "Ferritin")).toEqual([]);
|
||||
expect(matchForReview(index([ldl]), "LDL").suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it("scores names", () => {
|
||||
expect(nameSimilarity("Ferritin", "ferritin")).toBe(1);
|
||||
expect(nameSimilarity("alk phos", "alkaline phosphatase")).toBe(0.8);
|
||||
expect(nameSimilarity("LDL", "Ferritin")).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* v1.18.1 — `resolveOrMintBiomarker`: the auto-link-on-write helper.
|
||||
* `resolveOrMintBiomarker`: the auto-link-on-write helper.
|
||||
*
|
||||
* Every free-text lab write resolves (or mints) a user-scoped Biomarker by
|
||||
* `(userId, lower(analyte))` so NO `LabResult` row ever persists unlinked.
|
||||
* Asserts: case-insensitive reuse, mint-on-miss from the reading's own
|
||||
* spelling, no silent rewrite of an existing marker, and the concurrent
|
||||
* Every free-text lab write resolves (or mints) a user-scoped Biomarker so NO
|
||||
* `LabResult` row ever persists unlinked. Since v1.43 the name resolves
|
||||
* through the account's markers and aliases (`resolveInIndex`), loaded in one
|
||||
* read. Asserts: case-insensitive reuse, alias reuse, mint-on-miss from the
|
||||
* reading's own spelling, no silent rewrite of an existing marker, a minted
|
||||
* marker found by the next row through a shared index, and the concurrent
|
||||
* unique-violation race adopting the winner.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
biomarker: { findFirst: vi.fn(), create: vi.fn() },
|
||||
biomarker: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
|
||||
import { loadMarkerIndex } from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
|
||||
const baseInput = {
|
||||
@@ -25,21 +29,32 @@ const baseInput = {
|
||||
panel: "lipids" as string | null,
|
||||
};
|
||||
|
||||
function marker(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "bm",
|
||||
name: "Marker",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hidden: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.biomarker.findMany.mockResolvedValue([]);
|
||||
db.biomarkerAlias.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("resolveOrMintBiomarker", () => {
|
||||
it("reuses an existing marker (case-insensitive) without minting", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue({
|
||||
id: "bm_existing",
|
||||
name: "LDL Cholesterol",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: 100,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.findMany.mockResolvedValue([
|
||||
marker({ id: "bm_existing", name: "LDL", upperBound: 100 }),
|
||||
]);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
...baseInput,
|
||||
@@ -51,23 +66,33 @@ describe("resolveOrMintBiomarker", () => {
|
||||
// values do NOT rewrite it.
|
||||
expect(out.upperBound).toBe(100);
|
||||
expect(db.biomarker.create).not.toHaveBeenCalled();
|
||||
// Match is case-insensitive on the catalog identity.
|
||||
const where = db.biomarker.findFirst.mock.calls[0][0].where;
|
||||
expect(where.name).toEqual({ equals: "ldl", mode: "insensitive" });
|
||||
expect(where.userId).toBe("u1");
|
||||
expect(db.biomarker.findMany.mock.calls[0][0].where).toEqual({
|
||||
userId: "u1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the marker an alias of the account names", async () => {
|
||||
db.biomarker.findMany.mockResolvedValue([
|
||||
marker({ id: "bm_alp", name: "Alkaline Phosphatase", unit: "U/L" }),
|
||||
]);
|
||||
db.biomarkerAlias.findMany.mockResolvedValue([
|
||||
{ biomarkerId: "bm_alp", name: "Alk.P", nameFolded: "alk.p" },
|
||||
]);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
...baseInput,
|
||||
analyte: "ALK.P",
|
||||
unit: "U/L",
|
||||
});
|
||||
|
||||
expect(out.id).toBe("bm_alp");
|
||||
expect(db.biomarker.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a new marker from the reading when none exists", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue(null);
|
||||
db.biomarker.create.mockResolvedValue({
|
||||
id: "bm_new",
|
||||
name: "LDL",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: 116,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_new", name: "LDL", upperBound: 116, panel: "lipids" }),
|
||||
);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", baseInput);
|
||||
|
||||
@@ -84,16 +109,9 @@ describe("resolveOrMintBiomarker", () => {
|
||||
});
|
||||
|
||||
it("trims the analyte before minting", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue(null);
|
||||
db.biomarker.create.mockResolvedValue({
|
||||
id: "bm_x",
|
||||
name: "Ferritin",
|
||||
unit: "ng/mL",
|
||||
lowerBound: 30,
|
||||
upperBound: 400,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_x", name: "Ferritin", unit: "ng/mL" }),
|
||||
);
|
||||
|
||||
await resolveOrMintBiomarker("u1", {
|
||||
analyte: " Ferritin ",
|
||||
@@ -106,17 +124,32 @@ describe("resolveOrMintBiomarker", () => {
|
||||
expect(db.biomarker.create.mock.calls[0][0].data.name).toBe("Ferritin");
|
||||
});
|
||||
|
||||
it("finds a marker minted for an earlier row through the shared index", async () => {
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_tsh", name: "TSH", unit: "mIU/L" }),
|
||||
);
|
||||
const index = await loadMarkerIndex(db as never, "u1");
|
||||
const input = { ...baseInput, analyte: "TSH", unit: "mIU/L" };
|
||||
|
||||
const first = await resolveOrMintBiomarker("u1", input, { index });
|
||||
const second = await resolveOrMintBiomarker(
|
||||
"u1",
|
||||
{ ...input, analyte: "tsh" },
|
||||
{ index },
|
||||
);
|
||||
|
||||
expect(first.id).toBe("bm_tsh");
|
||||
expect(second.id).toBe("bm_tsh");
|
||||
expect(db.biomarker.create).toHaveBeenCalledTimes(1);
|
||||
expect(db.biomarker.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("adopts the race winner on a concurrent unique violation", async () => {
|
||||
// First lookup misses; create throws (the other writer won the unique
|
||||
// The index misses; create throws (the other writer won the unique
|
||||
// index); the re-lookup finds the winner.
|
||||
db.biomarker.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: "bm_winner",
|
||||
name: "TSH",
|
||||
unit: "mIU/L",
|
||||
lowerBound: 0.4,
|
||||
upperBound: 4,
|
||||
panel: "thyroid",
|
||||
});
|
||||
db.biomarker.findFirst.mockResolvedValueOnce(
|
||||
marker({ id: "bm_winner", name: "TSH", unit: "mIU/L" }),
|
||||
);
|
||||
db.biomarker.create.mockRejectedValue(new Error("unique constraint"));
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
@@ -128,7 +161,7 @@ describe("resolveOrMintBiomarker", () => {
|
||||
});
|
||||
|
||||
expect(out.id).toBe("bm_winner");
|
||||
expect(db.biomarker.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(db.biomarker.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the create fails and no race winner exists", async () => {
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { localisedValues } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog";
|
||||
import {
|
||||
ANALYTE_ALIASES,
|
||||
BIOMARKER_CATALOG,
|
||||
LAB_NAME_ALIASES,
|
||||
} from "./biomarker-catalog";
|
||||
import { analyteAcceptsUnit } from "./unit-conversion/convert";
|
||||
import { analyteConversionFor } from "./unit-conversion/registry";
|
||||
|
||||
@@ -77,7 +81,129 @@ export function matchAnalyteKey(input: {
|
||||
unit: string;
|
||||
}): string | null {
|
||||
cache ??= analyteNameIndex().index;
|
||||
const key = cache.get(foldForMatch(input.name));
|
||||
const key =
|
||||
cache.get(foldForMatch(input.name)) ??
|
||||
cache.get(foldWithoutDots(input.name));
|
||||
if (!key) return null;
|
||||
return analyteAcceptsUnit(key, input.unit) ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fold with every full stop and comma read as a space as well: lab
|
||||
* shorthand is printed "Alk.P", "T.Bili", "Bilirubin, total", and the dot or
|
||||
* comma carries no meaning there. Applied to both sides, like the fold itself.
|
||||
*/
|
||||
export function foldWithoutDots(raw: string): string {
|
||||
return foldForMatch(raw).replace(/[.,]/gu, " ").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
let catalogCache: ReadonlyMap<string, string> | null = null;
|
||||
let catalogNamesCache: ReadonlyMap<string, readonly string[]> | null = null;
|
||||
|
||||
/**
|
||||
* Which catalog analyte a NAME is, for telling two markers of one account
|
||||
* apart from two spellings of one marker (D1223). Unlike `analyteNameIndex`
|
||||
* this needs no conversion: "GPT" and "ALT" are one analyte whether or not
|
||||
* the registry can move it between units, so every seed, every alias in
|
||||
* `ANALYTE_ALIASES` and every name in `LAB_NAME_ALIASES` claims here, folded
|
||||
* with and without dots. A name two analytes claim matches neither.
|
||||
* Exported for the guard test.
|
||||
*/
|
||||
export function catalogNameIndex(): {
|
||||
index: ReadonlyMap<string, string>;
|
||||
ambiguous: readonly string[];
|
||||
} {
|
||||
const claims = new Map<string, Set<string>>();
|
||||
const claim = (folded: string, key: string) => {
|
||||
if (!folded) return;
|
||||
const keys = claims.get(folded) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
claims.set(folded, keys);
|
||||
};
|
||||
const claimName = (name: string, key: string) => {
|
||||
claim(foldForMatch(name), key);
|
||||
claim(foldWithoutDots(name), key);
|
||||
};
|
||||
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
|
||||
claimName(name, seed.slug);
|
||||
claim(withoutGloss(foldForMatch(name)), seed.slug);
|
||||
}
|
||||
}
|
||||
for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) {
|
||||
for (const name of names) claimName(name, key);
|
||||
}
|
||||
|
||||
const index = new Map<string, string>();
|
||||
const ambiguous: string[] = [];
|
||||
for (const [folded, keys] of claims) {
|
||||
if (keys.size === 1) index.set(folded, [...keys][0]);
|
||||
else ambiguous.push(folded);
|
||||
}
|
||||
return { index, ambiguous };
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifiers that may stand in parentheses after a name without making it a
|
||||
* different measurement: "Bilirubin (Total)", "Kreatinin (Serum)". Anything
|
||||
* else in parentheses names something else ("Calcium (ionisiert)", "Glucose
|
||||
* (urine)") and keeps the name unmatched.
|
||||
*/
|
||||
const NEUTRAL_GLOSSES = new Set([
|
||||
"total",
|
||||
"totale",
|
||||
"totales",
|
||||
"gesamt",
|
||||
"serum",
|
||||
"s",
|
||||
"plasma",
|
||||
"blood",
|
||||
"blut",
|
||||
"sang",
|
||||
"sangre",
|
||||
"sangue",
|
||||
"krew",
|
||||
]);
|
||||
|
||||
function lookupWhole(name: string): string | null {
|
||||
catalogCache ??= catalogNameIndex().index;
|
||||
return (
|
||||
catalogCache.get(foldForMatch(name)) ??
|
||||
catalogCache.get(foldWithoutDots(name)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog analyte a name belongs to, or null. Whole names only, with one
|
||||
* allowance for a trailing parenthetical: it is read when it names the same
|
||||
* analyte as the rest ("GPT (ALT)") or is a neutral qualifier ("Bilirubin
|
||||
* (Total)"), and never otherwise.
|
||||
*/
|
||||
export function catalogKeyForName(name: string): string | null {
|
||||
const whole = lookupWhole(name);
|
||||
if (whole) return whole;
|
||||
const glossed = /^(.+?)\s*\(([^()]+)\)\s*$/u.exec(name.trim());
|
||||
if (!glossed) return null;
|
||||
const outer = lookupWhole(glossed[1]);
|
||||
if (!outer) return null;
|
||||
const gloss = glossed[2];
|
||||
if (NEUTRAL_GLOSSES.has(foldWithoutDots(gloss))) return outer;
|
||||
return lookupWhole(gloss) === outer ? outer : null;
|
||||
}
|
||||
|
||||
/** Every folded name the catalog knows for one analyte, for suggestions. */
|
||||
export function catalogNamesForKey(key: string): readonly string[] {
|
||||
if (!catalogNamesCache) {
|
||||
catalogCache ??= catalogNameIndex().index;
|
||||
const byKey = new Map<string, string[]>();
|
||||
for (const [folded, k] of catalogCache) {
|
||||
const list = byKey.get(k) ?? [];
|
||||
list.push(folded);
|
||||
byKey.set(k, list);
|
||||
}
|
||||
catalogNamesCache = byKey;
|
||||
}
|
||||
return catalogNamesCache.get(key) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The other spellings of a marker (D1223), stored per account in
|
||||
* `biomarker_aliases`.
|
||||
*
|
||||
* `nameFolded` is `foldForMatch(name)` and unique per account, so one spelling
|
||||
* resolves to one marker. An alias equal to its own marker's name (folded) is
|
||||
* never stored: the name already resolves.
|
||||
*
|
||||
* Sources: `MERGE` (a merged marker's name), `IMPORT_CHOICE` (a name mapped to
|
||||
* a marker on a scan or document review), `MANUAL` (typed on the marker).
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
export type AliasSource = "MERGE" | "IMPORT_CHOICE" | "MANUAL";
|
||||
|
||||
type AliasClient = Pick<PrismaClient, "biomarkerAlias" | "biomarker">;
|
||||
type AliasTx = Prisma.TransactionClient;
|
||||
|
||||
/** Longest alias, matching the column. */
|
||||
export const BIOMARKER_ALIAS_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* The aliases of each marker, sorted, without the marker's own name. One read
|
||||
* for the whole set, so a list of markers costs one query.
|
||||
*/
|
||||
export async function aliasNamesByMarker(
|
||||
client: Pick<PrismaClient, "biomarkerAlias">,
|
||||
userId: string,
|
||||
markers: ReadonlyArray<{ id: string; name: string }>,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const out = new Map<string, string[]>();
|
||||
if (markers.length === 0) return out;
|
||||
const rows = await client.biomarkerAlias.findMany({
|
||||
where: { userId, biomarkerId: { in: markers.map((m) => m.id) } },
|
||||
select: { biomarkerId: true, name: true, nameFolded: true },
|
||||
});
|
||||
const ownFold = new Map(markers.map((m) => [m.id, foldForMatch(m.name)]));
|
||||
for (const row of rows) {
|
||||
if (row.nameFolded === ownFold.get(row.biomarkerId)) continue;
|
||||
const list = out.get(row.biomarkerId) ?? [];
|
||||
list.push(row.name);
|
||||
out.set(row.biomarkerId, list);
|
||||
}
|
||||
for (const list of out.values()) {
|
||||
list.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What blocks an alias: another marker's name or another marker's alias. */
|
||||
export interface AliasConflict {
|
||||
alias: string;
|
||||
biomarkerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first alias in `names` that another marker of the account already
|
||||
* answers to, by name or alias; null when none does.
|
||||
*/
|
||||
export async function findAliasConflict(
|
||||
client: AliasClient | AliasTx,
|
||||
userId: string,
|
||||
biomarkerId: string,
|
||||
names: readonly string[],
|
||||
): Promise<AliasConflict | null> {
|
||||
if (names.length === 0) return null;
|
||||
const folds = new Map(names.map((n) => [foldForMatch(n), n]));
|
||||
const [markers, aliases] = await Promise.all([
|
||||
client.biomarker.findMany({
|
||||
where: { userId, id: { not: biomarkerId } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
client.biomarkerAlias.findMany({
|
||||
where: {
|
||||
userId,
|
||||
biomarkerId: { not: biomarkerId },
|
||||
nameFolded: { in: [...folds.keys()] },
|
||||
},
|
||||
select: { biomarkerId: true, nameFolded: true },
|
||||
}),
|
||||
]);
|
||||
for (const marker of markers) {
|
||||
const hit = folds.get(foldForMatch(marker.name));
|
||||
if (hit !== undefined) return { alias: hit, biomarkerId: marker.id };
|
||||
}
|
||||
const alias = aliases[0];
|
||||
if (alias) {
|
||||
return {
|
||||
alias: folds.get(alias.nameFolded) ?? alias.nameFolded,
|
||||
biomarkerId: alias.biomarkerId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `names` the marker's aliases: what is listed stays (keeping where it
|
||||
* came from) or is added as `MANUAL`, what is not listed is removed. The list
|
||||
* is the whole set the marker answers to, as `aliases` on the marker reads it;
|
||||
* the caller has already checked it for conflicts.
|
||||
*/
|
||||
export async function replaceAliases(
|
||||
tx: AliasTx,
|
||||
userId: string,
|
||||
marker: { id: string; name: string },
|
||||
names: readonly string[],
|
||||
): Promise<void> {
|
||||
const ownFold = foldForMatch(marker.name);
|
||||
const wanted = new Map<string, string>();
|
||||
for (const raw of names) {
|
||||
const name = raw.trim();
|
||||
const folded = foldForMatch(name);
|
||||
if (!folded || folded === ownFold || wanted.has(folded)) continue;
|
||||
wanted.set(folded, name);
|
||||
}
|
||||
const existing = await tx.biomarkerAlias.findMany({
|
||||
where: { userId, biomarkerId: marker.id },
|
||||
select: { id: true, nameFolded: true },
|
||||
});
|
||||
const stale = existing
|
||||
.filter((row) => !wanted.has(row.nameFolded))
|
||||
.map((row) => row.id);
|
||||
if (stale.length > 0) {
|
||||
await tx.biomarkerAlias.deleteMany({
|
||||
where: { userId, id: { in: stale } },
|
||||
});
|
||||
}
|
||||
const kept = new Set(existing.map((row) => row.nameFolded));
|
||||
const fresh = [...wanted].filter(([folded]) => !kept.has(folded));
|
||||
if (fresh.length > 0) {
|
||||
await tx.biomarkerAlias.createMany({
|
||||
data: fresh.map(([nameFolded, name]) => ({
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
name: name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
|
||||
nameFolded: nameFolded.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
|
||||
source: "MANUAL",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a name the person mapped to a marker on a review screen, so the
|
||||
* next report with the same spelling resolves without asking. A spelling
|
||||
* already attached to another marker moves to this one: the choice is the
|
||||
* person's latest word on it. The marker's own name is not stored.
|
||||
* Returns whether an alias was written.
|
||||
*/
|
||||
export async function rememberImportAlias(
|
||||
client: Pick<PrismaClient, "biomarkerAlias">,
|
||||
userId: string,
|
||||
marker: { id: string; name: string },
|
||||
printedName: string,
|
||||
): Promise<boolean> {
|
||||
const name = printedName.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
|
||||
const nameFolded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
|
||||
if (!nameFolded || nameFolded === foldForMatch(marker.name)) return false;
|
||||
await client.biomarkerAlias.upsert({
|
||||
where: { userId_nameFolded: { userId, nameFolded } },
|
||||
create: {
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
name,
|
||||
nameFolded,
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
update: { biomarkerId: marker.id, name, source: "IMPORT_CHOICE" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -217,6 +217,23 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
|
||||
lowerBound: null,
|
||||
upperBound: 55,
|
||||
},
|
||||
// v1.43 (D1223) — the two markers a report most often prints in shorthand
|
||||
// ("Alk.P", "T.Bili"). Adult reference windows per MedlinePlus: ALP 44-147
|
||||
// IU/L, total bilirubin 0.1-1.2 mg/dL. Editable defaults like every seed.
|
||||
{
|
||||
slug: "alkaline-phosphatase",
|
||||
panel: "liver",
|
||||
unit: "U/L",
|
||||
lowerBound: 44,
|
||||
upperBound: 147,
|
||||
},
|
||||
{
|
||||
slug: "bilirubin-total",
|
||||
panel: "liver",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 0.1,
|
||||
upperBound: 1.2,
|
||||
},
|
||||
// Electrolytes
|
||||
{
|
||||
slug: "sodium",
|
||||
@@ -294,8 +311,8 @@ export const BIOMARKER_PANELS = [
|
||||
* Names are compared folded (lower case, no accents, hyphens and slashes as
|
||||
* spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose"
|
||||
* needs no entry of its own. The registry-only keys (urea, blood urea
|
||||
* nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their
|
||||
* names live only here.
|
||||
* nitrogen, uric acid, total calcium) have no seed, so their names live only
|
||||
* here; total bilirubin has had a seed since v1.43.
|
||||
*/
|
||||
export interface AnalyteAliases {
|
||||
/** The analyte key: a catalog slug, or a registry-only key. */
|
||||
@@ -336,6 +353,24 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"glycosylated hemoglobin",
|
||||
"glykohamoglobin",
|
||||
"hamoglobin a1c",
|
||||
// v1.43 (D1223) — MedlinePlus, Lab Tests Online (es, it, pl), Medics
|
||||
// Labor, CHU de Lille, IUCPQ / CHUM, Asan Medical Center.
|
||||
"hba1c",
|
||||
"glycohemoglobin",
|
||||
"glykohamoglobin a1c",
|
||||
"hemoglobina glicada",
|
||||
"hemoglobina glicosilada",
|
||||
"glicohemoglobina",
|
||||
"hemoglobine glyquee",
|
||||
"hb glyque",
|
||||
"glycohemoglobine",
|
||||
"hemoglobine glycosylee",
|
||||
"emoglobina a1c",
|
||||
"glicoemoglobina",
|
||||
"emoglobina glicosilata",
|
||||
"hemoglobina glikowana",
|
||||
"glikohemoglobina",
|
||||
"당화혈색소",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -351,8 +386,33 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"cholesterol calkowity",
|
||||
],
|
||||
},
|
||||
{ key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] },
|
||||
{ key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] },
|
||||
{
|
||||
key: "ldl",
|
||||
names: [
|
||||
"ldl",
|
||||
"ldl c",
|
||||
"ldl chol",
|
||||
"ldl cholesterin",
|
||||
// v1.43 — Labor Limbach, Medics Labor, gesundheit.gv.at, CHU de Lille,
|
||||
// the Korean national screening form.
|
||||
"ldl direkt",
|
||||
"ldlch",
|
||||
"cholesterol ldl",
|
||||
"저밀도 콜레스테롤",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hdl",
|
||||
names: [
|
||||
"hdl",
|
||||
"hdl c",
|
||||
"hdl chol",
|
||||
"hdl cholesterin",
|
||||
"hdlch",
|
||||
"cholesterol hdl",
|
||||
"고밀도 콜레스테롤",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "creatinine",
|
||||
names: [
|
||||
@@ -360,6 +420,18 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"serumkreatinin",
|
||||
"kreatinin im serum",
|
||||
"creatinine serique",
|
||||
// v1.43 — Labor Limbach ("Krea/S", serum only: the urine "Krea/U" is a
|
||||
// different measurement and is not here), Medics Labor, Lab Tests
|
||||
// Online (es, it, pl), CHU de Lille, CHUM, the Korean screening form.
|
||||
"krea s",
|
||||
"krea",
|
||||
"crea",
|
||||
"creatinin",
|
||||
"kreatinin",
|
||||
"creatininemie",
|
||||
"creat",
|
||||
"creatinina sierica",
|
||||
"혈청 크레아티닌",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -371,6 +443,22 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"25 oh d",
|
||||
"calcidiol",
|
||||
"vitamin d 25 oh",
|
||||
// v1.43 — 25-OH only; calcitriol (1,25-(OH)2 D) is a different test and
|
||||
// stays out. MedlinePlus, Medics Labor, gesundheit.gv.at, Lab Tests
|
||||
// Online (es, it, pl), CHU de Lille, IUCPQ.
|
||||
"25(oh)d",
|
||||
"25 (oh)d",
|
||||
"calcifediol",
|
||||
"25 hydroxycholecalciferol",
|
||||
"vd25",
|
||||
"25 hidroxivitamina d",
|
||||
"25 (oh) vitamine d",
|
||||
"25 oh, vitamine d3",
|
||||
"25 oh vitamina d",
|
||||
"25 idrossi vitamina d",
|
||||
"25 hydroksy witamina d3",
|
||||
"kalcydiol",
|
||||
"25 hydroksykolekalciferol",
|
||||
],
|
||||
},
|
||||
{ key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] },
|
||||
@@ -422,6 +510,417 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"bilirrubina",
|
||||
"bilirubine",
|
||||
"bilirubina",
|
||||
// v1.43 (D1223) — "Alk.P" / "T.Bili" are how the reports in the
|
||||
// discussion print them; the rest are MedlinePlus, testing.com, Labor
|
||||
// Limbach, Medics Labor, Lab Tests Online (es, it), Dimedic (pl) and
|
||||
// Asan Medical Center. The bare "BIL" is left out: lower case it is the
|
||||
// urine dipstick field in German code lists.
|
||||
"t.bili",
|
||||
"t. bili",
|
||||
"tbil",
|
||||
"total serum bilirubin",
|
||||
"tsb",
|
||||
"bili",
|
||||
"totales bilirubin",
|
||||
"bilirrubina total",
|
||||
"bilirubina totale",
|
||||
"bilirubina całkowita",
|
||||
"총 빌리루빈",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.43 (D1223) — other names for analytes, for recognising that two
|
||||
* spellings on two reports are one marker. Same shape and fold as
|
||||
* `ANALYTE_ALIASES`, and read together with it by `catalogNameIndex`, but
|
||||
* with no conversion attached: an analyte here may have no entry in the
|
||||
* unit-conversion registry ("GPT" is "ALT" whatever the unit), and the key may
|
||||
* be a catalog slug or a name-only key for an analyte with no seed (`ldh`,
|
||||
* `mcv`). Nothing here changes which unit conversion a marker gets.
|
||||
*
|
||||
* Every name is written folded (`foldForMatch`); a dot in shorthand is kept
|
||||
* ("alk.p") and also matched without it. Each name was checked against a
|
||||
* laboratory or clinical reference; a shorthand that names two analytes in
|
||||
* different places ("PCR", "AP" outside German reports, "Cr") is left out,
|
||||
* because a wrong automatic match moves a reading into the wrong history.
|
||||
*/
|
||||
export const LAB_NAME_ALIASES: readonly AnalyteAliases[] = [
|
||||
{
|
||||
key: "alkaline-phosphatase",
|
||||
names: [
|
||||
"alp",
|
||||
"alkp",
|
||||
"alk phos",
|
||||
"alk.p",
|
||||
"ap",
|
||||
"alk. phosphatase",
|
||||
"alkalische phosphatase",
|
||||
"fosfatasa alcalina",
|
||||
"pal",
|
||||
"phosphatases alcalines",
|
||||
"fosfatasi alcalina",
|
||||
"fosfataza alkaliczna",
|
||||
"fosfataza zasadowa",
|
||||
"알칼리인산분해효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "alt",
|
||||
names: [
|
||||
"alt",
|
||||
"sgpt",
|
||||
"gpt",
|
||||
"alanine transaminase",
|
||||
"alanine aminotransferase",
|
||||
"alat",
|
||||
"glutamat pyruvat transaminase",
|
||||
"alanina aminotransferasa",
|
||||
"transaminasa glutamico piruvico",
|
||||
"tgp",
|
||||
"alanina aminotransferasi",
|
||||
"aminotransferaza alaninowa",
|
||||
"alt(sgpt)",
|
||||
"혈청gpt",
|
||||
"알라닌아미노전달효소",
|
||||
"알라닌아미노전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ast",
|
||||
names: [
|
||||
"ast",
|
||||
"sgot",
|
||||
"got",
|
||||
"aspartate transaminase",
|
||||
"aspartate aminotransferase",
|
||||
"asat",
|
||||
"glutamat oxalacetat transaminase",
|
||||
"aspartato aminotransferasa",
|
||||
"transaminasa glutamico oxalacetico",
|
||||
"tgo",
|
||||
"aspartato transaminasi",
|
||||
"aspat",
|
||||
"aminotransferaza asparaginianowa",
|
||||
"ast(sgot)",
|
||||
"혈청got",
|
||||
"아스파테이트아미노전달효소",
|
||||
"아스파테이트아미노전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ggt",
|
||||
names: [
|
||||
"ggt",
|
||||
"ggtp",
|
||||
"gtp",
|
||||
"gamma gt",
|
||||
"γ gt",
|
||||
"γgt",
|
||||
"gamma glutamyltransferase",
|
||||
"gammaglutamyltransferase",
|
||||
"gamma glutamil transferasa",
|
||||
"gamma glutamylotranspeptydaza",
|
||||
"감마지티피(γgtp)",
|
||||
"γgtp",
|
||||
"감마지티피",
|
||||
"감마지티",
|
||||
"r gtp",
|
||||
"감마 gtp",
|
||||
"감마글루타밀전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ldh",
|
||||
names: [
|
||||
"ldh",
|
||||
"ld",
|
||||
"lactate dehydrogenase",
|
||||
"lactic dehydrogenase",
|
||||
"lactatdehydrogenase",
|
||||
"laktatdehydrogenase",
|
||||
"lactato deshidrogenasa",
|
||||
"lactate deshydrogenase",
|
||||
"lattico deidrogenasi",
|
||||
"lattato deidrogenasi",
|
||||
"dehydrogenaza mleczanowa",
|
||||
"젖산탈수효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
// The standard assay. "PCR" is left out: it is CRP on Spanish and
|
||||
// Italian reports and polymerase chain reaction everywhere else.
|
||||
key: "crp",
|
||||
names: [
|
||||
"crp",
|
||||
"c reactive protein",
|
||||
"c reaktives protein",
|
||||
"proteina c reactiva",
|
||||
"proteine c reactive",
|
||||
"proteina c reattiva",
|
||||
"białko c reaktywne",
|
||||
"c 반응성 단백",
|
||||
],
|
||||
},
|
||||
{
|
||||
// The high-sensitivity assay, kept apart from the standard one: the two
|
||||
// are read against different ranges, and a merge is the person's call.
|
||||
key: "hs-crp",
|
||||
names: [
|
||||
"hs crp",
|
||||
"hscrp",
|
||||
"high sensitivity crp",
|
||||
"cardiac crp",
|
||||
"crp hochsensitiv",
|
||||
"pcr ultrasensible",
|
||||
"pcr us",
|
||||
"proteina c reactiva ultrasensible",
|
||||
"crp ultrasensible",
|
||||
"crp hs",
|
||||
"hs pcr",
|
||||
"pcr ultrasensibile",
|
||||
"proteina c reattiva ultrasensibile",
|
||||
"crp wysoko czułe",
|
||||
"crp ultra czułe",
|
||||
"wysoko czułe białko c reaktywne",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tsh",
|
||||
names: [
|
||||
"tsh",
|
||||
"thyrotropin",
|
||||
"thyroid stimulating hormone",
|
||||
"thyreotropin",
|
||||
"tsh basal",
|
||||
"tirotropina",
|
||||
"hormona estimulante del tiroides",
|
||||
"hormona tiroestimulante",
|
||||
"tsh us",
|
||||
"tsh ultra sensible",
|
||||
"thyrotropine",
|
||||
"tireotropina",
|
||||
"ormone tireotropo",
|
||||
"tyreotropina",
|
||||
"hormon tyreotropowy",
|
||||
"갑상선자극호르몬",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Free T3 only: a bare "T3" is the total hormone.
|
||||
key: "ft3",
|
||||
names: [
|
||||
"ft3",
|
||||
"free t3",
|
||||
"free triiodothyronine",
|
||||
"t3 frei",
|
||||
"freies t3",
|
||||
"trijodthyronin frei",
|
||||
"t3 libre",
|
||||
"t3l",
|
||||
"triiodotironina libera",
|
||||
"wolna t3",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Free T4 only: a bare "T4" is the total hormone.
|
||||
key: "ft4",
|
||||
names: [
|
||||
"ft4",
|
||||
"free t4",
|
||||
"free thyroxine",
|
||||
"t4 frei",
|
||||
"freies t4",
|
||||
"thyroxin frei",
|
||||
"t4l",
|
||||
"t4 libre",
|
||||
"tiroxina libre",
|
||||
"thyroxine libre",
|
||||
"tiroxina libera",
|
||||
"wolna t4",
|
||||
"유리 싸이록신",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Estimates only: a bare "GFR" may be a measured rate, and a
|
||||
// Cockcroft-Gault clearance is a different quantity.
|
||||
key: "egfr",
|
||||
names: [
|
||||
"egfr",
|
||||
"estimated gfr",
|
||||
"cgfr",
|
||||
"egfr nach ckd epi formel",
|
||||
"ckdepi",
|
||||
"egfr nach mdrd formel",
|
||||
"tefg",
|
||||
"tfg calculada",
|
||||
"tasa estimada de filtrado glomerular",
|
||||
"fge",
|
||||
"dfg",
|
||||
"dfge",
|
||||
"szacowana filtracja kłebuszkowa",
|
||||
"신사구체여과율 (e gfr)",
|
||||
"e gfr",
|
||||
"신사구체여과율",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hemoglobin",
|
||||
names: ["hb", "hgb", "hemogl", "hemoglobine", "혈색소"],
|
||||
},
|
||||
{
|
||||
key: "hematocrit",
|
||||
names: [
|
||||
"hct",
|
||||
"pcv",
|
||||
"crit",
|
||||
"packed cell volume",
|
||||
"hkt",
|
||||
"hto",
|
||||
"ht",
|
||||
"적혈구용적률",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "mcv",
|
||||
names: [
|
||||
"mcv",
|
||||
"mittleres zellvolumen",
|
||||
"vcm",
|
||||
"volumen corpuscular medio",
|
||||
"vgm",
|
||||
"volume globulaire moyen",
|
||||
"평균적혈구용적",
|
||||
],
|
||||
},
|
||||
{
|
||||
// "Hb/E" is Labor Limbach's code for MCH, not haemoglobin.
|
||||
key: "mch",
|
||||
names: ["mch", "hb e", "hcm", "tcmh", "tgmh", "평균적혈구혈색소량"],
|
||||
},
|
||||
{
|
||||
key: "mchc",
|
||||
names: [
|
||||
"mchc",
|
||||
"chcm",
|
||||
"ccmh",
|
||||
"cgmh",
|
||||
"평균적혈구 헤모글로빈농도",
|
||||
"평균 적혈구혈색소 농도",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "rbc",
|
||||
names: [
|
||||
"rbc",
|
||||
"rbc count",
|
||||
"erythrocyte count",
|
||||
"red count",
|
||||
"ery",
|
||||
"erythrozyten",
|
||||
"recuento de hematies",
|
||||
"recuento eritrocitario",
|
||||
"erythrocytes",
|
||||
"hematies",
|
||||
"liczba erytrocytow",
|
||||
"적혈구",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wbc",
|
||||
names: [
|
||||
"wbc",
|
||||
"leukocyte count",
|
||||
"white cell count",
|
||||
"leuko",
|
||||
"leukozyten",
|
||||
"leuc",
|
||||
"leucocitos",
|
||||
"leucocytes",
|
||||
"liczba leukocytow",
|
||||
"백혈구",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "platelets",
|
||||
names: [
|
||||
"plt",
|
||||
"plt count",
|
||||
"thrombocyte count",
|
||||
"thromb",
|
||||
"thr",
|
||||
"thrombozyten",
|
||||
"plaq",
|
||||
"plaquetas",
|
||||
"plaquettes",
|
||||
"conta piastrinica",
|
||||
"liczba płytek krwi",
|
||||
"liczba trombocytow",
|
||||
"platelet",
|
||||
"혈소판",
|
||||
],
|
||||
},
|
||||
{
|
||||
// "TG" is left out: written "Tg" it is thyroglobulin, and the match
|
||||
// ignores case.
|
||||
key: "triglycerides",
|
||||
names: [
|
||||
"trig",
|
||||
"tgl",
|
||||
"trg",
|
||||
"triglyceride",
|
||||
"triglycerides",
|
||||
"trigliceridos",
|
||||
"trigliceridi",
|
||||
"triglicerydy",
|
||||
"중성지방",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ferritin",
|
||||
names: [
|
||||
"ferritin",
|
||||
"serum ferritin",
|
||||
"ferr",
|
||||
"ferritina",
|
||||
"ferritine",
|
||||
"ferritina sierica",
|
||||
"ferrytyna",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vitamin-b12",
|
||||
names: [
|
||||
"vitamin b12",
|
||||
"b12",
|
||||
"cobalamin",
|
||||
"cobalamina",
|
||||
"vitamine b12",
|
||||
"cyanocobalamine",
|
||||
"vb12",
|
||||
"vitamina b12",
|
||||
"witamina b12",
|
||||
"kobalamina",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sodium",
|
||||
names: ["na", "na+", "natrium", "sodium", "sodio", "natremie", "소디움"],
|
||||
},
|
||||
{
|
||||
// A bare "K" only ever reaches a potassium marker whose unit the reading
|
||||
// fits (`resolveInIndex`), never vitamin K.
|
||||
key: "potassium",
|
||||
names: [
|
||||
"k",
|
||||
"k+",
|
||||
"kalium",
|
||||
"potassium",
|
||||
"potasio",
|
||||
"potassio",
|
||||
"potas",
|
||||
"kaliemie",
|
||||
"포타슘",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Which of an account's markers a printed name belongs to (D1223).
|
||||
*
|
||||
* One lab writes "Alkaline Phosphatase", the next "Alk.P", and each spelling
|
||||
* used to become a marker of its own with half the history. Every path that
|
||||
* turns a name into a marker resolves it here, in one order:
|
||||
*
|
||||
* 1. `name` the marker's own name, case and spacing aside;
|
||||
* 2. `alias` a spelling the account attached to a marker (merged in,
|
||||
* chosen at an import, or typed on the marker), folded;
|
||||
* 3. `catalog` a name the catalog knows for the same analyte as a marker of
|
||||
* the account ("GPT" for a marker called "ALT"), when the
|
||||
* reading's unit is that marker's or converts to it;
|
||||
* 4. nothing the caller mints a marker, or the review offers suggestions.
|
||||
*
|
||||
* Steps 1 and 2 are the account's own decisions and win regardless of the
|
||||
* unit; a unit that does not fit is then refused by the write, exactly as it
|
||||
* is for a marker picked by hand. Step 3 is the app's guess, so it steps aside
|
||||
* for a unit it cannot reconcile and the reading keeps its own marker.
|
||||
*
|
||||
* The matching is pure over a {@link MarkerIndex} loaded once per request, so
|
||||
* the scan review, the commit and the document import cannot disagree about
|
||||
* the same name.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
import {
|
||||
catalogKeyForName,
|
||||
catalogNamesForKey,
|
||||
foldWithoutDots,
|
||||
} from "./analyte-match";
|
||||
import { convertLabValue } from "./unit-conversion/convert";
|
||||
import { sameLabUnit } from "./unit-normalise";
|
||||
|
||||
export type BiomarkerMatchKind = "name" | "alias" | "catalog";
|
||||
export type BiomarkerSuggestionKind = "catalog" | "similar";
|
||||
|
||||
/** Most suggestions a review row offers. */
|
||||
export const BIOMARKER_SUGGESTION_LIMIT = 3;
|
||||
|
||||
export interface IndexedMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IndexedAlias {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
nameFolded: string;
|
||||
}
|
||||
|
||||
export interface MarkerIndex {
|
||||
markers: readonly IndexedMarker[];
|
||||
aliases: readonly IndexedAlias[];
|
||||
}
|
||||
|
||||
export interface MarkerMatch {
|
||||
marker: IndexedMarker;
|
||||
matchKind: BiomarkerMatchKind;
|
||||
}
|
||||
|
||||
export interface MarkerSuggestion {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
matchKind: BiomarkerSuggestionKind;
|
||||
}
|
||||
|
||||
const MARKER_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
hidden: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
/** Every marker and alias of the account, for {@link resolveInIndex}. */
|
||||
export async function loadMarkerIndex(
|
||||
client: Pick<PrismaClient, "biomarker" | "biomarkerAlias">,
|
||||
userId: string,
|
||||
): Promise<MarkerIndex> {
|
||||
const [markers, aliases] = await Promise.all([
|
||||
client.biomarker.findMany({
|
||||
where: { userId },
|
||||
select: MARKER_SELECT,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
client.biomarkerAlias.findMany({
|
||||
where: { userId },
|
||||
select: { biomarkerId: true, name: true, nameFolded: true },
|
||||
}),
|
||||
]);
|
||||
return { markers, aliases };
|
||||
}
|
||||
|
||||
/** Add a marker minted after the index was loaded, so later rows see it. */
|
||||
export function addToIndex(index: MarkerIndex, marker: IndexedMarker): void {
|
||||
if (index.markers.some((m) => m.id === marker.id)) return;
|
||||
(index.markers as IndexedMarker[]).push(marker);
|
||||
}
|
||||
|
||||
/** Record an alias written after the index was loaded. */
|
||||
export function addAliasToIndex(
|
||||
index: MarkerIndex,
|
||||
biomarkerId: string,
|
||||
name: string,
|
||||
): void {
|
||||
const nameFolded = foldForMatch(name.trim());
|
||||
const list = index.aliases as IndexedAlias[];
|
||||
const at = list.findIndex((a) => a.nameFolded === nameFolded);
|
||||
const entry = { biomarkerId, name: name.trim(), nameFolded };
|
||||
if (at >= 0) list[at] = entry;
|
||||
else list.push(entry);
|
||||
}
|
||||
|
||||
/** Visible before hidden, then the oldest: the order a tie is broken in. */
|
||||
function preferred(candidates: IndexedMarker[]): IndexedMarker | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const visible = candidates.filter((m) => !m.hidden);
|
||||
return (visible.length > 0 ? visible : candidates)[0] ?? null;
|
||||
}
|
||||
|
||||
/** The catalog analytes a marker answers to: through its name and its aliases. */
|
||||
function markerCatalogKeys(
|
||||
index: MarkerIndex,
|
||||
marker: IndexedMarker,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const own = catalogKeyForName(marker.name) ?? marker.analyteKey;
|
||||
if (own) keys.add(own);
|
||||
for (const alias of index.aliases) {
|
||||
if (alias.biomarkerId !== marker.id) continue;
|
||||
const key = catalogKeyForName(alias.name);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reading stated in `unit` can be stored under the marker: the same
|
||||
* unit, or one the conversion moves into it. No unit (a qualitative reading,
|
||||
* or none stated) always fits.
|
||||
*/
|
||||
export function unitFitsMarker(
|
||||
marker: Pick<IndexedMarker, "unit" | "analyteKey">,
|
||||
unit: string | null | undefined,
|
||||
analyteKey: string | null,
|
||||
): boolean {
|
||||
if (unit === null || unit === undefined || unit.trim() === "") return true;
|
||||
if (sameLabUnit(unit, marker.unit)) return true;
|
||||
return convertLabValue({
|
||||
analyteKey: marker.analyteKey ?? analyteKey,
|
||||
value: 1,
|
||||
from: unit,
|
||||
to: marker.unit,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a printed name resolves to, and how; null when nothing does.
|
||||
* `unit` is the unit the reading states, used only by the catalog step.
|
||||
*/
|
||||
export function resolveInIndex(
|
||||
index: MarkerIndex,
|
||||
rawName: string,
|
||||
unit?: string | null,
|
||||
): MarkerMatch | null {
|
||||
const name = rawName.trim();
|
||||
if (!name) return null;
|
||||
|
||||
// 1. The marker's own name. An exact spelling wins over one that differs in
|
||||
// case, which wins over one that differs only in punctuation and spacing,
|
||||
// and that last one only when it names a single marker.
|
||||
const exact = index.markers.find((m) => m.name === name);
|
||||
if (exact) return { marker: exact, matchKind: "name" };
|
||||
const lower = name.toLowerCase();
|
||||
const caseless = preferred(
|
||||
index.markers.filter((m) => m.name.trim().toLowerCase() === lower),
|
||||
);
|
||||
if (caseless) return { marker: caseless, matchKind: "name" };
|
||||
const folded = foldForMatch(name);
|
||||
const byFold = index.markers.filter((m) => foldForMatch(m.name) === folded);
|
||||
if (byFold.length === 1) return { marker: byFold[0], matchKind: "name" };
|
||||
|
||||
// 2. An alias of the account.
|
||||
const alias = index.aliases.find((a) => a.nameFolded === folded);
|
||||
if (alias) {
|
||||
const marker = index.markers.find((m) => m.id === alias.biomarkerId);
|
||||
if (marker) return { marker, matchKind: "alias" };
|
||||
}
|
||||
|
||||
// 3. The catalog: a marker of the same analyte that can take the unit.
|
||||
const key = catalogKeyForName(name);
|
||||
if (key) {
|
||||
const candidates = index.markers.filter(
|
||||
(m) =>
|
||||
markerCatalogKeys(index, m).has(key) && unitFitsMarker(m, unit, key),
|
||||
);
|
||||
const marker = preferred(candidates);
|
||||
if (marker) return { marker, matchKind: "catalog" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Folded name without dots, commas or parentheses, for the similarity. */
|
||||
function loose(raw: string): string {
|
||||
return foldWithoutDots(raw)
|
||||
.replace(/[()[\]]/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bigrams(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < text.length - 1; i++) out.push(text.slice(i, i + 2));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sørensen-Dice over character bigrams, 0..1. */
|
||||
export function diceSimilarity(a: string, b: string): number {
|
||||
const x = bigrams(a);
|
||||
const y = bigrams(b);
|
||||
if (x.length === 0 || y.length === 0) return a === b && a !== "" ? 1 : 0;
|
||||
const counts = new Map<string, number>();
|
||||
for (const g of y) counts.set(g, (counts.get(g) ?? 0) + 1);
|
||||
let shared = 0;
|
||||
for (const g of x) {
|
||||
const n = counts.get(g) ?? 0;
|
||||
if (n > 0) {
|
||||
shared += 1;
|
||||
counts.set(g, n - 1);
|
||||
}
|
||||
}
|
||||
return (2 * shared) / (x.length + y.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand against the full name: every word of the shorter is the start of
|
||||
* a word of the longer, in order ("alk phos" against "alkaline phosphatase").
|
||||
*/
|
||||
function abbreviates(short: string, long: string): boolean {
|
||||
const s = short.split(" ").filter(Boolean);
|
||||
const l = long.split(" ").filter(Boolean);
|
||||
if (s.length === 0 || s.length > l.length) return false;
|
||||
let j = 0;
|
||||
for (const word of s) {
|
||||
while (j < l.length && !l[j].startsWith(word)) j++;
|
||||
if (j === l.length) return false;
|
||||
j++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** How alike two names are, 0..1. */
|
||||
export function nameSimilarity(a: string, b: string): number {
|
||||
const x = loose(a);
|
||||
const y = loose(b);
|
||||
if (!x || !y) return 0;
|
||||
if (x === y) return 1;
|
||||
const [shorter, longer] = x.length <= y.length ? [x, y] : [y, x];
|
||||
const prefix = abbreviates(shorter, longer) ? 0.8 : 0;
|
||||
return Math.max(prefix, diceSimilarity(x, y));
|
||||
}
|
||||
|
||||
/** Below this a marker is not offered as similar. */
|
||||
export const SIMILARITY_FLOOR = 0.5;
|
||||
|
||||
/**
|
||||
* Up to three markers a review row offers for a name nothing resolved:
|
||||
* first the markers of the same catalog analyte (their unit kept the catalog
|
||||
* step from taking them), then the markers whose name, aliases or catalog
|
||||
* names are most alike. Hidden markers are not offered.
|
||||
*/
|
||||
export function suggestInIndex(
|
||||
index: MarkerIndex,
|
||||
rawName: string,
|
||||
limit = BIOMARKER_SUGGESTION_LIMIT,
|
||||
): MarkerSuggestion[] {
|
||||
const name = rawName.trim();
|
||||
if (!name) return [];
|
||||
const out: MarkerSuggestion[] = [];
|
||||
const taken = new Set<string>();
|
||||
const visible = index.markers.filter((m) => !m.hidden);
|
||||
|
||||
const key = catalogKeyForName(name);
|
||||
if (key) {
|
||||
for (const marker of visible) {
|
||||
if (out.length >= limit) break;
|
||||
if (!markerCatalogKeys(index, marker).has(key)) continue;
|
||||
out.push(toSuggestion(marker, "catalog"));
|
||||
taken.add(marker.id);
|
||||
}
|
||||
}
|
||||
|
||||
const scored: Array<{ marker: IndexedMarker; score: number }> = [];
|
||||
for (const marker of visible) {
|
||||
if (taken.has(marker.id)) continue;
|
||||
const names = [marker.name];
|
||||
for (const alias of index.aliases) {
|
||||
if (alias.biomarkerId === marker.id) names.push(alias.name);
|
||||
}
|
||||
for (const k of markerCatalogKeys(index, marker)) {
|
||||
names.push(...catalogNamesForKey(k));
|
||||
}
|
||||
let score = 0;
|
||||
for (const candidate of names) {
|
||||
score = Math.max(score, nameSimilarity(name, candidate));
|
||||
}
|
||||
if (score >= SIMILARITY_FLOOR) scored.push({ marker, score });
|
||||
}
|
||||
scored.sort(
|
||||
(a, b) => b.score - a.score || a.marker.name.localeCompare(b.marker.name),
|
||||
);
|
||||
for (const { marker } of scored) {
|
||||
if (out.length >= limit) break;
|
||||
out.push(toSuggestion(marker, "similar"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toSuggestion(
|
||||
marker: IndexedMarker,
|
||||
matchKind: BiomarkerSuggestionKind,
|
||||
): MarkerSuggestion {
|
||||
return {
|
||||
biomarkerId: marker.id,
|
||||
name: marker.name,
|
||||
unit: marker.unit,
|
||||
matchKind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution and suggestions for one name, as the review rows carry them:
|
||||
* the matched marker and how, or none and up to three to choose from.
|
||||
*/
|
||||
export function matchForReview(
|
||||
index: MarkerIndex,
|
||||
name: string,
|
||||
unit?: string | null,
|
||||
): {
|
||||
match: MarkerMatch | null;
|
||||
suggestions: MarkerSuggestion[];
|
||||
} {
|
||||
const match = resolveInIndex(index, name, unit);
|
||||
return {
|
||||
match,
|
||||
suggestions: match ? [] : suggestInIndex(index, name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a name asked for on a read path (MCP `get_labs`, the
|
||||
* `healthlog://lab/{analyte}` resource) resolves to: its name, an alias or a
|
||||
* catalog abbreviation, with no unit to weigh. Null when nothing resolves;
|
||||
* the caller then keeps its own text match.
|
||||
*/
|
||||
export async function resolveMarkerForRead(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const index = await loadMarkerIndex(prisma, userId);
|
||||
const match = resolveInIndex(index, name);
|
||||
return match ? { id: match.marker.id, name: match.marker.name } : null;
|
||||
}
|
||||
@@ -14,6 +14,13 @@ import { Buffer } from "node:buffer";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import {
|
||||
addToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
|
||||
@@ -72,18 +79,19 @@ export interface MintedBiomarker {
|
||||
* v1.18.1 — resolve (or mint) the user-scoped Biomarker for a free-text lab
|
||||
* write so NO `LabResult` row ever persists unlinked.
|
||||
*
|
||||
* The catalog identity is `(userId, lower(analyte))`: a reading for "LDL",
|
||||
* "ldl", and "LDL " all resolve to one marker. An existing marker (hand-
|
||||
* defined or previously minted) is reused as-is — its unit / range are
|
||||
* authoritative and the incoming free-text unit / bounds are NOT allowed to
|
||||
* silently rewrite it. When no marker exists yet, one is minted from the
|
||||
* reading's own free-text spelling / unit / bounds. The `@@unique([userId,
|
||||
* name])` index is the structural backstop; a concurrent double-write
|
||||
* converges via the catch-and-refetch.
|
||||
* Since v1.43 (D1223) the name resolves through `resolveInIndex`: the
|
||||
* marker's own name (case aside), then an alias of the account, then a
|
||||
* catalog name for the same analyte as one of the account's markers ("GPT"
|
||||
* for "ALT") when the reading's unit fits that marker. An existing marker is
|
||||
* reused as-is: its unit / range are authoritative and the incoming free-text
|
||||
* unit / bounds are NOT allowed to silently rewrite it. When nothing resolves,
|
||||
* a marker is minted from the reading's own spelling / unit / bounds. The
|
||||
* `@@unique([userId, name])` index is the structural backstop; a concurrent
|
||||
* double-write converges via the catch-and-refetch.
|
||||
*
|
||||
* Turning the boot-time backfill into a pure historical migration: from
|
||||
* v1.18.1 on, every write goes through here, so the only unlinked rows that
|
||||
* can exist are pre-upgrade ones the backfill heals.
|
||||
* `index` lets a caller writing many rows load the account's markers once;
|
||||
* a marker minted here is added to it so a later row with the same name finds
|
||||
* it.
|
||||
*/
|
||||
export async function resolveOrMintBiomarker(
|
||||
userId: string,
|
||||
@@ -94,28 +102,15 @@ export async function resolveOrMintBiomarker(
|
||||
referenceHigh: number | null;
|
||||
panel: string | null;
|
||||
},
|
||||
options: { index?: MarkerIndex } = {},
|
||||
): Promise<MintedBiomarker> {
|
||||
const name = input.analyte.trim();
|
||||
const select = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
} as const;
|
||||
|
||||
// Case-insensitive match on the catalog identity. Postgres is the store;
|
||||
// `mode: "insensitive"` keeps "LDL" === "ldl" without a functional index.
|
||||
const existing = await prisma.biomarker.findFirst({
|
||||
where: { userId, name: { equals: name, mode: "insensitive" } },
|
||||
select,
|
||||
});
|
||||
if (existing) return existing;
|
||||
const index = options.index ?? (await loadMarkerIndex(prisma, userId));
|
||||
const hit = resolveInIndex(index, name, input.unit || null);
|
||||
if (hit) return toMinted(hit.marker);
|
||||
|
||||
try {
|
||||
return await prisma.biomarker.create({
|
||||
const created = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
@@ -125,15 +120,44 @@ export async function resolveOrMintBiomarker(
|
||||
panel: input.panel,
|
||||
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
|
||||
},
|
||||
select,
|
||||
select: INDEXED_SELECT,
|
||||
});
|
||||
addToIndex(index, created);
|
||||
return toMinted(created);
|
||||
} catch {
|
||||
// A concurrent write minted the same `(userId, name)` first — adopt it.
|
||||
const raced = await prisma.biomarker.findFirst({
|
||||
where: { userId, name: { equals: name, mode: "insensitive" } },
|
||||
select,
|
||||
select: INDEXED_SELECT,
|
||||
});
|
||||
if (raced) return raced;
|
||||
if (raced) {
|
||||
addToIndex(index, raced);
|
||||
return toMinted(raced);
|
||||
}
|
||||
throw new Error("Failed to resolve biomarker for lab write");
|
||||
}
|
||||
}
|
||||
|
||||
const INDEXED_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
hidden: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
function toMinted(marker: IndexedMarker): MintedBiomarker {
|
||||
return {
|
||||
id: marker.id,
|
||||
name: marker.name,
|
||||
unit: marker.unit,
|
||||
lowerBound: marker.lowerBound,
|
||||
upperBound: marker.upperBound,
|
||||
panel: marker.panel,
|
||||
analyteKey: marker.analyteKey,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -179,6 +179,8 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.biomarkerUpdate", { name });
|
||||
case "biomarker.delete":
|
||||
return t("recordSharing.activityVerb.biomarkerDelete", { name });
|
||||
case "labs.biomarker.merge":
|
||||
return t("recordSharing.activityVerb.biomarkerMerge", { name });
|
||||
|
||||
case "allergy.update":
|
||||
return t("recordSharing.activityVerb.allergyUpdate", { name });
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user