mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
This commit is contained in:
1 parent
a3ce6af9a3
commit
81be73fca8
42 files changed
+1645
-423
No files matched your search
@@ -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