feat(labs): merge markers, keep aliases and read lab series per marker

POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:22:40 +02:00
1 parent a3ce6af9a3
commit 81be73fca8
42 files changed
+1645 -423

No files matched your search

@@ -73,6 +73,8 @@ function buildMockPrisma() {
},
]),
},
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: {
findMany: vi.fn().mockResolvedValue([
{
@@ -160,6 +160,8 @@ function sourceClient() {
// Records section: empty, exercised by its own suite.
labResult: empty,
biomarker: empty,
biomarkerAlias: empty,
biomarkerMerge: empty,
illnessEpisode: empty,
allergy: empty,
familyHistoryEntry: empty,
+8 -4
View File
@@ -576,6 +576,14 @@ export const TWO_ENDED_MODELS = [
// questionnaire over every module decision taken in Settings since. It is
// carried and asserted, not defaulted.
"OnboardingRecord",
// v1.43 (D1223) — a marker's other spellings ride on the marker as
// `aliases[]`, and the merge ledger travels as its own list whose target is
// resolved by id or by name like a lab result's marker. Without the aliases a
// restored account is asked again at the next scan which marker each
// spelling belongs to; without the ledger a link to a merged marker reads as
// a plain 404 instead of leading to the marker it went into.
"BiomarkerAlias",
"BiomarkerMerge",
] as const;
/** One model claimed to travel both ways. */
@@ -614,10 +622,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// v1.43 — the new tables are declared here with the schema and move to
// TWO_ENDED_MODELS when their section writer and restore branch land.
BiomarkerAlias:
"Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.",
BiomarkerMerge:
"The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.",
MedicationIngredient:
"What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.",
DawarichDay:
+77
View File
@@ -126,10 +126,38 @@ export interface BiomarkerBackupEntry {
context: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
* v1.43 (D1223) — the other spellings the marker answers to, with where
* each came from. Absent on files written before them.
*/
aliases?: BiomarkerAliasBackupEntry[];
createdAt?: string;
updatedAt?: string;
}
export interface BiomarkerAliasBackupEntry {
/** Present in canonical DR payloads. */
id?: string;
name: string;
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
createdAt?: string;
}
/**
* v1.43 (D1223) — one marker folded into another. The target travels by id
* in a disaster-recovery file and by name in both, the way a lab result's
* marker does; the source is gone, so its id and name are the record.
*/
export interface BiomarkerMergeBackupEntry {
id?: string;
targetBiomarkerId?: string;
targetName: string;
sourceBiomarkerId: string;
sourceName: string;
readingCount: number;
mergedAt: string;
}
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
@@ -237,6 +265,7 @@ type CanonicalAllergy = AllergyDTO & {
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
biomarkerMerges: BiomarkerMergeBackupEntry[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
@@ -286,6 +315,8 @@ export async function buildRecordsBackupSection(
PrismaClient,
| "labResult"
| "biomarker"
| "biomarkerAlias"
| "biomarkerMerge"
| "illnessEpisode"
| "allergy"
| "familyHistoryEntry"
@@ -351,6 +382,8 @@ export async function buildRecordsBackupSection(
const [
labResultRows,
biomarkerRows,
biomarkerAliasRows,
biomarkerMergeRows,
episodeRows,
allergyRows,
familyRows,
@@ -366,6 +399,22 @@ export async function buildRecordsBackupSection(
where: { userId },
orderBy: { name: "asc" },
}),
prisma.biomarkerAlias.findMany({
where: { userId },
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
select: {
id: true,
biomarkerId: true,
name: true,
source: true,
createdAt: true,
},
}),
prisma.biomarkerMerge.findMany({
where: { userId },
orderBy: { mergedAt: "asc" },
include: { target: { select: { name: true } } },
}),
prisma.illnessEpisode.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
@@ -429,6 +478,32 @@ export async function buildRecordsBackupSection(
biomarkerName: r.biomarker?.name ?? null,
}));
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
for (const alias of biomarkerAliasRows) {
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
list.push({
name: alias.name,
source: alias.source as BiomarkerAliasBackupEntry["source"],
...(disasterRecovery
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
: {}),
});
aliasesByMarker.set(alias.biomarkerId, list);
}
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
(m) => ({
...(disasterRecovery
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
: {}),
targetName: m.target.name,
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt.toISOString(),
}),
);
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
@@ -449,6 +524,7 @@ export async function buildRecordsBackupSection(
decryptContextFromBytes,
),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
@@ -598,6 +674,7 @@ export async function buildRecordsBackupSection(
return {
labResults,
biomarkers,
biomarkerMerges,
illnessEpisodes,
allergies,
familyHistory,
+71
View File
@@ -72,6 +72,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import type { BackupMeasurement } from "@/lib/validations/backup";
import {
isCompatibleSchemaVersion,
@@ -2076,6 +2077,76 @@ export async function restoreBackup(
});
biomarkerByName.set(biomarker.name, created.id);
restoredBiomarkerIds.add(created.id);
// v1.43 (D1223) — the other spellings, folded the way the resolver
// reads them, so the next scan matches them again.
const aliasRows = (biomarker.aliases ?? [])
.map((alias) => ({
id: alias.id,
name: alias.name.trim().slice(0, 120),
nameFolded: foldForMatch(alias.name).slice(0, 120),
source: alias.source,
createdAt: alias.createdAt,
}))
.filter(
(alias) =>
alias.nameFolded !== "" &&
alias.nameFolded !== foldForMatch(biomarker.name),
);
if (aliasRows.length > 0) {
await tx.biomarkerAlias.createMany({
data: aliasRows.map((alias) => ({
...(alias.id ? { id: alias.id } : {}),
userId: ownerId,
biomarkerId: created.id,
name: alias.name,
nameFolded: alias.nameFolded,
source: alias.source,
...(alias.createdAt
? { createdAt: new Date(alias.createdAt) }
: {}),
})),
// One spelling answers to one marker per account; a file that
// names it twice keeps the first.
skipDuplicates: true,
});
}
}
// v1.43 (D1223) — the merge ledger, against the markers just written:
// by id where the file carries one this restore kept, by name
// otherwise. A row whose target the file does not carry has nothing
// to point at and is left out (counted on the event).
let unresolvedMerges = 0;
const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => {
const targetId =
merge.targetBiomarkerId &&
restoredBiomarkerIds.has(merge.targetBiomarkerId)
? merge.targetBiomarkerId
: biomarkerByName.get(merge.targetName);
if (!targetId) {
unresolvedMerges += 1;
return [];
}
return [
{
...(merge.id ? { id: merge.id } : {}),
userId: ownerId,
targetBiomarkerId: targetId,
sourceBiomarkerId: merge.sourceBiomarkerId,
sourceName: merge.sourceName,
readingCount: merge.readingCount,
mergedAt: new Date(merge.mergedAt),
},
];
});
if (mergeRows.length > 0) {
await tx.biomarkerMerge.createMany({ data: mergeRows });
}
if (unresolvedMerges > 0) {
annotate({
action: { name: "backup.restore.biomarkerMerge.unresolved" },
meta: { count: unresolvedMerges },
});
}
// Collected for the staged facts further down: an approved fact's