mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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
+333
-229
File diff suppressed because it is too large.
Load diff
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht, besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.",
|
||||
"lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.",
|
||||
"fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins (des Hormons, das Glukose aus dem Blut in die Zellen schleust), gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.",
|
||||
"omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert."
|
||||
"omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert.",
|
||||
"alkaline-phosphatase": "Die alkalische Phosphatase (AP) ist ein Enzym, das vor allem in Leber, Gallenwegen und Knochen vorkommt. Sie steigt, wenn der Gallenfluss gestört ist, und in Phasen aktiven Knochenaufbaus, etwa im Wachstum von Kindern und Jugendlichen oder bei der Heilung eines Bruchs. Weil beide Organe sie abgeben, wird ein erhöhter Wert meist zusammen mit der GGT und den übrigen Leberwerten gelesen, um eine Ursache in der Leber von einer im Knochen zu unterscheiden. Sie gehört zu den üblichen Leberwerten.",
|
||||
"bilirubin-total": "Das Gesamtbilirubin ist das gelbe Abbauprodukt des Hämoglobins aus gealterten roten Blutkörperchen, gemessen als Summe seiner unkonjugierten und konjugierten Form. Die Leber nimmt es auf, bindet es und scheidet es mit der Galle aus, daher zeigt der Wert, wie gut dieser Weg funktioniert. Er kann bei Lebererkrankungen, verschlossenen Gallenwegen oder vermehrtem Abbau roter Blutkörperchen steigen, harmlos auch beim Gilbert-Syndrom. Er wird zusammen mit den übrigen Leberwerten gelesen."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Nüchtern-Insulin",
|
||||
"omega-3-index": "Omega-3-Index"
|
||||
"omega-3-index": "Omega-3-Index",
|
||||
"alkaline-phosphatase": "Alkalische Phosphatase (AP)",
|
||||
"bilirubin-total": "Bilirubin gesamt"
|
||||
},
|
||||
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
|
||||
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Befundbereich {range}",
|
||||
"catalogDiffers": "dein Bereich {range}",
|
||||
"printed": "auf dem Befund in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "über einen Namen, den du früher zugeordnet hast",
|
||||
"viaCatalog": "als übliche Abkürzung erkannt",
|
||||
"chooseLabel": "Gehört zu",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Ein neuer Biomarker",
|
||||
"rememberHint": "Der Name wird für den nächsten Befund gemerkt.",
|
||||
"chooseHint": "Wähle einen Biomarker, wenn du ihn schon erfasst."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Auch geschrieben als",
|
||||
"taken": "{alias} gehört schon zu einem anderen Biomarker.",
|
||||
"saveError": "Die Namen konnten nicht gespeichert werden. Versuche es erneut.",
|
||||
"remove": "{alias} entfernen",
|
||||
"empty": "Ergänze die Namen, die andere Labore für {name} drucken, damit ihre Befunde hier landen.",
|
||||
"placeholder": "z. B. Alk.P",
|
||||
"addLabel": "Weiterer Name für diesen Biomarker",
|
||||
"add": "Hinzufügen",
|
||||
"alsoWritten": "Auch geschrieben als {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Zusammenführen",
|
||||
"title": "Biomarker zusammenführen",
|
||||
"description": "Fasse Biomarker zusammen, die derselbe Test unter anderem Namen sind.",
|
||||
"pickHint": "Wähle mindestens zwei Biomarker aus.",
|
||||
"keepLabel": "Behalten",
|
||||
"keepHint": "Die anderen gehen in diesen auf, und ihre Namen werden seine weiteren Namen.",
|
||||
"showPreview": "Vorschau zeigen",
|
||||
"back": "Zurück",
|
||||
"confirm": "Jetzt zusammenführen",
|
||||
"previewSummaryOne": "{count} Messung wechselt zu {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} Messungen wechseln zu {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} Messungen wechseln zu {name} ({unit}).",
|
||||
"movesOne": "{count} Messung, schon in derselben Einheit",
|
||||
"movesFew": "{count} Messungen, schon in derselben Einheit",
|
||||
"movesOther": "{count} Messungen, schon in derselben Einheit",
|
||||
"convertedOne": "{count} Messung, umgerechnet von {unit} in {targetUnit}",
|
||||
"convertedFew": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
|
||||
"convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
|
||||
"refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.",
|
||||
"aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:",
|
||||
"blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.",
|
||||
"noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.",
|
||||
"doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben",
|
||||
"doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben",
|
||||
"doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben",
|
||||
"error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
|
||||
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
|
||||
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden."
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden.",
|
||||
"failUnknownBiomarker": "Den gewählten Biomarker gibt es nicht mehr. Wähle einen anderen."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt",
|
||||
"lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt",
|
||||
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt"
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
|
||||
"biomarkerMerge": "{name} hat Biomarker zusammengeführt"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "Verknüpfung mit {name} gelöst",
|
||||
"linked": "Verknüpft",
|
||||
"loadNewer": "Neuere laden",
|
||||
"loadOlder": "Ältere laden"
|
||||
"loadOlder": "Ältere laden",
|
||||
"period": {
|
||||
"showing": "Zeitraum: {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses, particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.",
|
||||
"lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.",
|
||||
"fasting-insulin": "Fasting insulin is the level of insulin (the hormone that moves glucose from the blood into cells) measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.",
|
||||
"omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months."
|
||||
"omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months.",
|
||||
"alkaline-phosphatase": "Alkaline phosphatase (ALP) is an enzyme found mainly in the liver, the bile ducts and bone. It rises when bile flow is blocked and during phases of active bone building, such as growth in children and adolescents or bone healing. Because both organs release it, a raised value is usually read together with GGT and the other liver markers to tell a liver source from a bone source. It is one of the standard liver-panel markers.",
|
||||
"bilirubin-total": "Total bilirubin is the yellow breakdown product of haemoglobin from worn-out red blood cells, measured as the sum of its unconjugated and conjugated forms. The liver takes it up, conjugates it and excretes it in bile, so the level reflects how well this pathway is working. It can rise with liver disease, blocked bile ducts or increased breakdown of red cells, and harmlessly in Gilbert's syndrome. It is read alongside the other liver markers."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Fasting insulin",
|
||||
"omega-3-index": "Omega-3 index"
|
||||
"omega-3-index": "Omega-3 index",
|
||||
"alkaline-phosphatase": "Alkaline phosphatase (ALP)",
|
||||
"bilirubin-total": "Total bilirubin"
|
||||
},
|
||||
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
|
||||
"showingLatestOf": "Showing the latest {shown} of {total} readings",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Report range {range}",
|
||||
"catalogDiffers": "your range {range}",
|
||||
"printed": "as printed in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "by a name you chose before",
|
||||
"viaCatalog": "recognised as a common abbreviation",
|
||||
"chooseLabel": "Belongs to",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "A new biomarker",
|
||||
"rememberHint": "This name will be remembered for the next report.",
|
||||
"chooseHint": "Pick a biomarker if this is one you already track."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Also written as",
|
||||
"taken": "{alias} already belongs to another biomarker.",
|
||||
"saveError": "The names could not be saved. Try again.",
|
||||
"remove": "Remove {alias}",
|
||||
"empty": "Add the names other labs print for {name}, so their reports land here.",
|
||||
"placeholder": "e.g. Alk.P",
|
||||
"addLabel": "Another name for this biomarker",
|
||||
"add": "Add",
|
||||
"alsoWritten": "Also written as {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Merge",
|
||||
"title": "Merge biomarkers",
|
||||
"description": "Combine markers that are the same test written differently.",
|
||||
"pickHint": "Select at least two biomarkers.",
|
||||
"keepLabel": "Keep",
|
||||
"keepHint": "The others move into this one and their names become its other names.",
|
||||
"showPreview": "Show preview",
|
||||
"back": "Back",
|
||||
"confirm": "Merge now",
|
||||
"previewSummaryOne": "{count} reading moves to {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} readings move to {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} readings move to {name} ({unit}).",
|
||||
"movesOne": "{count} reading, already in the same unit",
|
||||
"movesFew": "{count} readings, already in the same unit",
|
||||
"movesOther": "{count} readings, already in the same unit",
|
||||
"convertedOne": "{count} reading, converted from {unit} to {targetUnit}",
|
||||
"convertedFew": "{count} readings, converted from {unit} to {targetUnit}",
|
||||
"convertedOther": "{count} readings, converted from {unit} to {targetUnit}",
|
||||
"refused": "{unit} cannot be converted to {targetUnit}.",
|
||||
"aliasesAdded": "These names will lead to the kept biomarker:",
|
||||
"blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.",
|
||||
"noUndo": "A merge cannot be undone.",
|
||||
"doneToastOne": "Merged into {name}, {count} reading moved",
|
||||
"doneToastFew": "Merged into {name}, {count} readings moved",
|
||||
"doneToastOther": "Merged into {name}, {count} readings moved",
|
||||
"error": "The biomarkers could not be merged. Try again."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "No values could be extracted from this document.",
|
||||
"failUnitRequired": "A numeric value needs a unit before it can be saved.",
|
||||
"failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
|
||||
"failGeneric": "This value could not be saved."
|
||||
"failGeneric": "This value could not be saved.",
|
||||
"failUnknownBiomarker": "The chosen biomarker no longer exists. Pick another one."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Make document contents searchable.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} removed an own vaccine",
|
||||
"lifeEventCreate": "{name} added a life event",
|
||||
"lifeEventUpdate": "{name} changed a life event",
|
||||
"lifeEventDelete": "{name} removed a life event"
|
||||
"lifeEventDelete": "{name} removed a life event",
|
||||
"biomarkerMerge": "{name} merged biomarkers"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "Unlinked {name}",
|
||||
"linked": "Linked",
|
||||
"loadNewer": "Load newer",
|
||||
"loadOlder": "Load older"
|
||||
"loadOlder": "Load older",
|
||||
"period": {
|
||||
"showing": "Showing {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.",
|
||||
"lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.",
|
||||
"fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.",
|
||||
"omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses."
|
||||
"omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses.",
|
||||
"alkaline-phosphatase": "La fosfatasa alcalina es una enzima presente sobre todo en el hígado, las vías biliares y el hueso. Sube cuando se obstruye el flujo de la bilis y en fases de formación ósea activa, como el crecimiento en niños y adolescentes o la consolidación de una fractura. Como la liberan ambos órganos, un valor alto suele leerse junto con la GGT y los demás marcadores hepáticos para distinguir un origen hepático de uno óseo. Es uno de los marcadores habituales del perfil hepático.",
|
||||
"bilirubin-total": "La bilirrubina total es el producto amarillo de la degradación de la hemoglobina de los glóbulos rojos envejecidos, medido como la suma de sus formas no conjugada y conjugada. El hígado la capta, la conjuga y la elimina por la bilis, de modo que su nivel refleja cómo funciona esa vía. Puede subir con enfermedades del hígado, obstrucción de las vías biliares o mayor destrucción de glóbulos rojos, y de forma inofensiva en el síndrome de Gilbert. Se interpreta junto con los demás marcadores hepáticos."
|
||||
},
|
||||
"apob": "Apolipoproteína B (ApoB)",
|
||||
"lp-a": "Lipoproteína(a)",
|
||||
"fasting-insulin": "Insulina en ayunas",
|
||||
"omega-3-index": "Índice omega-3"
|
||||
"omega-3-index": "Índice omega-3",
|
||||
"alkaline-phosphatase": "Fosfatasa alcalina",
|
||||
"bilirubin-total": "Bilirrubina total"
|
||||
},
|
||||
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
|
||||
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Rango del informe {range}",
|
||||
"catalogDiffers": "tu rango {range}",
|
||||
"printed": "impreso en {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "por un nombre que elegiste antes",
|
||||
"viaCatalog": "reconocido como una abreviatura habitual",
|
||||
"chooseLabel": "Pertenece a",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un biomarcador nuevo",
|
||||
"rememberHint": "Este nombre se recordará para el próximo informe.",
|
||||
"chooseHint": "Elige un biomarcador si ya lo registras."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "También escrito como",
|
||||
"taken": "{alias} ya pertenece a otro biomarcador.",
|
||||
"saveError": "No se pudieron guardar los nombres. Inténtalo de nuevo.",
|
||||
"remove": "Quitar {alias}",
|
||||
"empty": "Añade los nombres que otros laboratorios imprimen para {name}, para que sus informes lleguen aquí.",
|
||||
"placeholder": "p. ej. FA",
|
||||
"addLabel": "Otro nombre para este biomarcador",
|
||||
"add": "Añadir",
|
||||
"alsoWritten": "También escrito como {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Combinar",
|
||||
"title": "Combinar biomarcadores",
|
||||
"description": "Une los biomarcadores que son la misma prueba escrita de otra forma.",
|
||||
"pickHint": "Selecciona al menos dos biomarcadores.",
|
||||
"keepLabel": "Conservar",
|
||||
"keepHint": "Los demás pasan a este y sus nombres se convierten en sus otros nombres.",
|
||||
"showPreview": "Ver vista previa",
|
||||
"back": "Atrás",
|
||||
"confirm": "Combinar ahora",
|
||||
"previewSummaryOne": "{count} medición pasa a {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} mediciones pasan a {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} mediciones pasan a {name} ({unit}).",
|
||||
"movesOne": "{count} medición, ya en la misma unidad",
|
||||
"movesFew": "{count} mediciones, ya en la misma unidad",
|
||||
"movesOther": "{count} mediciones, ya en la misma unidad",
|
||||
"convertedOne": "{count} medición, convertida de {unit} a {targetUnit}",
|
||||
"convertedFew": "{count} mediciones, convertidas de {unit} a {targetUnit}",
|
||||
"convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}",
|
||||
"refused": "{unit} no se puede convertir a {targetUnit}.",
|
||||
"aliasesAdded": "Estos nombres llevarán al biomarcador conservado:",
|
||||
"blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.",
|
||||
"noUndo": "Una combinación no se puede deshacer.",
|
||||
"doneToastOne": "Combinado en {name}, {count} medición movida",
|
||||
"doneToastFew": "Combinado en {name}, {count} mediciones movidas",
|
||||
"doneToastOther": "Combinado en {name}, {count} mediciones movidas",
|
||||
"error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "No se pudieron extraer valores de este documento.",
|
||||
"failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
|
||||
"failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
|
||||
"failGeneric": "Este valor no se pudo guardar."
|
||||
"failGeneric": "Este valor no se pudo guardar.",
|
||||
"failUnknownBiomarker": "El biomarcador elegido ya no existe. Elige otro."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} quitó una vacuna propia",
|
||||
"lifeEventCreate": "{name} añadió un acontecimiento vital",
|
||||
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital"
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
|
||||
"biomarkerMerge": "{name} combinó biomarcadores"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "Se desvinculó {name}",
|
||||
"linked": "Vinculados",
|
||||
"loadNewer": "Cargar más recientes",
|
||||
"loadOlder": "Cargar anteriores"
|
||||
"loadOlder": "Cargar anteriores",
|
||||
"period": {
|
||||
"showing": "Mostrando {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.",
|
||||
"lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.",
|
||||
"fasting-insulin": "L'insuline à jeun est le taux d'insuline (l'hormone qui fait passer le glucose du sang vers les cellules) mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.",
|
||||
"omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois."
|
||||
"omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois.",
|
||||
"alkaline-phosphatase": "Les phosphatases alcalines (PAL) sont des enzymes présentes surtout dans le foie, les voies biliaires et l'os. Elles augmentent lorsque l'écoulement de la bile est entravé et pendant les phases de formation osseuse active, comme la croissance chez l'enfant et l'adolescent ou la consolidation d'une fracture. Comme les deux organes les libèrent, une valeur élevée se lit en général avec la GGT et les autres marqueurs hépatiques pour distinguer une origine hépatique d'une origine osseuse. Elles font partie du bilan hépatique habituel.",
|
||||
"bilirubin-total": "La bilirubine totale est le produit jaune de la dégradation de l'hémoglobine des globules rouges usés, mesuré comme la somme de ses formes non conjuguée et conjuguée. Le foie la capte, la conjugue et l'élimine dans la bile, si bien que son taux reflète le bon fonctionnement de cette voie. Elle peut augmenter en cas de maladie du foie, d'obstruction des voies biliaires ou de destruction accrue des globules rouges, et sans gravité dans le syndrome de Gilbert. Elle se lit avec les autres marqueurs hépatiques."
|
||||
},
|
||||
"apob": "Apolipoprotéine B (ApoB)",
|
||||
"lp-a": "Lipoprotéine(a)",
|
||||
"fasting-insulin": "Insuline à jeun",
|
||||
"omega-3-index": "Indice oméga-3"
|
||||
"omega-3-index": "Indice oméga-3",
|
||||
"alkaline-phosphatase": "Phosphatases alcalines (PAL)",
|
||||
"bilirubin-total": "Bilirubine totale"
|
||||
},
|
||||
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
|
||||
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
"catalogDiffers": "votre plage {range}",
|
||||
"printed": "imprimé en {unit} : {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "par un nom que tu as choisi auparavant",
|
||||
"viaCatalog": "reconnu comme une abréviation courante",
|
||||
"chooseLabel": "Correspond à",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un nouveau biomarqueur",
|
||||
"rememberHint": "Ce nom sera retenu pour le prochain compte rendu.",
|
||||
"chooseHint": "Choisis un biomarqueur si tu le suis déjà."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Aussi écrit",
|
||||
"taken": "{alias} appartient déjà à un autre biomarqueur.",
|
||||
"saveError": "Les noms n'ont pas pu être enregistrés. Réessaie.",
|
||||
"remove": "Retirer {alias}",
|
||||
"empty": "Ajoute les noms que d'autres laboratoires impriment pour {name}, pour que leurs comptes rendus arrivent ici.",
|
||||
"placeholder": "p. ex. PAL",
|
||||
"addLabel": "Autre nom pour ce biomarqueur",
|
||||
"add": "Ajouter",
|
||||
"alsoWritten": "Aussi écrit {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Fusionner",
|
||||
"title": "Fusionner des biomarqueurs",
|
||||
"description": "Réunis les biomarqueurs qui sont le même examen écrit autrement.",
|
||||
"pickHint": "Sélectionne au moins deux biomarqueurs.",
|
||||
"keepLabel": "Conserver",
|
||||
"keepHint": "Les autres sont fondus dans celui-ci et leurs noms deviennent ses autres noms.",
|
||||
"showPreview": "Voir l'aperçu",
|
||||
"back": "Retour",
|
||||
"confirm": "Fusionner maintenant",
|
||||
"previewSummaryOne": "{count} mesure passe à {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} mesures passent à {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} mesures passent à {name} ({unit}).",
|
||||
"movesOne": "{count} mesure, déjà dans la même unité",
|
||||
"movesFew": "{count} mesures, déjà dans la même unité",
|
||||
"movesOther": "{count} mesures, déjà dans la même unité",
|
||||
"convertedOne": "{count} mesure, convertie de {unit} en {targetUnit}",
|
||||
"convertedFew": "{count} mesures, converties de {unit} en {targetUnit}",
|
||||
"convertedOther": "{count} mesures, converties de {unit} en {targetUnit}",
|
||||
"refused": "{unit} ne peut pas être converti en {targetUnit}.",
|
||||
"aliasesAdded": "Ces noms mèneront au biomarqueur conservé :",
|
||||
"blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laisse ce biomarqueur de côté ou conserve-en un autre.",
|
||||
"noUndo": "Une fusion ne peut pas être annulée.",
|
||||
"doneToastOne": "Fusionné dans {name}, {count} mesure déplacée",
|
||||
"doneToastFew": "Fusionné dans {name}, {count} mesures déplacées",
|
||||
"doneToastOther": "Fusionné dans {name}, {count} mesures déplacées",
|
||||
"error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
|
||||
"failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
|
||||
"failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée."
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée.",
|
||||
"failUnknownBiomarker": "Le biomarqueur choisi n'existe plus. Choisis-en un autre."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendre le contenu des documents consultable.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} a retiré un vaccin personnel",
|
||||
"lifeEventCreate": "{name} a ajouté un événement de vie",
|
||||
"lifeEventUpdate": "{name} a modifié un événement de vie",
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie"
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie",
|
||||
"biomarkerMerge": "{name} a fusionné des biomarqueurs"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "{name} dissocié",
|
||||
"linked": "Liés",
|
||||
"loadNewer": "Charger les plus récents",
|
||||
"loadOlder": "Charger les plus anciens"
|
||||
"loadOlder": "Charger les plus anciens",
|
||||
"period": {
|
||||
"showing": "Période : {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.",
|
||||
"lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.",
|
||||
"fasting-insulin": "L'insulina a digiuno è il livello di insulina (l'ormone che fa passare il glucosio dal sangue alle cellule) misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.",
|
||||
"omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi."
|
||||
"omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi.",
|
||||
"alkaline-phosphatase": "La fosfatasi alcalina (ALP) è un enzima presente soprattutto nel fegato, nelle vie biliari e nell'osso. Sale quando il flusso della bile è ostacolato e nelle fasi di formazione ossea attiva, come la crescita nei bambini e negli adolescenti o la guarigione di una frattura. Poiché la rilasciano entrambi gli organi, un valore alto si legge di solito insieme alla GGT e agli altri indici epatici per distinguere un'origine epatica da una ossea. È uno degli indici abituali del profilo epatico.",
|
||||
"bilirubin-total": "La bilirubina totale è il prodotto giallo della degradazione dell'emoglobina dei globuli rossi invecchiati, misurato come somma delle sue forme non coniugata e coniugata. Il fegato la capta, la coniuga e la elimina con la bile, quindi il suo livello riflette il funzionamento di questa via. Può salire con malattie del fegato, ostruzione delle vie biliari o aumentata distruzione dei globuli rossi, e in modo innocuo nella sindrome di Gilbert. Si legge insieme agli altri indici epatici."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina a digiuno",
|
||||
"omega-3-index": "Indice omega-3"
|
||||
"omega-3-index": "Indice omega-3",
|
||||
"alkaline-phosphatase": "Fosfatasi alcalina (ALP)",
|
||||
"bilirubin-total": "Bilirubina totale"
|
||||
},
|
||||
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
|
||||
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
"catalogDiffers": "il tuo intervallo {range}",
|
||||
"printed": "stampato in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "tramite un nome che hai scelto in precedenza",
|
||||
"viaCatalog": "riconosciuto come abbreviazione comune",
|
||||
"chooseLabel": "Appartiene a",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un nuovo biomarcatore",
|
||||
"rememberHint": "Questo nome verrà ricordato per il prossimo referto.",
|
||||
"chooseHint": "Scegli un biomarcatore se lo registri già."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Scritto anche come",
|
||||
"taken": "{alias} appartiene già a un altro biomarcatore.",
|
||||
"saveError": "Non è stato possibile salvare i nomi. Riprova.",
|
||||
"remove": "Rimuovi {alias}",
|
||||
"empty": "Aggiungi i nomi che altri laboratori stampano per {name}, così i loro referti arrivano qui.",
|
||||
"placeholder": "es. ALP",
|
||||
"addLabel": "Altro nome per questo biomarcatore",
|
||||
"add": "Aggiungi",
|
||||
"alsoWritten": "Scritto anche come {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Unisci",
|
||||
"title": "Unisci biomarcatori",
|
||||
"description": "Riunisci i biomarcatori che sono lo stesso esame scritto in modo diverso.",
|
||||
"pickHint": "Seleziona almeno due biomarcatori.",
|
||||
"keepLabel": "Mantieni",
|
||||
"keepHint": "Gli altri confluiscono in questo e i loro nomi diventano i suoi altri nomi.",
|
||||
"showPreview": "Mostra anteprima",
|
||||
"back": "Indietro",
|
||||
"confirm": "Unisci ora",
|
||||
"previewSummaryOne": "{count} misurazione passa a {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} misurazioni passano a {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} misurazioni passano a {name} ({unit}).",
|
||||
"movesOne": "{count} misurazione, già nella stessa unità",
|
||||
"movesFew": "{count} misurazioni, già nella stessa unità",
|
||||
"movesOther": "{count} misurazioni, già nella stessa unità",
|
||||
"convertedOne": "{count} misurazione, convertita da {unit} a {targetUnit}",
|
||||
"convertedFew": "{count} misurazioni, convertite da {unit} a {targetUnit}",
|
||||
"convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}",
|
||||
"refused": "{unit} non può essere convertito in {targetUnit}.",
|
||||
"aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:",
|
||||
"blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.",
|
||||
"noUndo": "Un'unione non può essere annullata.",
|
||||
"doneToastOne": "Unito in {name}, {count} misurazione spostata",
|
||||
"doneToastFew": "Unito in {name}, {count} misurazioni spostate",
|
||||
"doneToastOther": "Unito in {name}, {count} misurazioni spostate",
|
||||
"error": "Non è stato possibile unire i biomarcatori. Riprova."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
|
||||
"failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
|
||||
"failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
|
||||
"failGeneric": "Questo valore non è stato salvato."
|
||||
"failGeneric": "Questo valore non è stato salvato.",
|
||||
"failUnknownBiomarker": "Il biomarcatore scelto non esiste più. Scegline un altro."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} ha rimosso un vaccino personale",
|
||||
"lifeEventCreate": "{name} ha aggiunto un evento della vita",
|
||||
"lifeEventUpdate": "{name} ha modificato un evento della vita",
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita"
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita",
|
||||
"biomarkerMerge": "{name} ha unito dei biomarcatori"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "Collegamento a {name} rimosso",
|
||||
"linked": "Collegati",
|
||||
"loadNewer": "Carica più recenti",
|
||||
"loadOlder": "Carica meno recenti"
|
||||
"loadOlder": "Carica meno recenti",
|
||||
"period": {
|
||||
"showing": "Periodo: {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "아포지단백 B는 죽상경화를 일으키는 지단백 입자마다 하나씩 붙어 있는 구조 단백질이라, 이 값을 재면 입자가 나르는 콜레스테롤이 아니라 입자 수를 직접 세는 셈이에요. LDL과 VLDL, 관련 입자 하나에 ApoB 분자가 하나씩 있기 때문에, 입자 수와 콜레스테롤 양이 어긋날 때 LDL 콜레스테롤이 놓치는 심혈관 위험을 잡아낼 수 있어요. 그래서 지질 기반 위험 평가를 다듬는 데 점점 많이 쓰여요. 기본 지질 검사와 함께 해석해요.",
|
||||
"lp-a": "지단백(a)는 LDL과 비슷한 입자에 단백질이 하나 더 붙은 형태로, 수치는 대체로 유전으로 정해지고 평생 꽤 일정하게 유지돼요. Lp(a)가 높으면 심혈관 질환과 대동맥판막 협착의 독립적인 유전 위험 요인이에요. 거의 변하지 않기 때문에 반복해서 추적하기보다 평생 위험을 파악하려고 보통 한 번만 재요. 몰 단위로 보고되고 전체 심혈관 위험의 일부로 읽어요.",
|
||||
"fasting-insulin": "공복 인슐린은 혈액 속 포도당을 세포로 옮기는 호르몬인 인슐린을 밤새 금식한 뒤 잰 값이에요. 공복혈당과 함께 보면 혈당을 유지하려고 몸이 더 많은 인슐린을 필요로 하는 상태, 즉 인슐린 저항성을 가늠할 수 있어요. 혈당은 정상인데 공복 인슐린이 높다면 이런 보상이 이미 진행 중이라는 이른 신호일 수 있어요. 단독으로 보기보다 대사 건강 전체 그림의 일부로 읽어요.",
|
||||
"omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요."
|
||||
"omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요.",
|
||||
"alkaline-phosphatase": "알칼리성 인산분해효소(ALP)는 주로 간, 담관, 뼈에 있는 효소예요. 담즙 흐름이 막히거나, 어린이와 청소년의 성장기나 골절이 아무는 시기처럼 뼈가 활발히 만들어질 때 올라가요. 두 기관 모두에서 나오기 때문에 높은 값은 보통 GGT와 다른 간 지표와 함께 읽어서 간에서 온 것인지 뼈에서 온 것인지 구분해요. 기본 간 검사 지표 중 하나예요.",
|
||||
"bilirubin-total": "총빌리루빈은 수명이 다한 적혈구의 헤모글로빈이 분해되면서 생기는 노란 물질로, 비포합형과 포합형을 합친 값으로 측정해요. 간이 이를 받아들여 포합한 뒤 담즙으로 내보내기 때문에, 수치는 이 경로가 얼마나 잘 작동하는지를 보여 줘요. 간 질환, 담관 폐쇄, 적혈구 파괴 증가 때 올라갈 수 있고, 길버트 증후군에서는 해롭지 않게 올라가기도 해요. 다른 간 지표와 함께 읽어요."
|
||||
},
|
||||
"apob": "아포지단백 B (ApoB)",
|
||||
"lp-a": "지단백(a)",
|
||||
"fasting-insulin": "공복 인슐린",
|
||||
"omega-3-index": "오메가3 지수"
|
||||
"omega-3-index": "오메가3 지수",
|
||||
"alkaline-phosphatase": "알칼리성 인산분해효소(ALP)",
|
||||
"bilirubin-total": "총빌리루빈"
|
||||
},
|
||||
"noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.",
|
||||
"showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
"catalogDiffers": "내 범위 {range}",
|
||||
"printed": "결과지 원문({unit}): {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "전에 고른 이름으로 연결됨",
|
||||
"viaCatalog": "흔히 쓰는 약어로 인식됨",
|
||||
"chooseLabel": "연결할 항목",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "새 바이오마커",
|
||||
"rememberHint": "다음 결과지를 위해 이 이름을 기억해요.",
|
||||
"chooseHint": "이미 기록 중인 바이오마커라면 골라 주세요."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "다른 표기",
|
||||
"taken": "{alias}은(는) 이미 다른 바이오마커에 연결돼 있어요.",
|
||||
"saveError": "이름을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"remove": "{alias} 삭제",
|
||||
"empty": "다른 검사기관이 {name}에 쓰는 이름을 추가하면 그 결과지도 여기로 모여요.",
|
||||
"placeholder": "예: ALP",
|
||||
"addLabel": "이 바이오마커의 다른 이름",
|
||||
"add": "추가",
|
||||
"alsoWritten": "다른 표기: {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "합치기",
|
||||
"title": "바이오마커 합치기",
|
||||
"description": "이름만 다르게 적힌 같은 검사 항목을 하나로 모아요.",
|
||||
"pickHint": "바이오마커를 두 개 이상 골라 주세요.",
|
||||
"keepLabel": "남길 항목",
|
||||
"keepHint": "나머지는 이 항목으로 옮겨지고, 그 이름은 이 항목의 다른 표기가 돼요.",
|
||||
"showPreview": "미리 보기",
|
||||
"back": "뒤로",
|
||||
"confirm": "지금 합치기",
|
||||
"previewSummaryOne": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"previewSummaryFew": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"previewSummaryOther": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"movesOne": "측정값 {count}개, 이미 같은 단위",
|
||||
"movesFew": "측정값 {count}개, 이미 같은 단위",
|
||||
"movesOther": "측정값 {count}개, 이미 같은 단위",
|
||||
"convertedOne": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"convertedFew": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
|
||||
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
|
||||
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
|
||||
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
|
||||
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.",
|
||||
"failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.",
|
||||
"failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.",
|
||||
"failGeneric": "이 값을 저장하지 못했어요."
|
||||
"failGeneric": "이 값을 저장하지 못했어요.",
|
||||
"failUnknownBiomarker": "고른 바이오마커가 더 이상 없어요. 다른 항목을 골라 주세요."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "문서 내용을 검색할 수 있게 만들어요.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요",
|
||||
"lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요",
|
||||
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요"
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
|
||||
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "{name} 연결 해제됨",
|
||||
"linked": "연결됨",
|
||||
"loadNewer": "최근 항목 더 보기",
|
||||
"loadOlder": "이전 항목 더 보기"
|
||||
"loadOlder": "이전 항목 더 보기",
|
||||
"period": {
|
||||
"showing": "{period} 표시 중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+62
-5
@@ -9624,12 +9624,16 @@
|
||||
"apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL, zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.",
|
||||
"lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.",
|
||||
"fasting-insulin": "Insulina na czczo to poziom insuliny (hormonu, który przenosi glukozę z krwi do komórek) mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.",
|
||||
"omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy."
|
||||
"omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy.",
|
||||
"alkaline-phosphatase": "Fosfataza alkaliczna (ALP) to enzym występujący głównie w wątrobie, drogach żółciowych i kościach. Rośnie przy utrudnionym odpływie żółci oraz w okresach aktywnej przebudowy kości, na przykład podczas wzrostu u dzieci i młodzieży albo gojenia złamania. Ponieważ uwalniają ją oba narządy, podwyższony wynik czyta się zwykle razem z GGTP i pozostałymi próbami wątrobowymi, aby odróżnić przyczynę wątrobową od kostnej. Należy do standardowych prób wątrobowych.",
|
||||
"bilirubin-total": "Bilirubina całkowita to żółty produkt rozpadu hemoglobiny ze zużytych czerwonych krwinek, mierzony jako suma jej postaci niesprzężonej i sprzężonej. Wątroba ją wychwytuje, sprzęga i wydala z żółcią, więc jej poziom pokazuje, jak działa ta droga. Może rosnąć przy chorobach wątroby, zablokowanych drogach żółciowych lub nasilonym rozpadzie czerwonych krwinek, a nieszkodliwie w zespole Gilberta. Odczytuje się ją razem z pozostałymi próbami wątrobowymi."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina na czczo",
|
||||
"omega-3-index": "Indeks omega-3"
|
||||
"omega-3-index": "Indeks omega-3",
|
||||
"alkaline-phosphatase": "Fosfataza alkaliczna (ALP)",
|
||||
"bilirubin-total": "Bilirubina całkowita"
|
||||
},
|
||||
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
|
||||
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
|
||||
@@ -9703,6 +9707,54 @@
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
"catalogDiffers": "twój zakres {range}",
|
||||
"printed": "wydrukowano w {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "według nazwy, którą wybrałeś wcześniej",
|
||||
"viaCatalog": "rozpoznano jako popularny skrót",
|
||||
"chooseLabel": "Należy do",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Nowy biomarker",
|
||||
"rememberHint": "Ta nazwa zostanie zapamiętana na następny wynik.",
|
||||
"chooseHint": "Wybierz biomarker, jeśli już go śledzisz."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Zapisywany też jako",
|
||||
"taken": "{alias} należy już do innego biomarkera.",
|
||||
"saveError": "Nie udało się zapisać nazw. Spróbuj ponownie.",
|
||||
"remove": "Usuń {alias}",
|
||||
"empty": "Dodaj nazwy, które inne laboratoria drukują dla {name}, aby ich wyniki trafiały tutaj.",
|
||||
"placeholder": "np. ALP",
|
||||
"addLabel": "Inna nazwa tego biomarkera",
|
||||
"add": "Dodaj",
|
||||
"alsoWritten": "Zapisywany też jako {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Scal",
|
||||
"title": "Scal biomarkery",
|
||||
"description": "Połącz biomarkery, które są tym samym badaniem zapisanym inaczej.",
|
||||
"pickHint": "Zaznacz co najmniej dwa biomarkery.",
|
||||
"keepLabel": "Zachowaj",
|
||||
"keepHint": "Pozostałe zostaną włączone do tego, a ich nazwy staną się jego innymi nazwami.",
|
||||
"showPreview": "Pokaż podgląd",
|
||||
"back": "Wstecz",
|
||||
"confirm": "Scal teraz",
|
||||
"previewSummaryOne": "{count} pomiar przechodzi do {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} pomiary przechodzą do {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} pomiarów przechodzi do {name} ({unit}).",
|
||||
"movesOne": "{count} pomiar, już w tej samej jednostce",
|
||||
"movesFew": "{count} pomiary, już w tej samej jednostce",
|
||||
"movesOther": "{count} pomiarów, już w tej samej jednostce",
|
||||
"convertedOne": "{count} pomiar, przeliczony z {unit} na {targetUnit}",
|
||||
"convertedFew": "{count} pomiary, przeliczone z {unit} na {targetUnit}",
|
||||
"convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}",
|
||||
"refused": "Nie można przeliczyć {unit} na {targetUnit}.",
|
||||
"aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:",
|
||||
"blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.",
|
||||
"noUndo": "Scalenia nie można cofnąć.",
|
||||
"doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar",
|
||||
"doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary",
|
||||
"doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów",
|
||||
"error": "Nie udało się scalić biomarkerów. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11142,7 +11194,8 @@
|
||||
"extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
|
||||
"failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
|
||||
"failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
|
||||
"failGeneric": "Nie udało się zapisać tej wartości."
|
||||
"failGeneric": "Nie udało się zapisać tej wartości.",
|
||||
"failUnknownBiomarker": "Wybrany biomarker już nie istnieje. Wybierz inny."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
|
||||
@@ -12028,7 +12081,8 @@
|
||||
"customVaccineDelete": "{name} usunął własną szczepionkę",
|
||||
"lifeEventCreate": "{name} dodał wydarzenie życiowe",
|
||||
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe"
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
|
||||
"biomarkerMerge": "{name} scalił biomarkery"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12247,7 +12301,10 @@
|
||||
"removed": "Odłączono: {name}",
|
||||
"linked": "Połączone",
|
||||
"loadNewer": "Wczytaj nowsze",
|
||||
"loadOlder": "Wczytaj starsze"
|
||||
"loadOlder": "Wczytaj starsze",
|
||||
"period": {
|
||||
"showing": "Okres: {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
@@ -9905,11 +9905,11 @@ model BiomarkerAlias {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
biomarkerId String @map("biomarker_id")
|
||||
/// @pending-consumer(#v143-B3) The alias as written, shown on the marker.
|
||||
/// The alias as written, shown on the marker.
|
||||
name String @db.VarChar(120)
|
||||
/// @pending-consumer(#v143-B3) `foldForMatch(name)`, the match key; unique per account.
|
||||
/// `foldForMatch(name)`, the match key; unique per account.
|
||||
nameFolded String @map("name_folded") @db.VarChar(120)
|
||||
/// @pending-consumer(#v143-B3) `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
|
||||
/// `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -9927,15 +9927,15 @@ model BiomarkerAlias {
|
||||
model BiomarkerMerge {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// @pending-consumer(#v143-B3) The marker the source was folded into.
|
||||
/// The marker the source was folded into.
|
||||
targetBiomarkerId String @map("target_biomarker_id")
|
||||
/// @pending-consumer(#v143-B3) Id of the deleted source marker (no FK: the row is gone).
|
||||
/// Id of the deleted source marker (no FK: the row is gone).
|
||||
sourceBiomarkerId String @map("source_biomarker_id")
|
||||
/// @pending-consumer(#v143-B3) The source marker's name at merge time.
|
||||
/// The source marker's name at merge time.
|
||||
sourceName String @map("source_name") @db.VarChar(120)
|
||||
/// @pending-consumer(#v143-B3) How many readings moved from the source.
|
||||
/// How many readings moved from the source.
|
||||
readingCount Int @map("reading_count")
|
||||
/// @pending-consumer(#v143-B3) When the merge ran.
|
||||
/// When the merge ran.
|
||||
mergedAt DateTime @default(now()) @map("merged_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -39,7 +39,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
|
||||
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
|
||||
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
|
||||
@@ -859,6 +859,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "labs",
|
||||
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.",
|
||||
@@ -1612,6 +1616,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: ["C3", "C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C3"],
|
||||
why: "Merging markers moves their readings into one and deletes the sources, with no undo, so it sits at the same level as the delete beside it. C3: each deleted source files its id and how many readings went with it; the name stays in the merge ledger. The shared record-write ceiling is charged to the actor, like every record write.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
@@ -2117,8 +2126,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
|
||||
* (`measurements`). The biomarker DELETE joins the manage literal on a module
|
||||
* that was already there. 256 -> 258.
|
||||
*
|
||||
* v1.43 -- merging biomarkers joins the record list and the manage literal
|
||||
* (D1223). 258 -> 260.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 258;
|
||||
const FROZEN_ENTRY_COUNT = 260;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2879,7 +2891,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(89);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -89,7 +89,7 @@ const SERVER_FEATURES = {
|
||||
// form work; until then a client must not send it (contract §0.6).
|
||||
cumulativeHourBuckets: false,
|
||||
syncTriggerBackfill: false,
|
||||
biomarkerMerge: false,
|
||||
biomarkerMerge: true,
|
||||
lifeCircumstances: false,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prisma = {
|
||||
biomarker: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
// v1.43 — the marker's other spellings, read and replaced with it.
|
||||
biomarkerAlias: {
|
||||
findMany: vi.fn(async () => []),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
biomarkerMerge: { findFirst: vi.fn(async () => null) },
|
||||
labResult: { deleteMany: vi.fn() },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
};
|
||||
// `$transaction` is set in `beforeEach`: the batch form for the delete, the
|
||||
// callback form for the edit.
|
||||
return { prisma: { ...prisma, $transaction: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -82,9 +96,13 @@ beforeEach(() => {
|
||||
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
|
||||
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
ops: Promise<unknown>[],
|
||||
) => Promise.all(ops)) as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: unknown) => Promise<unknown>)(prisma)
|
||||
: Promise.all(arg as Promise<unknown>[])) as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/biomarkers/[id]", () => {
|
||||
|
||||
@@ -7,19 +7,32 @@
|
||||
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
|
||||
* are deleted. A pair of units without a known conversion refuses the whole
|
||||
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
|
||||
* answers the same result and writes nothing. There is no undo.
|
||||
* answers the same result and writes nothing, refusals included, so the
|
||||
* preview can show them. There is no undo. See `src/lib/labs/biomarker-merge.ts`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
|
||||
* record-write ceiling. Until the merge lands the stub resolves the caller's
|
||||
* own record only.
|
||||
* Authority: `requireRecordAuth("manage", "labs")`, the same as the edit and
|
||||
* the delete beside it, because a merge rewrites and deletes markers. The
|
||||
* shared record-write ceiling is charged to the ACTOR (C1). Each deleted
|
||||
* source files what was destroyed (C3): its id and how many readings it took
|
||||
* along, never its name; the name stays in the merge ledger.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { destroyedDetails } from "@/lib/sharing/audit-details";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -32,10 +45,10 @@ async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
@@ -58,5 +71,74 @@ async function mergeBiomarkers(
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("labs.biomarker.merge");
|
||||
const outcome = await runMerge({
|
||||
userId: user.id,
|
||||
targetId: id,
|
||||
sourceIds: parsed.data.sourceIds,
|
||||
dryRun: parsed.data.dryRun,
|
||||
});
|
||||
|
||||
if (outcome.kind === "notFound") {
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
ids: outcome.ids,
|
||||
});
|
||||
}
|
||||
if (outcome.kind === "unitMismatch") {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.merge.unit-mismatch" },
|
||||
meta: { biomarkerId: id, refused: outcome.sources.length },
|
||||
});
|
||||
return apiError(
|
||||
"Some readings are in a unit that cannot be converted to the target's unit",
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.biomarker.merge.unitMismatch",
|
||||
sources: outcome.sources,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = outcome;
|
||||
annotate({
|
||||
action: {
|
||||
name: result.dryRun
|
||||
? "labs.biomarker.merge.preview"
|
||||
: "labs.biomarker.merge",
|
||||
},
|
||||
meta: {
|
||||
biomarkerId: id,
|
||||
sources: result.sources.length,
|
||||
movedReadings: result.movedReadings,
|
||||
convertedReadings: result.convertedReadings,
|
||||
},
|
||||
});
|
||||
if (result.dryRun) return apiSuccess(result);
|
||||
|
||||
invalidateUserHealthScore(user.id);
|
||||
const ipAddress = getClientIp(request);
|
||||
for (const source of result.sources) {
|
||||
await auditLog("labs.biomarker.merge", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
// IDs and numbers only. The source's name is not filed here; the merge
|
||||
// ledger keeps it with the row this audit points at.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
mergeId: result.mergeId,
|
||||
...destroyedDetails({
|
||||
model: "Biomarker",
|
||||
id: source.id,
|
||||
label: null,
|
||||
effectiveAt: new Date(),
|
||||
extra: {
|
||||
readingCount: source.readingCount,
|
||||
converted: source.conversion === "converted",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
}
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
aliasNamesByMarker,
|
||||
findAliasConflict,
|
||||
replaceAliases,
|
||||
} from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { mergedInto } from "@/lib/labs/biomarker-merge";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -38,37 +42,6 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
@@ -76,15 +49,23 @@ export const GET = apiHandler(
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
// v1.43 — a marker merged into another names where it went, so a
|
||||
// client holding the old id (a cached screen, a bookmark) can follow.
|
||||
const into = await mergedInto(user.id, id);
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
...(into ? { mergedInto: into } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, [row]);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.get" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(row));
|
||||
return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? []));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -171,6 +152,20 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the aliases, when sent, become the marker's whole set. A
|
||||
// spelling another marker already answers to (its name or one of its
|
||||
// aliases) is refused rather than silently moved.
|
||||
if (d.aliases !== undefined) {
|
||||
const conflict = await findAliasConflict(prisma, user.id, id, d.aliases);
|
||||
if (conflict) {
|
||||
return apiError("Another biomarker already answers to this name", 409, {
|
||||
errorCode: "labs.biomarker.alias.taken",
|
||||
alias: conflict.alias,
|
||||
biomarkerId: conflict.biomarkerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `context` / `panel` / a bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -212,7 +207,17 @@ export const PUT = apiHandler(
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await prisma.biomarker.update({ where: { id }, data });
|
||||
const aliasesBefore =
|
||||
(await aliasNamesByMarker(prisma, user.id, [existing])).get(id) ?? [];
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.biomarker.update({ where: { id }, data });
|
||||
if (d.aliases !== undefined) {
|
||||
await replaceAliases(tx, user.id, row, d.aliases);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const aliasesAfter =
|
||||
(await aliasNamesByMarker(prisma, user.id, [updated])).get(id) ?? [];
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("biomarker.update", {
|
||||
@@ -241,7 +246,13 @@ export const PUT = apiHandler(
|
||||
panel: updated.panel,
|
||||
hidden: updated.hidden,
|
||||
},
|
||||
redacted: d.context !== undefined ? ["context"] : [],
|
||||
redacted: [
|
||||
...(d.context !== undefined ? ["context"] : []),
|
||||
// Named, not quoted: the spellings are the person's own text.
|
||||
...(aliasesBefore.join("\n") !== aliasesAfter.join("\n")
|
||||
? ["aliases"]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -251,7 +262,7 @@ export const PUT = apiHandler(
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(updated));
|
||||
return apiSuccess(serialiseBiomarker(updated, aliasesAfter));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
decryptContextSoft,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -45,35 +44,6 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
// a bad-key / malformed row) so one un-decryptable row never 500s the whole
|
||||
// catalog — the single-resource GET is the authoritative read for the
|
||||
// plaintext context. This bounds the fail-closed blast radius to one marker.
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: decryptContextSoft(row.contextEncrypted),
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
@@ -84,12 +54,19 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: [{ hidden: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
// v1.43 — every marker's other spellings, one read for the whole list.
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, rows);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.list" },
|
||||
meta: { total: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
|
||||
return apiSuccess({
|
||||
biomarkers: rows.map((row) =>
|
||||
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
|
||||
@@ -179,5 +156,5 @@ async function postBiomarker(request: NextRequest) {
|
||||
meta: { biomarkerId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(created), 201);
|
||||
return apiSuccess(serialiseBiomarker(created, [], { soft: true }), 201);
|
||||
}
|
||||
@@ -100,7 +100,12 @@ async function confirmDocument(
|
||||
}
|
||||
|
||||
try {
|
||||
const ref = await commitApprovedFact(user.id, fact);
|
||||
// v1.43 (D1223) — an OBSERVATION may name the marker the person chose
|
||||
// on the review screen; the label is then remembered as its alias.
|
||||
const ref = await commitApprovedFact(user.id, fact, {
|
||||
biomarkerId: decision.biomarkerId,
|
||||
rememberAlias: decision.rememberAlias,
|
||||
});
|
||||
await prisma.extractedFact.update({
|
||||
where: { id: fact.id },
|
||||
data: {
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/documents/store", () => ({
|
||||
encryptFactData: vi.fn(() => new Uint8Array([1])),
|
||||
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
|
||||
serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
|
||||
markerIndexForFacts: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
loadDocumentChatText: vi.fn(),
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
@@ -241,7 +244,19 @@ async function runNow(
|
||||
origin: { ipAddress: getClientIp(request), worker: false },
|
||||
});
|
||||
if (!outcome.ok) return outcomeResponse(outcome);
|
||||
return apiSuccess(serialiseDocumentDetail(outcome.data, outcome.data.facts));
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
outcome.data,
|
||||
outcome.data.facts,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
await markerIndexForFacts(userId, outcome.data.facts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptFactData, serialiseFact } from "@/lib/documents/store";
|
||||
import {
|
||||
encryptFactData,
|
||||
markerIndexForFacts,
|
||||
serialiseFact,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
@@ -125,6 +129,8 @@ export const PATCH = apiHandler(
|
||||
meta: { documentId: id, factId: fact.id, factType: fact.factType },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseFact(updated));
|
||||
return apiSuccess(
|
||||
serialiseFact(updated, await markerIndexForFacts(user.id, [updated])),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
replaceVaccinationLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -138,6 +141,8 @@ export const GET = apiHandler(
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
|
||||
// v1.43 — the marker each pending lab value resolves to, or choices.
|
||||
await markerIndexForFacts(user.id, document.facts),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -86,6 +86,8 @@ vi.mock("@/lib/db", () => ({
|
||||
// helper now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
biomarkerMerge: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
@@ -135,6 +137,8 @@ beforeEach(() => {
|
||||
// `resetAllMocks` clears the inline defaults, and the full-backup payload
|
||||
// builder reads these unconditionally.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -67,6 +67,8 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
biomarkerMerge: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
@@ -205,6 +207,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
|
||||
@@ -77,6 +77,8 @@ vi.mock("@/lib/db", () => ({
|
||||
// builder now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
allergy: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
// v1.43 — the account's markers and aliases, loaded once per commit.
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn(), upsert: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -107,6 +110,8 @@ beforeEach(() => {
|
||||
// No live reading collides at commit time — the in-batch path is what's
|
||||
// under test, so the DB-visibility check returns empty for every row.
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
let nextId = 0;
|
||||
vi.mocked(prisma.labResult.create).mockImplementation((() =>
|
||||
Promise.resolve({ id: `lr-${++nextId}` })) as never);
|
||||
|
||||
@@ -34,6 +34,13 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
|
||||
import {
|
||||
addAliasToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
type IndexedMarker,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
@@ -69,13 +76,20 @@ async function isDuplicate(
|
||||
userId: string,
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
/** The marker the row resolves to, when it resolves to one already. */
|
||||
biomarkerId: string | null,
|
||||
): Promise<boolean> {
|
||||
const day = labReadingDay(new Date(row.takenAt), tz);
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
|
||||
// The printed name, or a reading already filed under the marker this
|
||||
// name resolves to: an earlier report may have spelled it another way.
|
||||
OR: [
|
||||
{ analyte: { equals: row.analyte.trim(), mode: "insensitive" } },
|
||||
...(biomarkerId ? [{ biomarkerId }] : []),
|
||||
],
|
||||
takenAt: labReadingDaySearchRange(day),
|
||||
},
|
||||
select: {
|
||||
@@ -123,8 +137,15 @@ async function isDuplicate(
|
||||
* Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
|
||||
* on a prior row autocommitting before the next row's live query runs.
|
||||
*/
|
||||
function inBatchKey(row: OcrCommitRow, tz: string): string {
|
||||
const analyte = row.analyte.trim().toLowerCase();
|
||||
function inBatchKey(
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
biomarkerId: string | null,
|
||||
): string {
|
||||
// Two spellings of one marker in one document are one marker (D1223).
|
||||
const analyte = biomarkerId
|
||||
? `bm:${biomarkerId}`
|
||||
: row.analyte.trim().toLowerCase();
|
||||
const dayKey = labReadingDay(new Date(row.takenAt), tz);
|
||||
const valuePart =
|
||||
row.value !== undefined
|
||||
@@ -188,14 +209,59 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const writtenInBatch = new Set<string>();
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
// v1.43 (D1223) — the account's markers and aliases, loaded once: every row
|
||||
// resolves its name against them (name, alias, catalog abbreviation) the
|
||||
// way the review did, and a marker minted for one row is found by the next.
|
||||
const markerIndex = await loadMarkerIndex(prisma, user.id);
|
||||
let aliasesRemembered = 0;
|
||||
for (const row of parsed.data.rows) {
|
||||
const key = inBatchKey(row, tz);
|
||||
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) {
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
|
||||
// The marker the person picked on the review screen, if they picked one.
|
||||
let chosen: IndexedMarker | null = null;
|
||||
if (row.biomarkerId) {
|
||||
chosen =
|
||||
markerIndex.markers.find((m) => m.id === row.biomarkerId) ?? null;
|
||||
if (!chosen) {
|
||||
skipped.push({
|
||||
analyte: row.analyte.trim(),
|
||||
reason: "unknown_biomarker",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// The choice is remembered, so the next report spelled this way
|
||||
// matches on its own.
|
||||
if (row.rememberAlias !== false) {
|
||||
const wrote = await rememberImportAlias(
|
||||
prisma,
|
||||
user.id,
|
||||
chosen,
|
||||
row.analyte,
|
||||
);
|
||||
if (wrote) {
|
||||
addAliasToIndex(markerIndex, chosen.id, row.analyte);
|
||||
aliasesRemembered += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedId =
|
||||
chosen?.id ??
|
||||
resolveInIndex(
|
||||
markerIndex,
|
||||
row.analyte,
|
||||
isQualitative ? null : (row.unit ?? null),
|
||||
)?.marker.id ??
|
||||
null;
|
||||
|
||||
const key = inBatchKey(row, tz, resolvedId);
|
||||
if (
|
||||
writtenInBatch.has(key) ||
|
||||
(await isDuplicate(user.id, row, tz, resolvedId))
|
||||
) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
// The window the scan read off the report. The confirmed numeric bounds
|
||||
// win over a re-parse of the string (the human may have corrected them on
|
||||
// the review screen); the string rides along either way.
|
||||
@@ -203,14 +269,20 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const sourceLow = row.referenceLow ?? printed?.low ?? null;
|
||||
const sourceHigh = row.referenceHigh ?? printed?.high ?? null;
|
||||
const sourceText = printed?.text ?? null;
|
||||
const biomarker = await resolveOrMintBiomarker(user.id, {
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
const biomarker = chosen
|
||||
? chosen
|
||||
: await resolveOrMintBiomarker(
|
||||
user.id,
|
||||
{
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
},
|
||||
{ index: markerIndex },
|
||||
);
|
||||
|
||||
// The marker's unit is the one the reading is stored under. A row that
|
||||
// states another unit is converted when the marker's analyte is known and
|
||||
@@ -273,6 +345,8 @@ async function commitOcrRows(request: NextRequest) {
|
||||
});
|
||||
|
||||
writtenInBatch.add(key);
|
||||
// A freshly minted marker is now the row's; key the batch on it too.
|
||||
writtenInBatch.add(inBatchKey(row, tz, biomarker.id));
|
||||
inserted.push(serialiseLabResult(created, biomarker));
|
||||
linkable.push({
|
||||
labResultId: created.id,
|
||||
@@ -319,7 +393,11 @@ async function commitOcrRows(request: NextRequest) {
|
||||
await auditLog("labs.ocr.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count: inserted.length, skipped: skipped.length },
|
||||
details: {
|
||||
count: inserted.length,
|
||||
skipped: skipped.length,
|
||||
aliasesRemembered,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
|
||||
setTerm: vi.fn(),
|
||||
searchPending: false,
|
||||
filterLocally: true,
|
||||
period: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
loadingOlder: false,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
*/
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
@@ -36,7 +37,11 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MarkerMatchField } from "@/components/labs/marker-match-field";
|
||||
import type { BiomarkerListResponse } from "@/components/labs/types";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ConditionFactData,
|
||||
@@ -62,6 +67,8 @@ function commitFailureKey(reason: string): string {
|
||||
return "documents.review.failUnitRequired";
|
||||
case "observation.unitMismatch":
|
||||
return "documents.review.failUnitMismatch";
|
||||
case "observation.unknownBiomarker":
|
||||
return "documents.review.failUnknownBiomarker";
|
||||
default:
|
||||
return "documents.review.failGeneric";
|
||||
}
|
||||
@@ -203,6 +210,9 @@ function FactRow({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
failure,
|
||||
chosenBiomarkerId,
|
||||
onChooseBiomarker,
|
||||
markerName,
|
||||
}: {
|
||||
documentId: string;
|
||||
fact: ExtractedFactDto;
|
||||
@@ -210,6 +220,11 @@ function FactRow({
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
/** A per-fact commit refusal from the last confirm attempt, or null. */
|
||||
failure: string | null;
|
||||
/** v1.43 — the suggestion the person picked for a lab value, or null. */
|
||||
chosenBiomarkerId: string | null;
|
||||
onChooseBiomarker: (biomarkerId: string | null) => void;
|
||||
/** The account's marker name by id, for the matched marker. */
|
||||
markerName: (biomarkerId: string) => string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const calendarDate = useCalendarDate();
|
||||
@@ -265,6 +280,25 @@ function FactRow({
|
||||
{typeLabel}
|
||||
{date ? `, ${calendarDate(date)}` : ""}
|
||||
</p>
|
||||
{isObservation &&
|
||||
(fact.biomarkerId !== undefined ||
|
||||
(fact.suggestions?.length ?? 0) > 0) ? (
|
||||
<MarkerMatchField
|
||||
matchedName={
|
||||
fact.biomarkerId
|
||||
? (markerName(fact.biomarkerId) ??
|
||||
(fact.data as ObservationFactData).label)
|
||||
: null
|
||||
}
|
||||
matchKind={fact.matchKind ?? null}
|
||||
suggestions={fact.suggestions ?? []}
|
||||
chosenId={chosenBiomarkerId}
|
||||
onChoose={(suggestion) =>
|
||||
onChooseBiomarker(suggestion?.biomarkerId ?? null)
|
||||
}
|
||||
slot="document-fact-marker"
|
||||
/>
|
||||
) : null}
|
||||
{isObservation && (fact.data as ObservationFactData).referenceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words">
|
||||
{t("documents.review.statedRange", {
|
||||
@@ -461,6 +495,18 @@ export function DocumentFactsSection({
|
||||
// until it is verified (which checks it).
|
||||
const [included, setIncluded] = useState<Record<string, boolean>>({});
|
||||
const [failures, setFailures] = useState<Record<string, string>>({});
|
||||
// v1.43 — the marker the person picked per lab value, keyed by fact.
|
||||
const [chosen, setChosen] = useState<Record<string, string | null>>({});
|
||||
const hasObservations = doc.facts.some(
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status === "PENDING",
|
||||
);
|
||||
const markers = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
enabled: canManage && hasObservations && open,
|
||||
});
|
||||
const markerName = (id: string) =>
|
||||
markers.data?.biomarkers.find((m) => m.id === id)?.name ?? null;
|
||||
|
||||
const pending = doc.facts.filter((fact) => fact.status === "PENDING");
|
||||
const approvedCount = doc.facts.filter(
|
||||
@@ -483,6 +529,7 @@ export function DocumentFactsSection({
|
||||
const decisions = decidable.map((fact) => ({
|
||||
factId: fact.id,
|
||||
action: isChecked(fact) ? ("approve" as const) : ("reject" as const),
|
||||
...(chosen[fact.id] ? { biomarkerId: chosen[fact.id] as string } : {}),
|
||||
}));
|
||||
confirmFacts.mutate(
|
||||
{ documentId: doc.id, decisions },
|
||||
@@ -567,6 +614,11 @@ export function DocumentFactsSection({
|
||||
checked={isChecked(fact)}
|
||||
onCheckedChange={(checked) => setChecked(fact.id, checked)}
|
||||
failure={failures[fact.id] ?? null}
|
||||
chosenBiomarkerId={chosen[fact.id] ?? null}
|
||||
onChooseBiomarker={(id) =>
|
||||
setChosen((prev) => ({ ...prev, [fact.id]: id }))
|
||||
}
|
||||
markerName={markerName}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -114,7 +114,12 @@ export function useConfirmFacts() {
|
||||
Error,
|
||||
{
|
||||
documentId: string;
|
||||
decisions: { factId: string; action: "approve" | "reject" }[];
|
||||
decisions: {
|
||||
factId: string;
|
||||
action: "approve" | "reject";
|
||||
/** v1.43 — the marker the person chose for a lab value. */
|
||||
biomarkerId?: string;
|
||||
}[];
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ documentId, decisions }) =>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManageDomain: () => true }),
|
||||
}));
|
||||
|
||||
import { BiomarkerAliases } from "../biomarker-aliases";
|
||||
import { BiomarkerMergeSheet } from "../biomarker-merge-sheet";
|
||||
import type { BiomarkerDto } from "../types";
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
footer?: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function wrap(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const marker = (overrides: Partial<BiomarkerDto>): BiomarkerDto => ({
|
||||
id: "bm-1",
|
||||
name: "Alkaline Phosphatase",
|
||||
unit: "U/L",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hasContext: false,
|
||||
context: null,
|
||||
hidden: false,
|
||||
aliases: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("<BiomarkerAliases>", () => {
|
||||
it("shows the other spellings as chips, removable for a manager", () => {
|
||||
const html = wrap(
|
||||
<BiomarkerAliases
|
||||
marker={marker({ aliases: ["Alk.P", "AP"] })}
|
||||
canEdit
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Also written as");
|
||||
expect(html).toContain("Alk.P");
|
||||
expect(html).toContain('aria-label="Remove AP"');
|
||||
expect(html).toContain("Another name for this biomarker");
|
||||
});
|
||||
|
||||
it("is read-only without the manage right, and absent when there is nothing to show", () => {
|
||||
const readOnly = wrap(
|
||||
<BiomarkerAliases
|
||||
marker={marker({ aliases: ["Alk.P"] })}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(readOnly).toContain("Alk.P");
|
||||
expect(readOnly).not.toContain("Remove Alk.P");
|
||||
expect(readOnly).not.toContain("<form");
|
||||
expect(wrap(<BiomarkerAliases marker={marker({})} canEdit={false} />)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks German too", () => {
|
||||
expect(
|
||||
wrap(<BiomarkerAliases marker={marker({})} canEdit />, "de"),
|
||||
).toContain("Auch geschrieben als");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BiomarkerMergeSheet>", () => {
|
||||
it("opens on the pick step with every marker and no preview yet", () => {
|
||||
const html = wrap(
|
||||
<BiomarkerMergeSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
markers={[
|
||||
marker({ id: "a", name: "Alkaline Phosphatase" }),
|
||||
marker({ id: "b", name: "Alk.P" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Merge biomarkers");
|
||||
expect(html).toContain('data-slot="biomarker-merge-select"');
|
||||
expect(html).toContain("Alk.P");
|
||||
expect(html).toContain("Select at least two biomarkers.");
|
||||
// The preview button waits for a selection.
|
||||
expect(html).toMatch(
|
||||
/<button[^>]*data-slot="biomarker-merge-preview"[^>]*disabled=""/,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="biomarker-merge-confirm"');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
|
||||
.ApiError,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiDelete: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import {
|
||||
chooseMarker,
|
||||
seedReviewRows,
|
||||
type OcrReviewRow,
|
||||
} from "../ocr-review-types";
|
||||
import { toCommitRow } from "../ocr-review-validation";
|
||||
import { OcrRowEditor } from "../ocr-row-editor";
|
||||
|
||||
const dto: OcrExtractedRowDto = {
|
||||
analyte: "Bilirubin ges.",
|
||||
value: 0.8,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-01",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "new",
|
||||
markerUnit: null,
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: null,
|
||||
matchKind: null,
|
||||
suggestions: [
|
||||
{
|
||||
biomarkerId: "bm-bili",
|
||||
name: "Bilirubin (Total)",
|
||||
unit: "µmol/L",
|
||||
matchKind: "similar",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const lookup = (id: string) =>
|
||||
id === "bm-bili"
|
||||
? { name: "Bilirubin (Total)", analyteKey: "bilirubin-total" }
|
||||
: id === "bm-alp"
|
||||
? { name: "Alkaline Phosphatase", analyteKey: null }
|
||||
: null;
|
||||
|
||||
function render(row: OcrReviewRow, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OcrRowEditor row={row} onChange={() => {}} markerLookup={lookup} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the scan review's marker choice (D1223)", () => {
|
||||
it("seeds the match and the suggestions, with nothing chosen", () => {
|
||||
const [row] = seedReviewRows([dto], null);
|
||||
expect(row).toMatchObject({
|
||||
biomarkerId: null,
|
||||
matchKind: null,
|
||||
chosenBiomarkerId: null,
|
||||
suggestions: dto.suggestions,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a scan from before the fields existed as no match", () => {
|
||||
const legacy = { ...dto } as Partial<OcrExtractedRowDto>;
|
||||
delete legacy.biomarkerId;
|
||||
delete legacy.matchKind;
|
||||
delete legacy.suggestions;
|
||||
const [row] = seedReviewRows([legacy as OcrExtractedRowDto], null);
|
||||
expect(row.suggestions).toEqual([]);
|
||||
expect(row.biomarkerId).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the suggestions and a new marker, new by default", () => {
|
||||
const [row] = seedReviewRows([dto], null);
|
||||
const html = render(row);
|
||||
expect(html).toContain('data-slot="ocr-row-marker-choice"');
|
||||
expect(html).toContain("Bilirubin (Total) (µmol/L)");
|
||||
expect(html).toContain("A new biomarker");
|
||||
expect(html).toMatch(/<option value="" selected="">/);
|
||||
});
|
||||
|
||||
it("names the matched marker and how it was found", () => {
|
||||
const [row] = seedReviewRows(
|
||||
[
|
||||
{
|
||||
...dto,
|
||||
analyte: "Alk.P",
|
||||
biomarkerId: "bm-alp",
|
||||
matchKind: "catalog",
|
||||
suggestions: [],
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "U/L",
|
||||
},
|
||||
],
|
||||
null,
|
||||
);
|
||||
const html = render(row);
|
||||
expect(html).toContain("Links to existing Alkaline Phosphatase");
|
||||
expect(html).toContain("recognised as a common abbreviation");
|
||||
expect(html).not.toContain('data-slot="ocr-row-marker-choice"');
|
||||
expect(render(row, "de")).toContain("als übliche Abkürzung erkannt");
|
||||
});
|
||||
|
||||
it("files a chosen row under the marker, judged in its unit", () => {
|
||||
const [row] = seedReviewRows([dto], null);
|
||||
const chosen = chooseMarker(
|
||||
row,
|
||||
dto.suggestions[0],
|
||||
(id) => lookup(id)?.analyteKey ?? null,
|
||||
);
|
||||
expect(chosen).toMatchObject({
|
||||
chosenBiomarkerId: "bm-bili",
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "µmol/L",
|
||||
markerAnalyteKey: "bilirubin-total",
|
||||
});
|
||||
expect(toCommitRow(chosen)).toMatchObject({ biomarkerId: "bm-bili" });
|
||||
|
||||
const back = chooseMarker(chosen, null, () => null);
|
||||
expect(back).toMatchObject({
|
||||
chosenBiomarkerId: null,
|
||||
biomarkerMatch: "new",
|
||||
markerUnit: null,
|
||||
});
|
||||
expect(toCommitRow(back)).not.toHaveProperty("biomarkerId");
|
||||
});
|
||||
|
||||
it("sends no marker for an automatic match", () => {
|
||||
const [row] = seedReviewRows(
|
||||
[{ ...dto, biomarkerId: "bm-alp", matchKind: "alias", suggestions: [] }],
|
||||
null,
|
||||
);
|
||||
expect(toCommitRow(row)).not.toHaveProperty("biomarkerId");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -76,17 +77,20 @@ function render(
|
||||
consentRequired?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
// The dialog reads the account's markers once rows are on screen (v1.43).
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OcrReviewDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
mode={options.mode ?? "vision"}
|
||||
pdfSupported={options.pdfSupported ?? true}
|
||||
consentRequired={options.consentRequired}
|
||||
onCommitted={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OcrReviewDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
mode={options.mode ?? "vision"}
|
||||
pdfSupported={options.pdfSupported ?? true}
|
||||
consentRequired={options.consentRequired}
|
||||
onCommitted={() => {}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
|
||||
markerUnit: "mmol/L",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-ldl",
|
||||
matchKind: "name",
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
...overrides,
|
||||
};
|
||||
@@ -205,6 +209,9 @@ describe("a reading in another unit than its marker's", () => {
|
||||
markerUnit,
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-glucose",
|
||||
matchKind: "name" as const,
|
||||
suggestions: [],
|
||||
});
|
||||
const [same, other, fresh] = seedReviewRows(
|
||||
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
|
||||
@@ -271,6 +278,9 @@ describe("a reading in another unit that can be converted", () => {
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-glucose",
|
||||
matchKind: "name" as const,
|
||||
suggestions: [],
|
||||
};
|
||||
const [converts, blocked] = seedReviewRows(
|
||||
[dto, { ...dto, markerAnalyteKey: null }],
|
||||
|
||||
@@ -21,6 +21,10 @@ const baseRow: OcrReviewRow = {
|
||||
markerUnit: "mmol/L",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-ldl",
|
||||
matchKind: "name",
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (D1223) — the other spellings a marker answers to.
|
||||
*
|
||||
* Shown as chips on the marker; with the labs manage right they can be added
|
||||
* and removed. Every change sends the whole set (`PUT /api/biomarkers/{id}`
|
||||
* with `aliases`), which is how the server reads it. A spelling another marker
|
||||
* already answers to is refused with `labs.biomarker.alias.taken`; the
|
||||
* sentence names the spelling so the person knows which one to drop.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { ApiError, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto } from "./types";
|
||||
|
||||
/** Longest alias the server stores. */
|
||||
const ALIAS_MAX = 120;
|
||||
|
||||
export function BiomarkerAliases({
|
||||
marker,
|
||||
canEdit,
|
||||
}: {
|
||||
marker: Pick<BiomarkerDto, "id" | "name" | "aliases">;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const aliases = marker.aliases ?? [];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (next: string[]) =>
|
||||
apiPut<BiomarkerDto>(`/api/biomarkers/${marker.id}`, { aliases: next }),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setDraft("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.biomarkerDetail(marker.id),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
},
|
||||
onError: (err) => {
|
||||
const alias =
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "labs.biomarker.alias.taken" &&
|
||||
typeof err.meta.alias === "string"
|
||||
? err.meta.alias
|
||||
: null;
|
||||
setError(
|
||||
alias
|
||||
? t("labs.aliases.taken", { alias })
|
||||
: t("labs.aliases.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (aliases.length === 0 && !canEdit) return null;
|
||||
|
||||
const add = () => {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
if (aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
save.mutate([...aliases, name]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="biomarker-aliases">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("labs.aliases.title")}
|
||||
</p>
|
||||
{aliases.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{aliases.map((alias) => (
|
||||
<li key={alias}>
|
||||
<TagChip className="inline-flex items-center gap-1">
|
||||
{alias}
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
onClick={() =>
|
||||
save.mutate(aliases.filter((a) => a !== alias))
|
||||
}
|
||||
disabled={save.isPending}
|
||||
aria-label={t("labs.aliases.remove", { alias })}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</TagChip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.aliases.empty", { name: marker.name })}
|
||||
</p>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
add();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
maxLength={ALIAS_MAX}
|
||||
placeholder={t("labs.aliases.placeholder")}
|
||||
aria-label={t("labs.aliases.addLabel")}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
disabled={save.isPending || draft.trim() === ""}
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Plus className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("labs.aliases.add")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { Eye, EyeOff, FlaskConical, Merge, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
@@ -18,7 +18,10 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
|
||||
@@ -49,6 +52,10 @@ export function BiomarkerManager() {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
|
||||
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
// v1.43 (D1223) — merging is a manage-level act, like edit and delete.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canMerge = canManageDomain("labs");
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
@@ -125,6 +132,16 @@ export function BiomarkerManager() {
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
{marker.aliases && marker.aliases.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
data-slot="biomarker-row-aliases"
|
||||
>
|
||||
{t("labs.aliases.alsoWritten", {
|
||||
aliases: marker.aliases.join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -216,6 +233,18 @@ export function BiomarkerManager() {
|
||||
state's own button, so the card never offers the same action twice. */}
|
||||
{!isLoading && !isError && markers.length > 0 ? (
|
||||
<SettingsCardActions>
|
||||
{canMerge && markers.length >= 2 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMergeOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="biomarker-merge-open"
|
||||
>
|
||||
<Merge className="h-4 w-4" />
|
||||
{t("labs.merge.open")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.biomarker.define")}
|
||||
@@ -223,6 +252,14 @@ export function BiomarkerManager() {
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
|
||||
{canMerge ? (
|
||||
<BiomarkerMergeSheet
|
||||
open={mergeOpen}
|
||||
onOpenChange={setMergeOpen}
|
||||
markers={markers}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (D1223) — merge markers that are one analyte written two ways.
|
||||
*
|
||||
* Three steps in one sheet: pick the markers, pick the one that stays, read
|
||||
* the preview, then confirm. The preview is the server's own dry run
|
||||
* (`POST /api/biomarkers/{id}/merge` with `dryRun: true`): how many readings
|
||||
* move, which are converted into the kept marker's unit, which names become
|
||||
* its aliases, and any marker whose unit cannot be converted, which blocks the
|
||||
* merge. A merge cannot be undone, and the sheet says so before the button.
|
||||
* Nothing is computed here; every number is the server's.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
|
||||
/** The most sources one merge takes, matching the server. */
|
||||
const MAX_SOURCES = 10;
|
||||
|
||||
export function BiomarkerMergeSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
markers,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
markers: readonly BiomarkerDto[];
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [targetId, setTargetId] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<BiomarkerMergeResultDto | null>(null);
|
||||
|
||||
const target = targetId && selected.includes(targetId) ? targetId : null;
|
||||
const keepId = target ?? selected[0] ?? null;
|
||||
const sourceIds = useMemo(
|
||||
() => selected.filter((id) => id !== keepId),
|
||||
[selected, keepId],
|
||||
);
|
||||
const byId = useMemo(() => new Map(markers.map((m) => [m.id, m])), [markers]);
|
||||
|
||||
const reset = () => {
|
||||
setSelected([]);
|
||||
setTargetId(null);
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (dryRun: boolean) =>
|
||||
apiPost<BiomarkerMergeResultDto>(`/api/biomarkers/${keepId}/merge`, {
|
||||
sourceIds,
|
||||
dryRun,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
if (result.dryRun) {
|
||||
setPreview(result);
|
||||
return;
|
||||
}
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
tCount("labs.merge.doneToast", result.movedReadings, {
|
||||
name: result.target.name,
|
||||
}),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t("labs.merge.error")),
|
||||
});
|
||||
|
||||
const toggle = (id: string, checked: boolean) => {
|
||||
setPreview(null);
|
||||
setSelected((prev) =>
|
||||
checked
|
||||
? prev.length > MAX_SOURCES
|
||||
? prev
|
||||
: [...prev, id]
|
||||
: prev.filter((x) => x !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const refused = preview?.sources.filter((s) => s.conversion === "refused");
|
||||
const canPreview = keepId !== null && sourceIds.length > 0;
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
title={t("labs.merge.title")}
|
||||
description={t("labs.merge.description")}
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
{preview ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setPreview(null)}
|
||||
disabled={run.isPending}
|
||||
>
|
||||
{t("labs.merge.back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="biomarker-merge-confirm"
|
||||
onClick={() => run.mutate(false)}
|
||||
disabled={run.isPending || (refused?.length ?? 0) > 0}
|
||||
>
|
||||
{run.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("labs.merge.confirm")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="biomarker-merge-preview"
|
||||
onClick={() => run.mutate(true)}
|
||||
disabled={!canPreview || run.isPending}
|
||||
>
|
||||
{run.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("labs.merge.showPreview")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{preview ? (
|
||||
<div className="space-y-4" data-slot="biomarker-merge-preview-body">
|
||||
<p className="text-sm">
|
||||
{tCount("labs.merge.previewSummary", preview.movedReadings, {
|
||||
name: preview.target.name,
|
||||
unit: preview.target.unit,
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{preview.sources.map((source) => (
|
||||
<li
|
||||
key={source.id}
|
||||
className="border-border space-y-0.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-words">
|
||||
{source.name}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{source.unit}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
source.conversion === "refused"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{source.conversion === "refused"
|
||||
? t("labs.merge.refused", {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
})
|
||||
: source.conversion === "converted"
|
||||
? tCount("labs.merge.converted", source.readingCount, {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
})
|
||||
: tCount("labs.merge.moves", source.readingCount)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{preview.aliasesAdded.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.merge.aliasesAdded")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{preview.aliasesAdded.map((alias) => (
|
||||
<TagChip key={alias}>{alias}</TagChip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{(refused?.length ?? 0) > 0 ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("labs.merge.blocked")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{t("labs.merge.noUndo")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" data-slot="biomarker-merge-select">
|
||||
<ul className="space-y-1">
|
||||
{markers.map((marker) => {
|
||||
const id = `merge-pick-${marker.id}`;
|
||||
const checked = selected.includes(marker.id);
|
||||
return (
|
||||
<li key={marker.id}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="hover:bg-muted/50 flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={(state) =>
|
||||
toggle(marker.id, state === true)
|
||||
}
|
||||
disabled={!checked && selected.length > MAX_SOURCES}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{marker.unit}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{selected.length >= 2 ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="merge-keep">{t("labs.merge.keepLabel")}</Label>
|
||||
<NativeSelect
|
||||
id="merge-keep"
|
||||
value={keepId ?? ""}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{selected.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{byId.get(id)?.name ?? id}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.merge.keepHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.merge.pickHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -52,6 +52,7 @@ import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
import { BiomarkerAliases } from "./biomarker-aliases";
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import { LabForm } from "./lab-form";
|
||||
import type {
|
||||
@@ -158,12 +159,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const {
|
||||
data: marker,
|
||||
isError: markerError,
|
||||
error: markerFailure,
|
||||
refetch: refetchMarker,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
});
|
||||
|
||||
// v1.43 (D1223) — a marker merged into another answers 404 with the
|
||||
// marker it went into; a bookmark or a stale screen follows it there.
|
||||
const mergedInto =
|
||||
markerFailure instanceof ApiError &&
|
||||
markerFailure.status === 404 &&
|
||||
typeof markerFailure.meta?.mergedInto === "string"
|
||||
? markerFailure.meta.mergedInto
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!mergedInto) return;
|
||||
queryClient.removeQueries({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
});
|
||||
router.replace(`/labs/${mergedInto}`);
|
||||
}, [mergedInto, biomarkerId, queryClient, router]);
|
||||
|
||||
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
|
||||
// `limit=500` window (which silently truncated a marker once it crossed 500
|
||||
// readings); it loads a page at a time and a "Load more" control reveals the
|
||||
@@ -305,7 +323,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
|
||||
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
|
||||
// collapsing to a bare line with no context and no way back.
|
||||
if (markerError || listError) {
|
||||
// A merged marker is on its way to its target (see above), not failed.
|
||||
if ((markerError && !mergedInto) || listError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -409,6 +428,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* v1.43 (D1223) — the other spellings this marker answers to. */}
|
||||
{marker ? (
|
||||
<BiomarkerAliases marker={marker} canEdit={canManageLabs} />
|
||||
) : null}
|
||||
|
||||
{/* Bridge to the source report PDFs. There is no per-value document
|
||||
relation in the data model yet, so this links to the vault filtered
|
||||
to lab-result documents rather than the exact scan behind one value.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (D1223) — which marker a reviewed lab value goes to.
|
||||
*
|
||||
* Shared by the scan review and the document review. The server resolves a
|
||||
* printed name to one of the account's markers (its name, an alias, or a
|
||||
* catalog abbreviation such as "T.Bili") and, when nothing resolves, offers up
|
||||
* to three markers to choose from. This renders that resolution: the marker a
|
||||
* value was matched to, or a choice between the suggestions and a new marker.
|
||||
* A choice is remembered by the server as another spelling of the marker, so
|
||||
* the next report from the same lab matches on its own.
|
||||
*
|
||||
* Nothing is computed here: the match, its kind and the suggestions all come
|
||||
* from the review DTO.
|
||||
*/
|
||||
import { useId } from "react";
|
||||
import { FilePlus2, Link2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MarkerSuggestionDto {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
matchKind: "catalog" | "similar";
|
||||
}
|
||||
|
||||
/** The value for "a new marker" in the choice. */
|
||||
const NEW_MARKER = "";
|
||||
|
||||
export function MarkerMatchField({
|
||||
matchedName,
|
||||
matchKind,
|
||||
suggestions,
|
||||
chosenId,
|
||||
onChoose,
|
||||
slot,
|
||||
}: {
|
||||
/** The marker the server matched, by name; null when nothing matched. */
|
||||
matchedName: string | null;
|
||||
matchKind: "name" | "alias" | "catalog" | null;
|
||||
suggestions: readonly MarkerSuggestionDto[];
|
||||
/** The suggestion the person picked, or null for a new marker. */
|
||||
chosenId: string | null;
|
||||
onChoose: (suggestion: MarkerSuggestionDto | null) => void;
|
||||
/** `data-slot` prefix for tests. */
|
||||
slot: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const selectId = useId();
|
||||
|
||||
if (matchedName !== null) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground max-w-full"
|
||||
title={t("labs.ocr.linksExisting", { name: matchedName })}
|
||||
data-slot={`${slot}-matched`}
|
||||
>
|
||||
<Link2 aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.linksExisting", { name: matchedName })}
|
||||
</span>
|
||||
</Badge>
|
||||
{matchKind === "alias" || matchKind === "catalog" ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{matchKind === "alias"
|
||||
? t("labs.match.viaAlias")
|
||||
: t("labs.match.viaCatalog")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
data-slot={`${slot}-new`}
|
||||
>
|
||||
<FilePlus2 aria-hidden />
|
||||
{t("labs.ocr.newBiomarker")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot={`${slot}-choice`}>
|
||||
<Label htmlFor={selectId} className="text-xs">
|
||||
{t("labs.match.chooseLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={selectId}
|
||||
value={chosenId ?? NEW_MARKER}
|
||||
onChange={(event) =>
|
||||
onChoose(
|
||||
suggestions.find((s) => s.biomarkerId === event.target.value) ??
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
|
||||
{t("labs.match.suggestionOption", {
|
||||
name: suggestion.name,
|
||||
unit: suggestion.unit,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_MARKER}>{t("labs.match.newMarkerOption")}</option>
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{chosenId ? t("labs.match.rememberHint") : t("labs.match.chooseHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,16 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { OcrRowEditor } from "./ocr-row-editor";
|
||||
import type { BiomarkerListResponse } from "./types";
|
||||
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
|
||||
import { collectRowErrors, planSave } from "./ocr-review-validation";
|
||||
import {
|
||||
@@ -176,6 +180,24 @@ export function OcrReviewDialog({
|
||||
const textExtract = useOcrTextExtract();
|
||||
const extract = mode === "text" ? textExtract : visionExtract;
|
||||
const commit = useOcrCommit();
|
||||
// v1.43 — the account's markers, so a matched row names its marker and a
|
||||
// chosen suggestion brings the analyte it converts with. Only once rows are
|
||||
// on screen; the review works without it (names fall back to the printed
|
||||
// analyte).
|
||||
const markers = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
enabled: rows.length > 0,
|
||||
});
|
||||
const markerLookup = useMemo(() => {
|
||||
const byId = new Map(
|
||||
(markers.data?.biomarkers ?? []).map((m) => [
|
||||
m.id,
|
||||
{ name: m.name, analyteKey: m.analyteKey },
|
||||
]),
|
||||
);
|
||||
return (id: string) => byId.get(id) ?? null;
|
||||
}, [markers.data]);
|
||||
// Text mode never accepts PDFs (tesseract.js can't read them).
|
||||
const allowPdf = mode === "vision" && pdfSupported;
|
||||
|
||||
@@ -439,6 +461,7 @@ export function OcrReviewDialog({
|
||||
<OcrRowEditor
|
||||
key={row.key}
|
||||
row={row}
|
||||
markerLookup={markerLookup}
|
||||
errors={showErrors ? rowErrors.get(row.key) : undefined}
|
||||
onChange={(next) =>
|
||||
setRows((prev) =>
|
||||
|
||||
@@ -34,6 +34,16 @@ export interface OcrReviewRow {
|
||||
markerAnalyteKey: string | null;
|
||||
/** Non-null when the server flagged a likely duplicate of a live reading. */
|
||||
duplicateOf: string | null;
|
||||
/**
|
||||
* v1.43 (D1223) — the marker the server matched the name to (its name, an
|
||||
* alias, or a catalog abbreviation), and how; null when nothing matched.
|
||||
*/
|
||||
biomarkerId: string | null;
|
||||
matchKind: "name" | "alias" | "catalog" | null;
|
||||
/** Up to three markers to choose from when nothing matched. */
|
||||
suggestions: OcrExtractedRowDto["suggestions"];
|
||||
/** The suggestion the person picked; null keeps the automatic outcome. */
|
||||
chosenBiomarkerId: string | null;
|
||||
/** Whether the row is selected for saving. Defaults false for duplicates. */
|
||||
confirmed: boolean;
|
||||
}
|
||||
@@ -58,6 +68,11 @@ export function seedReviewRows(
|
||||
markerUnit: row.markerUnit,
|
||||
markerAnalyteKey: row.markerAnalyteKey,
|
||||
duplicateOf: row.duplicateOf,
|
||||
// A scan read before v1.43 carries none of the three.
|
||||
biomarkerId: row.biomarkerId ?? null,
|
||||
matchKind: row.matchKind ?? null,
|
||||
suggestions: row.suggestions ?? [],
|
||||
chosenBiomarkerId: null,
|
||||
// A flagged duplicate starts unchecked, and so does a reading in another
|
||||
// unit than its marker's that cannot be converted (it carries a badge
|
||||
// saying why, and ticking it marks the unit field until it is fixed); a
|
||||
@@ -74,3 +89,31 @@ export function seedReviewRows(
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a row at a suggestion the person picked, or back at a new marker. The
|
||||
* marker's unit and analyte come with the choice, so the unit check and the
|
||||
* conversion badge judge the row against the marker it will be filed under.
|
||||
*/
|
||||
export function chooseMarker(
|
||||
row: OcrReviewRow,
|
||||
choice: { biomarkerId: string; unit: string } | null,
|
||||
analyteKeyOf: (biomarkerId: string) => string | null,
|
||||
): OcrReviewRow {
|
||||
if (choice === null) {
|
||||
return {
|
||||
...row,
|
||||
chosenBiomarkerId: null,
|
||||
biomarkerMatch: "new",
|
||||
markerUnit: null,
|
||||
markerAnalyteKey: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
chosenBiomarkerId: choice.biomarkerId,
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: choice.unit,
|
||||
markerAnalyteKey: analyteKeyOf(choice.biomarkerId),
|
||||
};
|
||||
}
|
||||
@@ -129,7 +129,12 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
if (isQualitativeRow(row)) {
|
||||
const valueText = (row.valueText ?? "").trim();
|
||||
if (!valueText) return null;
|
||||
return { analyte, valueText, takenAt: takenAt.toISOString() };
|
||||
return {
|
||||
analyte,
|
||||
valueText,
|
||||
takenAt: takenAt.toISOString(),
|
||||
...chosen(row),
|
||||
};
|
||||
}
|
||||
|
||||
if (row.value === null || !Number.isFinite(row.value)) return null;
|
||||
@@ -144,9 +149,15 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
|
||||
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
|
||||
...(row.referenceText ? { referenceText: row.referenceText } : {}),
|
||||
...chosen(row),
|
||||
};
|
||||
}
|
||||
|
||||
/** The marker the person picked, which the commit then files the row under. */
|
||||
function chosen(row: OcrReviewRow): { biomarkerId?: string } {
|
||||
return row.chosenBiomarkerId ? { biomarkerId: row.chosenBiomarkerId } : {};
|
||||
}
|
||||
|
||||
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
|
||||
export function collectRowErrors(
|
||||
rows: readonly OcrReviewRow[],
|
||||
|
||||
@@ -15,13 +15,7 @@
|
||||
*/
|
||||
import { useId } from "react";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
FilePlus2,
|
||||
Link2,
|
||||
Ruler,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Ruler, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -31,7 +25,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
import { MarkerMatchField } from "./marker-match-field";
|
||||
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
|
||||
import {
|
||||
readingConversion,
|
||||
readingUnitBlocked,
|
||||
@@ -55,9 +50,18 @@ export function OcrRowEditor({
|
||||
row,
|
||||
onChange,
|
||||
errors,
|
||||
markerLookup = () => null,
|
||||
}: {
|
||||
row: OcrReviewRow;
|
||||
onChange: (next: OcrReviewRow) => void;
|
||||
/**
|
||||
* The account's marker by id, for the matched marker's name and the
|
||||
* analyte a chosen suggestion converts with. Absent: names fall back to the
|
||||
* printed analyte.
|
||||
*/
|
||||
markerLookup?: (
|
||||
biomarkerId: string,
|
||||
) => { name: string; analyteKey: string | null } | null;
|
||||
/** The blocking fields of this row, once a save has been attempted. */
|
||||
errors?: OcrRowErrors;
|
||||
}) {
|
||||
@@ -151,26 +155,29 @@ export function OcrRowEditor({
|
||||
className="font-medium"
|
||||
/>
|
||||
{fieldError("analyte", "labs.ocr.errorAnalyte")}
|
||||
{/* v1.43 (D1223) — the marker this value goes to: the server's
|
||||
match, or a choice between its suggestions and a new one. */}
|
||||
<MarkerMatchField
|
||||
matchedName={
|
||||
row.biomarkerId !== null
|
||||
? (markerLookup(row.biomarkerId)?.name ?? row.analyte)
|
||||
: null
|
||||
}
|
||||
matchKind={row.matchKind}
|
||||
suggestions={row.suggestions}
|
||||
chosenId={row.chosenBiomarkerId}
|
||||
onChoose={(suggestion) =>
|
||||
onChange(
|
||||
chooseMarker(
|
||||
row,
|
||||
suggestion,
|
||||
(id) => markerLookup(id)?.analyteKey ?? null,
|
||||
),
|
||||
)
|
||||
}
|
||||
slot="ocr-row-marker"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{row.biomarkerMatch === "existing" ? (
|
||||
// The chip repeats the analyte, so a long panel name ran past
|
||||
// the row on a phone; it truncates inside the row instead.
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground max-w-full"
|
||||
title={t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
>
|
||||
<Link2 aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<FilePlus2 aria-hidden />
|
||||
{t("labs.ocr.newBiomarker")}
|
||||
</Badge>
|
||||
)}
|
||||
{row.duplicateOf ? (
|
||||
<Badge variant="secondary">
|
||||
<TriangleAlert aria-hidden />
|
||||
|
||||
@@ -95,6 +95,11 @@ export interface BiomarkerDto {
|
||||
analyteKey: string | null;
|
||||
hasContext: boolean;
|
||||
context: string | null;
|
||||
/**
|
||||
* v1.43 — other spellings that resolve to this marker, sorted, without its
|
||||
* own name. Absent from an older server.
|
||||
*/
|
||||
aliases?: string[];
|
||||
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
@@ -104,3 +109,21 @@ export interface BiomarkerDto {
|
||||
export interface BiomarkerListResponse {
|
||||
biomarkers: BiomarkerDto[];
|
||||
}
|
||||
|
||||
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
|
||||
export interface BiomarkerMergeResultDto {
|
||||
dryRun: boolean;
|
||||
target: BiomarkerDto;
|
||||
sources: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
readingCount: number;
|
||||
conversion: "none" | "converted" | "refused";
|
||||
refusal: "unknown_analyte" | "unknown_unit" | "not_finite" | null;
|
||||
}>;
|
||||
movedReadings: number;
|
||||
convertedReadings: number;
|
||||
aliasesAdded: string[];
|
||||
mergeId: string | null;
|
||||
}
|
||||
@@ -51,6 +51,11 @@ export interface OcrCommitRowInput {
|
||||
referenceHigh?: number;
|
||||
referenceText?: string;
|
||||
takenAt: string;
|
||||
/**
|
||||
* v1.43 — the marker the person chose on the review screen; the server then
|
||||
* remembers the analyte as its alias. Absent for an automatic match.
|
||||
*/
|
||||
biomarkerId?: string;
|
||||
}
|
||||
|
||||
export interface OcrCommitResult extends OcrCommitResponseDto {
|
||||
|
||||
@@ -125,6 +125,41 @@ describe("paged link picker", () => {
|
||||
expect(html).toContain('data-slot="labs-add"');
|
||||
});
|
||||
|
||||
it("names the period a date term searches, and does not filter its answer by the digits", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({
|
||||
term: "03.2019",
|
||||
filterLocally: true,
|
||||
period: {
|
||||
year: 2019,
|
||||
month: 3,
|
||||
fromDay: "2019-03-01",
|
||||
toDay: "2019-03-31",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="labs-period"');
|
||||
expect(html).toContain("Showing March 2019");
|
||||
expect(html).toContain('data-slot="labs-option"');
|
||||
});
|
||||
|
||||
it("shows no period line for a plain term", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({ term: "metabolic" })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="labs-period"');
|
||||
});
|
||||
|
||||
it("does not filter a server answer again by the term", () => {
|
||||
// The route matched on the panel name; the option does not render it.
|
||||
const html = render(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A picker term that names a year or a month becomes a date window; anything
|
||||
* else stays a text search. Every accepted form is checked in every UI
|
||||
* locale, and the near misses that must stay text are checked too.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatPeriod, parsePeriodQuery } from "../period-query";
|
||||
|
||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function window(term: string, locale: string) {
|
||||
const parsed = parsePeriodQuery(term, locale, NOW);
|
||||
return parsed ? `${parsed.fromDay}..${parsed.toDay}` : null;
|
||||
}
|
||||
|
||||
const MARCH = "2019-03-01..2019-03-31";
|
||||
const YEAR = "2019-01-01..2019-12-31";
|
||||
|
||||
describe("parsePeriodQuery", () => {
|
||||
describe.each(LOCALES)("%s", (locale) => {
|
||||
it("reads a year", () => {
|
||||
expect(window("2019", locale)).toBe(YEAR);
|
||||
expect(window(" 2019 ", locale)).toBe(YEAR);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"03.2019",
|
||||
"3.2019",
|
||||
"03/2019",
|
||||
"3/2019",
|
||||
"03-2019",
|
||||
"2019-03",
|
||||
"2019/03",
|
||||
"2019.03",
|
||||
"2019.3",
|
||||
" 03 . 2019 ",
|
||||
])("reads the numeric month %j", (term) => {
|
||||
expect(window(term, locale)).toBe(MARCH);
|
||||
});
|
||||
|
||||
it.each(["LDL", "2019abc", "13.2019", "00.2019", "1850", "1899", "2028"])(
|
||||
"leaves %j as text",
|
||||
(term) => {
|
||||
expect(window(term, locale)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", " ", "2019 2020", "03.2019.04", "LDL 2019", "3 2019 x"])(
|
||||
"leaves %j as text",
|
||||
(term) => {
|
||||
expect(window(term, locale)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", ["März 2019", "märz 2019", "MÄRZ 2019", "Maerz 2019", "Marz 2019"]],
|
||||
["de", ["mär 2019", "Mär. 2019", "2019 März", "März, 2019"]],
|
||||
["en", ["March 2019", "march 2019", "Mar 2019", "Mar. 2019", "2019 March"]],
|
||||
["es", ["marzo 2019", "Marzo de 2019", "mar 2019", "mar. 2019"]],
|
||||
["fr", ["mars 2019", "Mars 2019", "mar 2019", "2019 mars"]],
|
||||
["it", ["marzo 2019", "Marzo 2019", "mar 2019", "marzo del 2019"]],
|
||||
["pl", ["marzec 2019", "Marzec 2019", "marca 2019", "mar 2019"]],
|
||||
["ko", ["2019년 3월", "2019년3월", "2019년 03월", "3월 2019", "2019. 3."]],
|
||||
] as const)("reads March 2019 in %s", (locale, terms) => {
|
||||
for (const term of terms) {
|
||||
expect(window(term, locale), term).toBe(MARCH);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", "Dezember 2019", "2019-12-01..2019-12-31"],
|
||||
["de", "Okt 2019", "2019-10-01..2019-10-31"],
|
||||
["de", "Februar 2020", "2020-02-01..2020-02-29"],
|
||||
["en", "Sept 2019", "2019-09-01..2019-09-30"],
|
||||
["en", "Sep 2019", "2019-09-01..2019-09-30"],
|
||||
["en", "May 2019", "2019-05-01..2019-05-31"],
|
||||
["es", "septiembre 2019", "2019-09-01..2019-09-30"],
|
||||
["es", "ene 2019", "2019-01-01..2019-01-31"],
|
||||
["es", "dic 2019", "2019-12-01..2019-12-31"],
|
||||
["fr", "février 2019", "2019-02-01..2019-02-28"],
|
||||
["fr", "fevrier 2019", "2019-02-01..2019-02-28"],
|
||||
["fr", "févr. 2019", "2019-02-01..2019-02-28"],
|
||||
["fr", "août 2019", "2019-08-01..2019-08-31"],
|
||||
["fr", "déc 2019", "2019-12-01..2019-12-31"],
|
||||
["fr", "juil. 2019", "2019-07-01..2019-07-31"],
|
||||
["it", "gennaio 2019", "2019-01-01..2019-01-31"],
|
||||
["it", "giu 2019", "2019-06-01..2019-06-30"],
|
||||
["it", "set 2019", "2019-09-01..2019-09-30"],
|
||||
["pl", "październik 2019", "2019-10-01..2019-10-31"],
|
||||
["pl", "paź 2019", "2019-10-01..2019-10-31"],
|
||||
["pl", "października 2019", "2019-10-01..2019-10-31"],
|
||||
["pl", "luty 2019", "2019-02-01..2019-02-28"],
|
||||
["pl", "styczeń 2019", "2019-01-01..2019-01-31"],
|
||||
["ko", "2019년 12월", "2019-12-01..2019-12-31"],
|
||||
["ko", "2019년", YEAR],
|
||||
// Austrian German calls January "Jänner", and Intl knows it.
|
||||
["de-AT", "Jänner 2019", "2019-01-01..2019-01-31"],
|
||||
["de-AT", "März 2019", MARCH],
|
||||
] as const)("%s %j", (locale, term, expected) => {
|
||||
expect(window(term, locale)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// "jui" is both juin and juillet; "ma" is too short to name one month.
|
||||
["fr", "jui 2019"],
|
||||
["en", "ma 2019"],
|
||||
["en", "Ju 2019"],
|
||||
// A month name from another language is text in this one.
|
||||
["en", "März 2019"],
|
||||
["de", "March 2019"],
|
||||
// A month without a year is not a period.
|
||||
["en", "March"],
|
||||
["de", "03"],
|
||||
// Two months, or a month name with digits glued on.
|
||||
["en", "March May"],
|
||||
["en", "March2019"],
|
||||
] as const)("%s %j stays text", (locale, term) => {
|
||||
expect(window(term, locale)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts next year and refuses the one after", () => {
|
||||
expect(window("2027", "en")).toBe("2027-01-01..2027-12-31");
|
||||
expect(window("12.2027", "en")).toBe("2027-12-01..2027-12-31");
|
||||
expect(window("2028", "en")).toBeNull();
|
||||
expect(window("1900", "en")).toBe("1900-01-01..1900-12-31");
|
||||
});
|
||||
|
||||
it("names the year and the month", () => {
|
||||
expect(parsePeriodQuery("2019", "de", NOW)).toEqual({
|
||||
year: 2019,
|
||||
month: null,
|
||||
fromDay: "2019-01-01",
|
||||
toDay: "2019-12-31",
|
||||
});
|
||||
expect(parsePeriodQuery("Maerz 2019", "de", NOW)).toMatchObject({
|
||||
year: 2019,
|
||||
month: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("names a period the way the locale writes it", () => {
|
||||
const march = parsePeriodQuery("03.2019", "de", NOW)!;
|
||||
expect(formatPeriod(march, "de")).toBe("März 2019");
|
||||
expect(formatPeriod(march, "en")).toBe("March 2019");
|
||||
expect(formatPeriod(march, "ko")).toBe("2019년 3월");
|
||||
expect(formatPeriod(parsePeriodQuery("2019", "en", NOW)!, "en")).toBe(
|
||||
"2019",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,15 +28,24 @@ import {
|
||||
} from "../use-link-picker-pages";
|
||||
|
||||
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
|
||||
const ctx = { locale: "en", timeZone: "UTC" } as const;
|
||||
|
||||
const REQUESTS: PickerPageRequest[] = [
|
||||
{ direction: "older", pivot, term: "", cursor: null },
|
||||
{ direction: "older", pivot, term: "", cursor: "200" },
|
||||
{ direction: "older", pivot: null, term: "", cursor: null },
|
||||
{ direction: "newer", pivot, term: "", cursor: null },
|
||||
{ direction: "newer", pivot, term: "", cursor: "100" },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: null },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: "100" },
|
||||
{ direction: "older", pivot, term: "", cursor: null, ...ctx },
|
||||
{ direction: "older", pivot, term: "", cursor: "200", ...ctx },
|
||||
{ direction: "older", pivot: null, term: "", cursor: null, ...ctx },
|
||||
{ direction: "newer", pivot, term: "", cursor: null, ...ctx },
|
||||
{ direction: "newer", pivot, term: "", cursor: "100", ...ctx },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: null, ...ctx },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: "100", ...ctx },
|
||||
{ direction: "search", pivot: null, term: "2019", cursor: null, ...ctx },
|
||||
{
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term: "March 2019",
|
||||
cursor: "100",
|
||||
...ctx,
|
||||
},
|
||||
];
|
||||
|
||||
function params(url: string): Record<string, string> {
|
||||
@@ -67,10 +76,22 @@ describe("documents picker", () => {
|
||||
|
||||
it("splits the day-granular window at the pivot's day", () => {
|
||||
const older = params(
|
||||
documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
documentPickerUrl({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...ctx,
|
||||
}),
|
||||
);
|
||||
const newer = params(
|
||||
documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
|
||||
documentPickerUrl({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...ctx,
|
||||
}),
|
||||
);
|
||||
expect(older.to).toBe("2019-05-17");
|
||||
expect(newer.from).toBe("2019-05-18");
|
||||
@@ -88,10 +109,22 @@ describe("the windows around a record's date", () => {
|
||||
|
||||
it("older reads up to the split newest first, newer after it oldest first", () => {
|
||||
const older = params(
|
||||
labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
labPickerUrl({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...ctx,
|
||||
}),
|
||||
);
|
||||
const newer = params(
|
||||
labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
|
||||
labPickerUrl({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...ctx,
|
||||
}),
|
||||
);
|
||||
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
|
||||
expect(newer).toMatchObject({
|
||||
@@ -107,6 +140,7 @@ describe("the windows around a record's date", () => {
|
||||
pivot: null,
|
||||
term: "ferritin",
|
||||
cursor: null,
|
||||
...ctx,
|
||||
}),
|
||||
);
|
||||
expect(search.q).toBe("ferritin");
|
||||
@@ -120,3 +154,88 @@ describe("the windows around a record's date", () => {
|
||||
expect(nextOffset("100", 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a search term that names a period", () => {
|
||||
const search = (
|
||||
term: string,
|
||||
locale = "en",
|
||||
timeZone = "UTC",
|
||||
): PickerPageRequest => ({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
locale,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
it.each([
|
||||
["labs", labPickerUrl, "sortDir"],
|
||||
["episodes", episodePickerUrl, "sortDir"],
|
||||
] as const)(
|
||||
"%s: a year is the year's instant window, newest first, with no q",
|
||||
(_name, build, sortKey) => {
|
||||
const sent = params(build(search("2019")));
|
||||
expect(sent.q).toBeUndefined();
|
||||
expect(sent.from).toBe("2019-01-01T00:00:00.000Z");
|
||||
expect(sent.to).toBe("2019-12-31T23:59:59.999Z");
|
||||
expect(sent[sortKey]).toBe("desc");
|
||||
},
|
||||
);
|
||||
|
||||
it("labs: a month in the person's language is that month", () => {
|
||||
const sent = params(labPickerUrl(search("März 2019", "de")));
|
||||
expect(sent.q).toBeUndefined();
|
||||
expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
|
||||
expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
|
||||
});
|
||||
|
||||
it("the window follows the person's timezone, DST included", () => {
|
||||
// Berlin: March 2019 starts at UTC+1 and ends at UTC+2.
|
||||
const sent = params(
|
||||
episodePickerUrl(search("03.2019", "de", "Europe/Berlin")),
|
||||
);
|
||||
expect(sent.from).toBe("2019-02-28T23:00:00.000Z");
|
||||
expect(sent.to).toBe("2019-03-31T21:59:59.999Z");
|
||||
});
|
||||
|
||||
it("documents: the day-granular window is the period's two calendar days", () => {
|
||||
const sent = params(documentPickerUrl(search("2019-02")));
|
||||
expect(sent.q).toBeUndefined();
|
||||
expect(sent.from).toBe("2019-02-01");
|
||||
expect(sent.to).toBe("2019-02-28");
|
||||
expect(sent.order).toBe("desc");
|
||||
});
|
||||
|
||||
it("encounters: the route without text search still gets the period", () => {
|
||||
const sent = params(encounterPickerUrl(search("Mar 2019")));
|
||||
expect(sent.q).toBeUndefined();
|
||||
expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
|
||||
expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
|
||||
});
|
||||
|
||||
it.each([labPickerUrl, episodePickerUrl, documentPickerUrl])(
|
||||
"a plain term stays a text search (%#)",
|
||||
(build) => {
|
||||
for (const term of ["LDL", "2019abc", "13.2019", "1850"]) {
|
||||
const sent = params(build(search(term)));
|
||||
expect(sent.q).toBe(term);
|
||||
expect(sent.from).toBeUndefined();
|
||||
expect(sent.to).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("a month name is read in the UI locale only", () => {
|
||||
// "März" is not an English month, so it stays text.
|
||||
expect(params(labPickerUrl(search("März 2019", "en"))).q).toBe("März 2019");
|
||||
});
|
||||
|
||||
it("the older and newer windows never read the term", () => {
|
||||
const older = params(
|
||||
labPickerUrl({ ...search("2019"), direction: "older", pivot }),
|
||||
);
|
||||
expect(older.to).toBe(pivot);
|
||||
expect(older.from).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,8 @@ describe("fetchVaultPage", () => {
|
||||
pivot: "2026-05-17T23:59:59.999Z",
|
||||
term: "",
|
||||
cursor: "z",
|
||||
locale: "en",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
expect(page.items.map((d) => d.id)).toEqual(["a"]);
|
||||
expect(page.next).toBe("a");
|
||||
|
||||
@@ -53,6 +53,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { formatPeriod, type PeriodQuery } from "./period-query";
|
||||
|
||||
export interface EntityLinkOption {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -110,6 +112,8 @@ export interface EntityLinkServerPaging {
|
||||
* here instead of being sent.
|
||||
*/
|
||||
filterLocally?: boolean;
|
||||
/** The term names a year or a month, and the list is that period. */
|
||||
period?: PeriodQuery | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +131,7 @@ export function serverPagingFrom(pages: {
|
||||
loadOlder: () => void;
|
||||
loadNewer: () => void;
|
||||
filterLocally: boolean;
|
||||
period: PeriodQuery | null;
|
||||
}): EntityLinkServerPaging {
|
||||
return {
|
||||
term: pages.term,
|
||||
@@ -139,6 +144,7 @@ export function serverPagingFrom(pages: {
|
||||
onOlder: pages.loadOlder,
|
||||
onNewer: pages.loadNewer,
|
||||
filterLocally: pages.filterLocally,
|
||||
period: pages.period,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ export function EntityLinkPicker({
|
||||
*/
|
||||
knownOptions?: EntityLinkOption[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localTerm, setLocalTerm] = useState("");
|
||||
const term = server ? server.term : localTerm;
|
||||
@@ -308,7 +314,10 @@ export function EntityLinkPicker({
|
||||
// again here would drop a match on a field the option does not render.
|
||||
const groups = useMemo(() => {
|
||||
const visible =
|
||||
server && !server.filterLocally ? options : filterOptions(options, term);
|
||||
// A period is answered by the route's date window, never by the digits.
|
||||
server && (!server.filterLocally || server.period)
|
||||
? options
|
||||
: filterOptions(options, term);
|
||||
// Linked records the loaded pages do not hold stay reachable in the
|
||||
// sheet, on top, so they can be unticked where they are listed.
|
||||
const loaded = new Set(options.map((option) => option.id));
|
||||
@@ -486,6 +495,17 @@ export function EntityLinkPicker({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{server?.period ? (
|
||||
<p
|
||||
className="text-muted-foreground px-1 text-xs"
|
||||
data-slot={`${slot}-period`}
|
||||
>
|
||||
{t("links.picker.period.showing", {
|
||||
period: formatPeriod(server.period, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{server?.searchPending && groups.length === 0 ? (
|
||||
<div
|
||||
className="flex justify-center py-6"
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* A picker search term that names a calendar period: a year, or a month of a
|
||||
* year.
|
||||
*
|
||||
* A person looking for "the lab panel from March 2019" types the date, not an
|
||||
* analyte. The list routes match `q` against names and titles only, so "2019"
|
||||
* or "03.2019" found nothing, or only whatever happened to carry those digits
|
||||
* in its name. A term that parses here is sent as the route's own `from` /
|
||||
* `to` window instead, so the answer is every record in that period, however
|
||||
* far back it sits.
|
||||
*
|
||||
* Accepted, after folding case, accents and the umlaut spellings "ae", "oe",
|
||||
* "ue" (so "März", "Maerz" and "mär" agree):
|
||||
*
|
||||
* - a year: "2019" (Korean "2019년");
|
||||
* - a numeric month and year, either order, separated by ".", "/", "-" or a
|
||||
* space: "03.2019", "3.2019", "03/2019", "2019-03", "2019/03", "2019.03",
|
||||
* Korean "2019년 3월" / "2019. 3.";
|
||||
* - a month name and a year, either order, in the person's UI locale: the
|
||||
* full name, the short name, the genitive form where the language has one
|
||||
* (Polish "marca"), or any prefix of at least three letters that names one
|
||||
* month only ("sept", "paź"; "jui" is both juin and juillet, so it is not
|
||||
* a period). Spanish "de" may join the two ("marzo de 2019").
|
||||
*
|
||||
* The month names come from `Intl.DateTimeFormat` for the locale rather than a
|
||||
* table kept by hand, so they read the way the app's own dates are rendered.
|
||||
*
|
||||
* A year before {@link MIN_PERIOD_YEAR} or more than one year after the
|
||||
* current one is not a period ("1850" stays a text search), and neither is a
|
||||
* month outside 1 to 12 ("13.2019") or a term with anything else in it
|
||||
* ("2019abc", "LDL 2019").
|
||||
*
|
||||
* Pure: no provider, no clock unless the caller omits `now`.
|
||||
*/
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
/** The earliest year a period search accepts. */
|
||||
export const MIN_PERIOD_YEAR = 1900;
|
||||
|
||||
export interface PeriodQuery {
|
||||
year: number;
|
||||
/** 1 to 12, or null for the whole year. */
|
||||
month: number | null;
|
||||
/** First calendar day of the period, `YYYY-MM-DD`. */
|
||||
fromDay: string;
|
||||
/** Last calendar day of the period, `YYYY-MM-DD` (inclusive). */
|
||||
toDay: string;
|
||||
}
|
||||
|
||||
/** Words that may stand between a month name and its year. */
|
||||
const CONNECTORS: Record<string, readonly string[]> = {
|
||||
es: ["de", "del"],
|
||||
it: ["del"],
|
||||
};
|
||||
|
||||
const nameCache = new Map<string, ReadonlyArray<[string, number]>>();
|
||||
|
||||
/** Spell German umlauts the way a keyboard without them does. */
|
||||
function umlautSpelling(lower: string): string {
|
||||
return lower.replace(/ä/gu, "ae").replace(/ö/gu, "oe").replace(/ü/gu, "ue");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every folded spelling of every month in `locale`, paired with its month
|
||||
* number: long, short and (where it differs) genitive, each also with its
|
||||
* umlauts spelled out.
|
||||
*/
|
||||
function monthNames(locale: string): ReadonlyArray<[string, number]> {
|
||||
const cached = nameCache.get(locale);
|
||||
if (cached) return cached;
|
||||
const names: Array<[string, number]> = [];
|
||||
const add = (raw: string, month: number) => {
|
||||
const lower = raw.toLowerCase().replace(/\./gu, "");
|
||||
for (const spelling of [lower, umlautSpelling(lower)]) {
|
||||
const folded = foldForMatch(spelling);
|
||||
if (folded && !names.some(([n, m]) => n === folded && m === month)) {
|
||||
names.push([folded, month]);
|
||||
}
|
||||
}
|
||||
};
|
||||
let long: Intl.DateTimeFormat;
|
||||
let short: Intl.DateTimeFormat;
|
||||
let genitive: Intl.DateTimeFormat;
|
||||
try {
|
||||
long = new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" });
|
||||
short = new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
genitive = new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
nameCache.set(locale, names);
|
||||
return names;
|
||||
}
|
||||
for (let month = 1; month <= 12; month += 1) {
|
||||
const day = new Date(Date.UTC(2019, month - 1, 15));
|
||||
add(long.format(day), month);
|
||||
add(short.format(day), month);
|
||||
const part = genitive
|
||||
.formatToParts(day)
|
||||
.find((p) => p.type === "month")?.value;
|
||||
if (part) add(part, month);
|
||||
}
|
||||
nameCache.set(locale, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** The month a folded word names in `locale`, or null. */
|
||||
function monthFromName(word: string, locale: string): number | null {
|
||||
const names = monthNames(locale);
|
||||
const exact = names.find(([name]) => name === word);
|
||||
if (exact) return exact[1];
|
||||
if (word.length < 3 || !/^\p{L}+$/u.test(word)) return null;
|
||||
const months = new Set(
|
||||
names.filter(([name]) => name.startsWith(word)).map(([, month]) => month),
|
||||
);
|
||||
return months.size === 1 ? [...months][0] : null;
|
||||
}
|
||||
|
||||
function plausibleYear(word: string, now: Date): number | null {
|
||||
if (!/^\d{4}$/u.test(word)) return null;
|
||||
const year = Number(word);
|
||||
if (year < MIN_PERIOD_YEAR || year > now.getUTCFullYear() + 1) return null;
|
||||
return year;
|
||||
}
|
||||
|
||||
function numericMonth(word: string): number | null {
|
||||
if (!/^\d{1,2}$/u.test(word)) return null;
|
||||
const month = Number(word);
|
||||
return month >= 1 && month <= 12 ? month : null;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function period(year: number, month: number | null): PeriodQuery {
|
||||
if (month === null) {
|
||||
return { year, month, fromDay: `${year}-01-01`, toDay: `${year}-12-31` };
|
||||
}
|
||||
const fromDay = `${year}-${pad(month)}-01`;
|
||||
const nextMonth =
|
||||
month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`;
|
||||
return { year, month, fromDay, toDay: shiftDateKey(nextMonth, -1) };
|
||||
}
|
||||
|
||||
/** The base language of a locale tag: "de-AT" → "de". */
|
||||
function language(locale: string): string {
|
||||
return locale.toLowerCase().split(/[-_]/u)[0] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The period `term` names in `locale`, or null when it is not one (and the
|
||||
* picker keeps its text search).
|
||||
*/
|
||||
export function parsePeriodQuery(
|
||||
term: string,
|
||||
locale: string,
|
||||
now: Date = new Date(),
|
||||
): PeriodQuery | null {
|
||||
const lang = language(locale);
|
||||
let text = term.toLowerCase();
|
||||
// Korean writes the units after the numbers: "2019년 3월".
|
||||
if (lang === "ko") {
|
||||
text = text.replace(/(\d)\s*년/gu, "$1 ").replace(/(\d)\s*월/gu, "$1 ");
|
||||
}
|
||||
const connectors = CONNECTORS[lang] ?? [];
|
||||
const words = foldForMatch(text.replace(/[.,]/gu, " "))
|
||||
.split(" ")
|
||||
.filter((word) => word.length > 0 && !connectors.includes(word));
|
||||
|
||||
if (words.length === 1) {
|
||||
const year = plausibleYear(words[0], now);
|
||||
return year === null ? null : period(year, null);
|
||||
}
|
||||
if (words.length !== 2) return null;
|
||||
|
||||
for (const [first, second] of [
|
||||
[words[0], words[1]],
|
||||
[words[1], words[0]],
|
||||
]) {
|
||||
const year = plausibleYear(second, now);
|
||||
if (year === null) continue;
|
||||
const month = numericMonth(first) ?? monthFromName(first, locale);
|
||||
if (month !== null) return period(year, month);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The period as the app names it in `locale`: "2019", "März 2019". */
|
||||
export function formatPeriod(value: PeriodQuery, locale: string): string {
|
||||
if (value.month === null) return String(value.year);
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(Date.UTC(value.year, value.month - 1, 15)));
|
||||
} catch {
|
||||
return `${pad(value.month)}.${value.year}`;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,28 @@
|
||||
* `search` the term across the whole history. The cursor is the family's own:
|
||||
* an offset for the routes that page by offset, the last row's id for the
|
||||
* document route's keyset.
|
||||
*
|
||||
* A search term that names a year or a month ("2019", "03.2019", "März 2019",
|
||||
* see {@link parsePeriodQuery}) is not sent as `q`: it becomes the route's
|
||||
* own `from` / `to` window over that period, newest first, so the answer is
|
||||
* every record dated in it rather than the records whose name contains the
|
||||
* digits. The visit route, which has no text search at all, gets the same
|
||||
* window.
|
||||
*
|
||||
* The period is a calendar period as the person reads it. The routes over an
|
||||
* instant (lab draw, condition onset, visit) get the first instant of the
|
||||
* period's first day and the last instant of its last day in the person's
|
||||
* timezone, the zone every date in the picker is rendered in, so a visit at
|
||||
* 00:30 on 1 March is in March for the person who sees it dated 1 March. A
|
||||
* date-only lab reading is stored at noon UTC, which reads as its own date in
|
||||
* every zone from UTC-11 to UTC+11. The document route's window is already
|
||||
* day-granular and inclusive over a stored calendar date, so it gets the two
|
||||
* day keys as they are.
|
||||
*/
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { parsePeriodQuery, type PeriodQuery } from "./period-query";
|
||||
import {
|
||||
PICKER_PAGE_SIZE,
|
||||
afterPivot,
|
||||
@@ -22,6 +43,44 @@ function withParams(path: string, params: URLSearchParams): string {
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** The period a search request names, or null outside a period search. */
|
||||
export function requestPeriod(request: PickerPageRequest): PeriodQuery | null {
|
||||
if (request.direction !== "search") return null;
|
||||
return parsePeriodQuery(request.term, request.locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant window of a period in `timeZone`: from the first instant of its
|
||||
* first day to the last millisecond before the day after its last, both
|
||||
* inclusive as the instant routes read them.
|
||||
*/
|
||||
export function periodInstants(
|
||||
period: PeriodQuery,
|
||||
timeZone: string,
|
||||
): { from: string; to: string } {
|
||||
const from = startOfLocalDayKey(period.fromDay, timeZone);
|
||||
const next = startOfLocalDayKey(shiftDateKey(period.toDay, 1), timeZone);
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: new Date(next.getTime() - 1).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** `q`, or the period's instant window, for a search on an instant route. */
|
||||
function setInstantSearch(
|
||||
params: URLSearchParams,
|
||||
request: PickerPageRequest,
|
||||
): void {
|
||||
const period = requestPeriod(request);
|
||||
if (period) {
|
||||
const window = periodInstants(period, request.timeZone);
|
||||
params.set("from", window.from);
|
||||
params.set("to", window.to);
|
||||
} else {
|
||||
params.set("q", request.term);
|
||||
}
|
||||
}
|
||||
|
||||
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
|
||||
export function labPickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
@@ -29,7 +88,7 @@ export function labPickerUrl(request: PickerPageRequest): string {
|
||||
offset: request.cursor ?? "0",
|
||||
sortDir: request.direction === "newer" ? "asc" : "desc",
|
||||
});
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
if (request.direction === "search") setInstantSearch(params, request);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
@@ -45,7 +104,7 @@ export function episodePickerUrl(request: PickerPageRequest): string {
|
||||
offset: request.cursor ?? "0",
|
||||
sortDir: request.direction === "newer" ? "asc" : "desc",
|
||||
});
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
if (request.direction === "search") setInstantSearch(params, request);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
@@ -65,7 +124,11 @@ export function documentPickerUrl(request: PickerPageRequest): string {
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
});
|
||||
if (request.cursor) params.set("cursor", request.cursor);
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
const period = requestPeriod(request);
|
||||
if (period) {
|
||||
params.set("from", period.fromDay);
|
||||
params.set("to", period.toDay);
|
||||
} else if (request.direction === "search") params.set("q", request.term);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") {
|
||||
params.set("to", request.pivot.slice(0, 10));
|
||||
@@ -80,15 +143,20 @@ export function documentPickerUrl(request: PickerPageRequest): string {
|
||||
* `GET /api/encounters` — offset paging inside a window on the visit instant.
|
||||
* The route answers in two arms (upcoming soonest first, past newest first),
|
||||
* each paged by the same offset; the picker merges and orders them itself.
|
||||
* The visit route has no text search, so `search` reads the whole history
|
||||
* and the picker filters what it has loaded.
|
||||
* The visit route has no text search, so a plain term never reaches it (the
|
||||
* picker filters what it has loaded); a period search reads that period.
|
||||
*/
|
||||
export function encounterPickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
offset: request.cursor ?? "0",
|
||||
});
|
||||
if (request.direction !== "search" && request.pivot !== null) {
|
||||
const period = requestPeriod(request);
|
||||
if (period) {
|
||||
const window = periodInstants(period, request.timeZone);
|
||||
params.set("from", window.from);
|
||||
params.set("to", window.to);
|
||||
} else if (request.direction !== "search" && request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@
|
||||
* paged further forward from there;
|
||||
*
|
||||
* and a typed term switches to `search`, which asks the list route itself
|
||||
* (`q`) so a match is found wherever it sits in the history. With no date
|
||||
* yet the picker opens on the newest records and pages back.
|
||||
* (`q`) so a match is found wherever it sits in the history. A term that names
|
||||
* a year or a month ("2019", "03.2019", "März 2019") searches that period
|
||||
* instead, through the route's date window, on every family including the
|
||||
* visit list that has no text search (`parsePeriodQuery`). With no date yet
|
||||
* the picker opens on the newest records and pages back.
|
||||
*
|
||||
* Every page comes from the family's own list route through `fetchPage`, so
|
||||
* paging, the filter and the ownership check stay server-side.
|
||||
@@ -33,6 +36,9 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { parsePeriodQuery, type PeriodQuery } from "./period-query";
|
||||
|
||||
export type PickerDirection = "older" | "newer" | "search";
|
||||
|
||||
@@ -50,6 +56,10 @@ export interface PickerPageRequest {
|
||||
term: string;
|
||||
/** The family's own cursor (an offset or a row id), null for page one. */
|
||||
cursor: string | null;
|
||||
/** The UI locale, which names the months a period search understands. */
|
||||
locale: string;
|
||||
/** The person's timezone, where a period's days begin and end. */
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
/** Records per page, the same for every family. */
|
||||
@@ -106,17 +116,41 @@ export function useLinkPickerPages<T extends { id: string }>({
|
||||
*/
|
||||
serverSearch?: boolean;
|
||||
}) {
|
||||
const { locale } = useTranslations();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const [rawTerm, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const term = serverSearch ? debounced : "";
|
||||
// A period goes to the route as a date window, which every family's route
|
||||
// has, so it is searched on the server even where text is not.
|
||||
const period: PeriodQuery | null = useMemo(
|
||||
() => parsePeriodQuery(debounced, locale),
|
||||
[debounced, locale],
|
||||
);
|
||||
const typedPeriod = useMemo(
|
||||
() => parsePeriodQuery(rawTerm.trim(), locale) !== null,
|
||||
[rawTerm, locale],
|
||||
);
|
||||
const term = serverSearch || period !== null ? debounced : "";
|
||||
const searching = term.length > 0;
|
||||
// "März 2019" and "03.2019" are one request; the key says which period, in
|
||||
// which zone, rather than how it was typed.
|
||||
const searchKey = period
|
||||
? `period:${period.fromDay}..${period.toDay}@${timeZone}`
|
||||
: term;
|
||||
|
||||
const older = useInfiniteQuery({
|
||||
queryKey: queryKey("older", pivot, ""),
|
||||
enabled: enabled && !searching,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
|
||||
fetchPage({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: pageParam,
|
||||
locale,
|
||||
timeZone,
|
||||
}),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
@@ -131,16 +165,30 @@ export function useLinkPickerPages<T extends { id: string }>({
|
||||
enabled: enabled && !searching && newerRequested,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
|
||||
fetchPage({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: pageParam,
|
||||
locale,
|
||||
timeZone,
|
||||
}),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
const search = useInfiniteQuery({
|
||||
queryKey: queryKey("search", null, term),
|
||||
queryKey: queryKey("search", null, searchKey),
|
||||
enabled: enabled && searching,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
|
||||
fetchPage({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: pageParam,
|
||||
locale,
|
||||
timeZone,
|
||||
}),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
@@ -171,10 +219,15 @@ export function useLinkPickerPages<T extends { id: string }>({
|
||||
setTerm,
|
||||
/** A search is in flight (typed, or its first page not back yet). */
|
||||
searchPending:
|
||||
serverSearch &&
|
||||
(serverSearch || typedPeriod || period !== null) &&
|
||||
(rawTerm.trim() !== term || (searching && search.isPending)),
|
||||
/** The picker filters the loaded options by the term itself. */
|
||||
filterLocally: !serverSearch,
|
||||
/**
|
||||
* The picker filters the loaded options by the term itself: a route
|
||||
* without text search, and a term that is not a period.
|
||||
*/
|
||||
filterLocally: !serverSearch && !typedPeriod,
|
||||
/** The period the list is showing, when the term names one. */
|
||||
period: searching ? period : null,
|
||||
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
|
||||
// Unknown until asked: offered once for every dated record, then only
|
||||
// while the server reports another page.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+8
@@ -296,6 +296,14 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/biomarkers/[id]/merge/route.ts",
|
||||
"/api/biomarkers/[id]/merge",
|
||||
"POST",
|
||||
"labs",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/allergies/route.ts",
|
||||
"/api/allergies",
|
||||
|
||||
@@ -244,6 +244,8 @@ const COUNT_BACK: Record<
|
||||
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
|
||||
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
|
||||
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
|
||||
BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }),
|
||||
BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }),
|
||||
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
|
||||
FamilyHistoryEntry: (p, userId) =>
|
||||
p.familyHistoryEntry.count({ where: { userId } }),
|
||||
@@ -738,6 +740,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
// Another spelling of the marker, and the ledger row of a marker that was
|
||||
// merged into it: both ride the marker (D1223).
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
name: "Ferr.",
|
||||
nameFolded: "ferr.",
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
});
|
||||
await prisma.biomarkerMerge.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
targetBiomarkerId: biomarker.id,
|
||||
sourceBiomarkerId: "merged-away-marker",
|
||||
sourceName: "Ferritin (Serum)",
|
||||
readingCount: 3,
|
||||
},
|
||||
});
|
||||
const labResult = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* A marker's other spellings and the merge ledger survive a backup and a
|
||||
* restore, in both kinds of file (D1223).
|
||||
*
|
||||
* 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. Every column of both tables is compared
|
||||
* after a disaster-recovery round trip, and the portable file, which carries
|
||||
* no ids, is checked for what the resolver and the 404 actually use: the
|
||||
* spelling, its fold, where it came from and which marker it belongs to, and
|
||||
* which marker a merged id went into.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { loadMarkerIndex, resolveInIndex } from "@/lib/labs/biomarker-resolve";
|
||||
import { mergedInto } from "@/lib/labs/biomarker-merge";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { POST } from "./restore-job-driver";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/cache/invalidate")>()),
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const OWNER = "biomarker-backup-owner";
|
||||
|
||||
async function createOwner() {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: OWNER,
|
||||
username: "biomarker-backup-owner",
|
||||
email: "biomarker-backup-owner@example.test",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "biomarker-backup-admin",
|
||||
email: "biomarker-backup-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/** Two markers, an alias from each source, and a two-step merge ledger. */
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
const alp = await prisma.biomarker.create({
|
||||
data: { userId: OWNER, name: "Alkaline Phosphatase", unit: "U/L" },
|
||||
});
|
||||
const bili = await prisma.biomarker.create({
|
||||
data: { userId: OWNER, name: "Bilirubin (Total)", unit: "mg/dL" },
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
analyte: "Alk.P",
|
||||
value: 80,
|
||||
unit: "U/L",
|
||||
takenAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.biomarkerAlias.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
name: "Alk.P",
|
||||
nameFolded: "alk.p",
|
||||
source: "MERGE",
|
||||
createdAt: new Date("2026-05-02T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
name: "AP",
|
||||
nameFolded: "ap",
|
||||
source: "MANUAL",
|
||||
createdAt: new Date("2026-05-03T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: bili.id,
|
||||
name: "Bilirubin ges.",
|
||||
nameFolded: "bilirubin ges.",
|
||||
source: "IMPORT_CHOICE",
|
||||
createdAt: new Date("2026-05-04T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.biomarkerMerge.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: OWNER,
|
||||
targetBiomarkerId: alp.id,
|
||||
sourceBiomarkerId: "merged-alk-p",
|
||||
sourceName: "Alk.P",
|
||||
readingCount: 12,
|
||||
mergedAt: new Date("2026-05-02T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
targetBiomarkerId: bili.id,
|
||||
sourceBiomarkerId: "merged-t-bili",
|
||||
sourceName: "T.Bili",
|
||||
readingCount: 0,
|
||||
mergedAt: new Date("2026-05-05T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
return { alp, bili };
|
||||
}
|
||||
|
||||
async function snapshot() {
|
||||
const prisma = getPrismaClient();
|
||||
const markers = await prisma.biomarker.findMany({
|
||||
where: { userId: OWNER },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameOf = new Map(markers.map((m) => [m.id, m.name]));
|
||||
const aliases = (
|
||||
await prisma.biomarkerAlias.findMany({ where: { userId: OWNER } })
|
||||
)
|
||||
.map((a) => ({ ...a, marker: nameOf.get(a.biomarkerId) }))
|
||||
.sort((a, b) => a.nameFolded.localeCompare(b.nameFolded));
|
||||
const merges = (
|
||||
await prisma.biomarkerMerge.findMany({ where: { userId: OWNER } })
|
||||
)
|
||||
.map((m) => ({ ...m, target: nameOf.get(m.targetBiomarkerId) }))
|
||||
.sort((a, b) => a.sourceBiomarkerId.localeCompare(b.sourceBiomarkerId));
|
||||
return { aliases, merges };
|
||||
}
|
||||
|
||||
/** Empty the account, store `data` as a backup row, and restore it. */
|
||||
async function restore(
|
||||
data: Awaited<ReturnType<typeof legacyStreamedBlobFrom>> | string,
|
||||
type: string,
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.delete({ where: { id: OWNER } });
|
||||
await createOwner();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: { userId: OWNER, type, data },
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await seedAdmin();
|
||||
await createOwner();
|
||||
});
|
||||
|
||||
describe("biomarker aliases and merges in a backup", () => {
|
||||
it("come back column for column from a disaster-recovery file", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seed();
|
||||
const before = await snapshot();
|
||||
expect(before.aliases).toHaveLength(3);
|
||||
expect(before.merges).toHaveLength(2);
|
||||
|
||||
let json = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt: new Date() },
|
||||
);
|
||||
await restore(
|
||||
await legacyStreamedBlobFrom(async (write) => {
|
||||
await write(json);
|
||||
}),
|
||||
"BIOMARKER_DR_ROUND_TRIP",
|
||||
);
|
||||
|
||||
// Every column, ids and stamps included.
|
||||
expect(await snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it("come back from a portable file with every value the resolver and the 404 read", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { alp, bili } = await seed();
|
||||
const before = await snapshot();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER);
|
||||
const parsed = parseBackupPayload(JSON.stringify(payload));
|
||||
expect(
|
||||
parsed.biomarkers.find((b) => b.name === "Alkaline Phosphatase")?.aliases,
|
||||
).toHaveLength(2);
|
||||
expect(parsed.biomarkerMerges).toHaveLength(2);
|
||||
// Stored the way an uploaded portable file is: encrypted JSON.
|
||||
await restore(
|
||||
encrypt(JSON.stringify(parsed)),
|
||||
"BIOMARKER_PORTABLE_ROUND_TRIP",
|
||||
);
|
||||
|
||||
const after = await snapshot();
|
||||
const portable = (s: typeof before) => ({
|
||||
aliases: s.aliases.map((a) => ({
|
||||
name: a.name,
|
||||
nameFolded: a.nameFolded,
|
||||
source: a.source,
|
||||
marker: a.marker,
|
||||
userId: a.userId,
|
||||
})),
|
||||
merges: s.merges.map((m) => ({
|
||||
sourceBiomarkerId: m.sourceBiomarkerId,
|
||||
sourceName: m.sourceName,
|
||||
readingCount: m.readingCount,
|
||||
mergedAt: m.mergedAt,
|
||||
target: m.target,
|
||||
userId: m.userId,
|
||||
})),
|
||||
});
|
||||
expect(portable(after)).toEqual(portable(before));
|
||||
|
||||
// What the values are for: the spellings resolve again, and the merged
|
||||
// ids still name the marker they went into.
|
||||
const index = await loadMarkerIndex(prisma, OWNER);
|
||||
const restoredAlp = index.markers.find((m) => m.name === alp.name)!;
|
||||
const restoredBili = index.markers.find((m) => m.name === bili.name)!;
|
||||
expect(resolveInIndex(index, "AP")).toEqual({
|
||||
marker: restoredAlp,
|
||||
matchKind: "alias",
|
||||
});
|
||||
expect(resolveInIndex(index, "bilirubin ges.")?.marker).toEqual(
|
||||
restoredBili,
|
||||
);
|
||||
expect(await mergedInto(OWNER, "merged-t-bili")).toBe(restoredBili.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,728 @@
|
||||
/**
|
||||
* Merging markers, aliases and catalog shorthand against real SQL (D1223).
|
||||
*
|
||||
* "Alk.P" and "Alkaline Phosphatase" written by two labs used to be two
|
||||
* markers with half a history each. These tests drive the shipped route
|
||||
* exports and readers: a merge moves every reading and every reader then
|
||||
* shows one series, a dry run answers the same and writes nothing, readings
|
||||
* in another unit are converted with what the report printed kept, a pair
|
||||
* that cannot be converted refuses the whole merge, and a spelling chosen once
|
||||
* on a scan or a document resolves on its own the next time.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USER_ID = "user-biomarker-merge";
|
||||
const OTHER_ID = "user-biomarker-merge-other";
|
||||
const DAY_MS = 86_400_000;
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{ id: USER_ID, username: "bm-merge", email: "bm-merge@example.test" },
|
||||
{ id: OTHER_ID, username: "bm-other", email: "bm-other@example.test" },
|
||||
],
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
async function seedMarker(
|
||||
name: string,
|
||||
unit: string,
|
||||
readings: number,
|
||||
options: { userId?: string; firstDay?: number; value?: number } = {},
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = options.userId ?? USER_ID;
|
||||
const marker = await prisma.biomarker.create({
|
||||
data: { userId, name, unit },
|
||||
});
|
||||
await prisma.labResult.createMany({
|
||||
data: Array.from({ length: readings }, (_, i) => ({
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
analyte: name,
|
||||
value: (options.value ?? 80) + (i % 7),
|
||||
unit,
|
||||
takenAt: daysAgo((options.firstDay ?? 10) + i * 3),
|
||||
})),
|
||||
});
|
||||
return marker;
|
||||
}
|
||||
|
||||
function json(body: unknown, url: string, method = "POST") {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function merge(
|
||||
targetId: string,
|
||||
body: { sourceIds: string[]; dryRun?: boolean },
|
||||
) {
|
||||
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
|
||||
return POST(json(body, `http://localhost/api/biomarkers/${targetId}/merge`), {
|
||||
params: Promise.resolve({ id: targetId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function tableCounts() {
|
||||
const prisma = getPrismaClient();
|
||||
return {
|
||||
markers: await prisma.biomarker.count(),
|
||||
readings: await prisma.labResult.count(),
|
||||
linked: await prisma.labResult.groupBy({
|
||||
by: ["biomarkerId"],
|
||||
_count: true,
|
||||
orderBy: { biomarkerId: "asc" },
|
||||
}),
|
||||
aliases: await prisma.biomarkerAlias.count(),
|
||||
merges: await prisma.biomarkerMerge.count(),
|
||||
values: await prisma.labResult.findMany({
|
||||
select: { id: true, value: true, unit: true, sourceValue: true },
|
||||
orderBy: { id: "asc" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/biomarkers/{id}/merge", () => {
|
||||
it("merges 30 and 12 readings into one series every reader shows as 42", async () => {
|
||||
const target = await seedMarker("Alkaline Phosphatase", "U/L", 30);
|
||||
const source = await seedMarker("Alk.P", "U/L", 12, { firstDay: 400 });
|
||||
|
||||
const res = await merge(target.id, { sourceIds: [source.id] });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
dryRun: false,
|
||||
movedReadings: 12,
|
||||
convertedReadings: 0,
|
||||
aliasesAdded: ["Alk.P"],
|
||||
sources: [
|
||||
{
|
||||
id: source.id,
|
||||
name: "Alk.P",
|
||||
unit: "U/L",
|
||||
readingCount: 12,
|
||||
conversion: "none",
|
||||
refusal: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(data.mergeId).toEqual(expect.any(String));
|
||||
expect(data.target.aliases).toEqual(["Alk.P"]);
|
||||
|
||||
// The overview: one marker, 42 readings.
|
||||
const { GET: overview } = await import("@/app/api/labs/overview/route");
|
||||
const ov = await (
|
||||
await overview(new NextRequest("http://localhost/api/labs/overview"))
|
||||
).json();
|
||||
expect(ov.data.markers).toHaveLength(1);
|
||||
expect(ov.data.markers[0]).toMatchObject({
|
||||
key: `bm:${target.id}`,
|
||||
count: 42,
|
||||
});
|
||||
|
||||
// The marker detail's own list.
|
||||
const { GET: list } = await import("@/app/api/labs/route");
|
||||
const detail = await (
|
||||
await list(
|
||||
new NextRequest(
|
||||
`http://localhost/api/labs?biomarkerId=${target.id}&limit=100`,
|
||||
),
|
||||
)
|
||||
).json();
|
||||
expect(detail.data.meta.total).toBe(42);
|
||||
|
||||
// The doctor report: one line, counted 42, under the marker's name.
|
||||
const { loadLabResults } =
|
||||
await import("@/lib/doctor-report/clinical-records");
|
||||
const report = await loadLabResults(USER_ID, daysAgo(1000), new Date());
|
||||
expect(report).toHaveLength(1);
|
||||
expect(report![0]).toMatchObject({
|
||||
analyte: "Alkaline Phosphatase",
|
||||
count: 42,
|
||||
});
|
||||
|
||||
// FHIR: one Observation for the marker, named after it.
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let n = 0;
|
||||
const observations = labObservations(
|
||||
{ labResults: report } as Parameters<typeof labObservations>[0],
|
||||
() => `obs-${++n}`,
|
||||
);
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0].code.text).toBe("Alkaline Phosphatase");
|
||||
|
||||
// MCP history: the printed name, the marker's name and the alias all read
|
||||
// the whole series.
|
||||
const { getLabHistory } = await import("@/lib/mcp/rich-reads");
|
||||
for (const analyte of ["Alk.P", "alkaline phosphatase", "ALP"]) {
|
||||
const total: number[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const page = await getLabHistory(USER_ID, { analyte, offset });
|
||||
expect(page.present, analyte).toBe(true);
|
||||
if (!page.present) break;
|
||||
total.push(page.readings!.length);
|
||||
if (!page.nextCursor) break;
|
||||
offset += page.readings!.length;
|
||||
}
|
||||
expect(
|
||||
total.reduce((a, b) => a + b, 0),
|
||||
analyte,
|
||||
).toBe(42);
|
||||
}
|
||||
|
||||
// The source is gone, its old id leads to the target, and the reading's
|
||||
// printed name survived.
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.biomarker.findUnique({ where: { id: source.id } }),
|
||||
).toBeNull();
|
||||
const { GET: getMarker } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const stale = await getMarker(
|
||||
new NextRequest(`http://localhost/api/biomarkers/${source.id}`),
|
||||
{ params: Promise.resolve({ id: source.id }) },
|
||||
);
|
||||
expect(stale.status).toBe(404);
|
||||
const staleBody = await stale.json();
|
||||
expect(staleBody.meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
mergedInto: target.id,
|
||||
});
|
||||
expect(
|
||||
await prisma.labResult.count({
|
||||
where: { biomarkerId: target.id, analyte: "Alk.P" },
|
||||
}),
|
||||
).toBe(12);
|
||||
const ledger = await prisma.biomarkerMerge.findMany({});
|
||||
expect(ledger).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: USER_ID,
|
||||
targetBiomarkerId: target.id,
|
||||
sourceBiomarkerId: source.id,
|
||||
sourceName: "Alk.P",
|
||||
readingCount: 12,
|
||||
}),
|
||||
]);
|
||||
const audit = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { action: "labs.biomarker.merge" },
|
||||
});
|
||||
expect(audit.details).not.toContain("Alk.P");
|
||||
expect(audit.details).toContain(source.id);
|
||||
});
|
||||
|
||||
it("answers a dry run exactly like the merge and writes nothing", async () => {
|
||||
const target = await seedMarker("Glucose", "mg/dL", 5, { value: 90 });
|
||||
const sameUnit = await seedMarker("Blutzucker", "mg/dL", 3);
|
||||
const molar = await seedMarker("Glukose", "mmol/L", 4, { value: 5 });
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
name: "GLU",
|
||||
nameFolded: "glu",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
const before = await tableCounts();
|
||||
const preview = await merge(target.id, {
|
||||
sourceIds: [sameUnit.id, molar.id],
|
||||
dryRun: true,
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
const dry = (await preview.json()).data;
|
||||
expect(await tableCounts()).toEqual(before);
|
||||
expect(dry.dryRun).toBe(true);
|
||||
expect(dry.mergeId).toBeNull();
|
||||
expect(dry.target.aliases).toEqual([]);
|
||||
|
||||
const applied = (
|
||||
await (
|
||||
await merge(target.id, { sourceIds: [sameUnit.id, molar.id] })
|
||||
).json()
|
||||
).data;
|
||||
const comparable = (r: Record<string, unknown>) => {
|
||||
const rest = { ...r };
|
||||
delete rest.dryRun;
|
||||
delete rest.mergeId;
|
||||
delete rest.target;
|
||||
return rest;
|
||||
};
|
||||
expect(comparable(applied)).toEqual(comparable(dry));
|
||||
expect(applied).toMatchObject({
|
||||
movedReadings: 7,
|
||||
convertedReadings: 4,
|
||||
aliasesAdded: ["Blutzucker", "Glukose", "GLU"],
|
||||
});
|
||||
expect(applied.sources[1]).toMatchObject({
|
||||
id: molar.id,
|
||||
conversion: "converted",
|
||||
});
|
||||
expect([...applied.target.aliases].sort()).toEqual(
|
||||
["Blutzucker", "GLU", "Glukose"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("converts readings in another unit and keeps what the report printed", async () => {
|
||||
const target = await seedMarker("Glucose", "mg/dL", 1, { value: 90 });
|
||||
const molar = await seedMarker("Glukose", "mmol/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
const fresh = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
analyte: "Glukose",
|
||||
value: 5.5,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.6,
|
||||
takenAt: daysAgo(40),
|
||||
},
|
||||
});
|
||||
// A reading converted on its way in already: its original pair stays.
|
||||
const twice = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
analyte: "Glukose",
|
||||
value: 6,
|
||||
unit: "mmol/L",
|
||||
sourceValue: 6000,
|
||||
sourceUnit: "µmol/L",
|
||||
takenAt: daysAgo(50),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await merge(target.id, { sourceIds: [molar.id] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const moved = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: fresh.id },
|
||||
});
|
||||
expect(moved.biomarkerId).toBe(target.id);
|
||||
expect(moved.unit).toBe("mg/dL");
|
||||
expect(moved.value).toBeCloseTo(99.09, 1);
|
||||
expect(moved.sourceValue).toBe(5.5);
|
||||
expect(moved.sourceUnit).toBe("mmol/L");
|
||||
expect(moved.sourceReferenceLow).toBeCloseTo(70.26, 1);
|
||||
expect(moved.sourceReferenceHigh).toBeCloseTo(100.89, 1);
|
||||
expect(moved.analyte).toBe("Glukose");
|
||||
|
||||
const kept = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: twice.id },
|
||||
});
|
||||
expect(kept.value).toBeCloseTo(108.09, 1);
|
||||
expect(kept.sourceValue).toBe(6000);
|
||||
expect(kept.sourceUnit).toBe("µmol/L");
|
||||
});
|
||||
|
||||
it("refuses the whole merge when one source cannot be converted", async () => {
|
||||
const target = await seedMarker("ALT", "U/L", 3);
|
||||
const convertible = await seedMarker("GPT", "U/L", 2);
|
||||
const enzymeSi = await seedMarker("ALAT", "µkat/L", 2, { value: 1 });
|
||||
|
||||
const before = await tableCounts();
|
||||
const res = await merge(target.id, {
|
||||
sourceIds: [convertible.id, enzymeSi.id],
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.merge.unitMismatch",
|
||||
sources: [
|
||||
{
|
||||
id: enzymeSi.id,
|
||||
unit: "µkat/L",
|
||||
targetUnit: "U/L",
|
||||
reason: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
// Nothing moved, not even the source that would have converted.
|
||||
expect(await tableCounts()).toEqual(before);
|
||||
|
||||
// The preview names the refusal instead of failing.
|
||||
const preview = await merge(target.id, {
|
||||
sourceIds: [convertible.id, enzymeSi.id],
|
||||
dryRun: true,
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
const dry = (await preview.json()).data;
|
||||
expect(dry.sources[0].conversion).toBe("none");
|
||||
expect(dry.sources[1]).toMatchObject({
|
||||
conversion: "refused",
|
||||
refusal: expect.stringMatching(/unknown_(analyte|unit)/),
|
||||
});
|
||||
});
|
||||
|
||||
it("names every missing id, refuses a target among the sources, and never reaches another account", async () => {
|
||||
const target = await seedMarker("TSH", "mIU/L", 1);
|
||||
const foreign = await seedMarker("TSH basal", "mIU/L", 1, {
|
||||
userId: OTHER_ID,
|
||||
});
|
||||
|
||||
const missing = await merge(target.id, {
|
||||
sourceIds: [foreign.id, "nope"],
|
||||
});
|
||||
expect(missing.status).toBe(404);
|
||||
expect((await missing.json()).meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
ids: [foreign.id, "nope"],
|
||||
});
|
||||
|
||||
const self = await merge(target.id, { sourceIds: [target.id] });
|
||||
expect(self.status).toBe(422);
|
||||
expect((await self.json()).meta.errorCode).toBe(
|
||||
"labs.biomarker.merge.invalid",
|
||||
);
|
||||
|
||||
expect(
|
||||
await getPrismaClient().biomarker.count({ where: { userId: OTHER_ID } }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("follows a marker merged twice to where it ended up", async () => {
|
||||
const a = await seedMarker("Ferritin (Serum)", "ng/mL", 1);
|
||||
const b = await seedMarker("Ferr", "ng/mL", 1);
|
||||
const c = await seedMarker("Ferritin", "ng/mL", 1);
|
||||
expect((await merge(b.id, { sourceIds: [a.id] })).status).toBe(200);
|
||||
expect((await merge(c.id, { sourceIds: [b.id] })).status).toBe(200);
|
||||
|
||||
const { GET } = await import("@/app/api/biomarkers/[id]/route");
|
||||
for (const id of [a.id, b.id]) {
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/biomarkers/${id}`),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect((await res.json()).meta.mergedInto).toBe(c.id);
|
||||
}
|
||||
const aliases = await getPrismaClient().biomarkerAlias.findMany({
|
||||
where: { biomarkerId: c.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
expect(aliases.map((alias) => alias.name)).toEqual([
|
||||
"Ferr",
|
||||
"Ferritin (Serum)",
|
||||
]);
|
||||
expect(
|
||||
await getPrismaClient().labResult.count({
|
||||
where: { biomarkerId: c.id },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliases on the marker", () => {
|
||||
async function put(id: string, body: unknown) {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
return PUT(json(body, `http://localhost/api/biomarkers/${id}`, "PUT"), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
}
|
||||
|
||||
it("replaces the set, keeps where a kept spelling came from, and leaves it alone when omitted", async () => {
|
||||
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: alp.id,
|
||||
name: "Alk.P",
|
||||
nameFolded: "alk.p",
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await put(alp.id, {
|
||||
aliases: ["Alk.P", "AP", "alkaline phosphatase"],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.aliases).toEqual(["Alk.P", "AP"]);
|
||||
const rows = await prisma.biomarkerAlias.findMany({
|
||||
where: { biomarkerId: alp.id },
|
||||
orderBy: { nameFolded: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => [r.name, r.source])).toEqual([
|
||||
["Alk.P", "IMPORT_CHOICE"],
|
||||
["AP", "MANUAL"],
|
||||
]);
|
||||
|
||||
// An older client's PUT without the field keeps them.
|
||||
const untouched = await put(alp.id, { lowerBound: 40 });
|
||||
expect((await untouched.json()).data.aliases).toEqual(["Alk.P", "AP"]);
|
||||
|
||||
const cleared = await put(alp.id, { aliases: [] });
|
||||
expect((await cleared.json()).data.aliases).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a spelling another marker already answers to", async () => {
|
||||
const alt = await seedMarker("ALT", "U/L", 0);
|
||||
const ast = await seedMarker("AST", "U/L", 0);
|
||||
await getPrismaClient().biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: ast.id,
|
||||
name: "GOT",
|
||||
nameFolded: "got",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
for (const [alias, owner] of [
|
||||
["ast", ast.id],
|
||||
["got", ast.id],
|
||||
] as const) {
|
||||
const res = await put(alt.id, { aliases: ["GPT", alias] });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.alias.taken",
|
||||
alias,
|
||||
biomarkerId: owner,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
await getPrismaClient().biomarkerAlias.count({
|
||||
where: { biomarkerId: alt.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a spelling resolves on its own once chosen", () => {
|
||||
it("maps T.Bili to the chosen marker on the second scan and on manual entry", async () => {
|
||||
const bili = await seedMarker("Bilirubin (Total)", "mg/dL", 2, {
|
||||
value: 1,
|
||||
});
|
||||
const { annotateRow } = await import("@/lib/labs/ocr-extract");
|
||||
const scanned = {
|
||||
analyte: "Bilirubin ges.",
|
||||
value: 0.8,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-01",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
};
|
||||
|
||||
// First scan: a name nothing resolves; the review offers the marker.
|
||||
const first = await annotateRow(USER_ID, scanned, null, "UTC");
|
||||
expect(first.biomarkerId).toBeNull();
|
||||
expect(first.biomarkerMatch).toBe("new");
|
||||
expect(first.suggestions[0]).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "similar",
|
||||
});
|
||||
|
||||
// The catalog's shorthand resolves without a choice.
|
||||
const shorthand = await annotateRow(
|
||||
USER_ID,
|
||||
{ ...scanned, analyte: "T.Bili" },
|
||||
null,
|
||||
"UTC",
|
||||
);
|
||||
expect(shorthand).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "catalog",
|
||||
biomarkerMatch: "existing",
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
// The person picks the marker for "Bilirubin ges." and commits.
|
||||
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
|
||||
const committed = await commit(
|
||||
json(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
analyte: "Bilirubin ges.",
|
||||
value: 0.8,
|
||||
unit: "mg/dL",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
biomarkerId: bili.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://localhost/api/labs/ocr/commit",
|
||||
),
|
||||
);
|
||||
expect(committed.status).toBe(200);
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.labResult.count({ where: { biomarkerId: bili.id } }),
|
||||
).toBe(3);
|
||||
expect(
|
||||
await prisma.biomarkerAlias.findFirst({
|
||||
where: { biomarkerId: bili.id },
|
||||
select: { name: true, source: true },
|
||||
}),
|
||||
).toEqual({ name: "Bilirubin ges.", source: "IMPORT_CHOICE" });
|
||||
|
||||
// The second scan of the same lab matches without asking.
|
||||
const second = await annotateRow(
|
||||
USER_ID,
|
||||
{ ...scanned, takenAt: "2026-07-01" },
|
||||
null,
|
||||
"UTC",
|
||||
);
|
||||
expect(second).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "alias",
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
// Manual entry with the alias lands on the marker, no new marker.
|
||||
const { POST: create } = await import("@/app/api/labs/route");
|
||||
const typed = await create(
|
||||
json(
|
||||
{
|
||||
analyte: "bilirubin ges.",
|
||||
value: 0.9,
|
||||
unit: "mg/dL",
|
||||
takenAt: new Date().toISOString(),
|
||||
},
|
||||
"http://localhost/api/labs",
|
||||
),
|
||||
);
|
||||
expect(typed.status).toBe(201);
|
||||
expect((await typed.json()).data.biomarkerId).toBe(bili.id);
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a row naming a marker the record does not have", async () => {
|
||||
const foreign = await seedMarker("Ferritin", "ng/mL", 0, {
|
||||
userId: OTHER_ID,
|
||||
});
|
||||
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
|
||||
const res = await commit(
|
||||
json(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
analyte: "Ferritin",
|
||||
value: 50,
|
||||
unit: "ng/mL",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
biomarkerId: foreign.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://localhost/api/labs/ocr/commit",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.skipped).toEqual([
|
||||
{ analyte: "Ferritin", reason: "unknown_biomarker" },
|
||||
]);
|
||||
expect(await getPrismaClient().labResult.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("files a confirmed document value under the chosen marker and remembers the label", async () => {
|
||||
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
const { encryptFactData, encryptFactProvenance, serialiseFact } =
|
||||
await import("@/lib/documents/store");
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
kind: "LAB_RESULT",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 10,
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
},
|
||||
});
|
||||
const fact = await prisma.extractedFact.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
documentId: document.id,
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Alk. Phosph.",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 70,
|
||||
valueText: null,
|
||||
unit: "U/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
effectiveDate: "2026-05-02",
|
||||
}),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Alk. Phosph. 70 U/L",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: 1,
|
||||
confidence: 0.95,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { loadMarkerIndex } = await import("@/lib/labs/biomarker-resolve");
|
||||
const dto = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
|
||||
expect(dto.biomarkerId).toBeNull();
|
||||
expect(dto.suggestions?.[0]?.biomarkerId).toBe(alp.id);
|
||||
|
||||
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
||||
const ref = await commitApprovedFact(USER_ID, fact, {
|
||||
biomarkerId: alp.id,
|
||||
});
|
||||
const reading = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: ref.recordId },
|
||||
});
|
||||
expect(reading.biomarkerId).toBe(alp.id);
|
||||
expect((await prisma.biomarkerAlias.findFirstOrThrow({})).name).toBe(
|
||||
"Alk. Phosph.",
|
||||
);
|
||||
|
||||
const again = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
|
||||
expect(again).toMatchObject({ biomarkerId: alp.id, matchKind: "alias" });
|
||||
});
|
||||
});
|
||||
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1124,6 +1124,51 @@ manageContract("DELETE /api/biomarkers/[id]", {
|
||||
})) === null,
|
||||
});
|
||||
|
||||
manageContract("POST /api/biomarkers/[id]/merge", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/biomarkers/[id]/merge",
|
||||
)!,
|
||||
prepare: async (userId) => {
|
||||
const prisma = getPrismaClient();
|
||||
const target = await prisma.biomarker.create({
|
||||
data: { userId, name: "Alkaline Phosphatase", unit: "U/L" },
|
||||
});
|
||||
const source = await prisma.biomarker.create({
|
||||
data: { userId, name: "Alk.P", unit: "U/L" },
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
biomarkerId: source.id,
|
||||
analyte: "Alk.P",
|
||||
value: 81,
|
||||
unit: "U/L",
|
||||
takenAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
return { id: target.id, sourceId: source.id };
|
||||
},
|
||||
act: async (row) => {
|
||||
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
|
||||
return call(
|
||||
POST as Handler,
|
||||
"POST",
|
||||
`/api/biomarkers/${row.id}/merge`,
|
||||
{ sourceIds: [row.sourceId] },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "labs.biomarker.merge",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().biomarker.findUnique({
|
||||
where: { id: row.sourceId },
|
||||
})) === null &&
|
||||
(await getPrismaClient().labResult.count({
|
||||
where: { biomarkerId: row.id },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("POST /api/allergies", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/allergies" && entry.action === "POST",
|
||||
@@ -2878,13 +2923,13 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(88);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(89);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(88);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(89);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const VISIT_AT = "2019-04-03T09:30:00.000Z";
|
||||
const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z");
|
||||
const RESULT_COUNT = 1_200;
|
||||
/** What the hook adds to every request: the UI locale and the person's zone. */
|
||||
const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const;
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
});
|
||||
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
|
||||
// Nothing after the window's split is on it.
|
||||
@@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot,
|
||||
term: "",
|
||||
cursor,
|
||||
...CTX,
|
||||
});
|
||||
for (const row of page.results) {
|
||||
expect(seen.has(row.id)).toBe(false);
|
||||
@@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
...CTX,
|
||||
});
|
||||
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
|
||||
}
|
||||
@@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot: pickerPivot(VISIT_AT),
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(list.past).toHaveLength(1);
|
||||
@@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => {
|
||||
const pivot = pickerPivot(VISIT_AT);
|
||||
const around = await get<Array<{ id: string; onsetAt: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
episodePickerUrl({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(around[0]!.id).toBe(old.id);
|
||||
|
||||
@@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => {
|
||||
pivot: null,
|
||||
term: "tendin",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(found.map((row) => row.id)).toEqual([old.id]);
|
||||
@@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => {
|
||||
for (let guard = 0; guard < 5; guard++) {
|
||||
const page: Array<{ id: string }> = await get(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
|
||||
episodePickerUrl({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
for (const row of page) {
|
||||
expect(ids.has(row.id)).toBe(false);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* A picker search for a year or a month returns exactly that period.
|
||||
*
|
||||
* Typing "2019" into a visit's link picker used to send `q=2019`, which the
|
||||
* list routes match against names and titles, so filtering by a year found
|
||||
* nothing. A term that names a period is now sent as the route's own date
|
||||
* window. This drives every family's list route with the requests the
|
||||
* pickers build (`picker-requests.ts`), against three years of records, and
|
||||
* walks every page: the answer must be every record in the period and
|
||||
* nothing else, including the records far beyond the first page.
|
||||
*
|
||||
* The expected sets are derived independently of the builders: an instant
|
||||
* belongs to the period its day in the person's zone falls in
|
||||
* (`userDayKey`), a document to the period its stored calendar date is in.
|
||||
* The seeds put records just either side of each boundary in that zone.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { GET as listEncounters } from "@/app/api/encounters/route";
|
||||
import { GET as listDocuments } from "@/app/api/documents/inbound/route";
|
||||
import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
|
||||
import { GET as listLabs } from "@/app/api/labs/route";
|
||||
import {
|
||||
documentPickerUrl,
|
||||
encounterPickerUrl,
|
||||
episodePickerUrl,
|
||||
labPickerUrl,
|
||||
} from "@/components/links/picker-requests";
|
||||
import {
|
||||
PICKER_PAGE_SIZE,
|
||||
nextOffset,
|
||||
type PickerPageRequest,
|
||||
} from "@/components/links/use-link-picker-pages";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "period-search-owner";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */
|
||||
const DAYS = Array.from(
|
||||
{ length: 1096 },
|
||||
(_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Instants just either side of the period boundaries in Berlin: 23:30 and
|
||||
* 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1)
|
||||
* and the end of March 2019 (UTC+2, after the DST change), and New Year 2020.
|
||||
*/
|
||||
const EDGES = [
|
||||
"2018-12-31T22:30:00.000Z",
|
||||
"2018-12-31T23:30:00.000Z",
|
||||
"2019-02-28T22:30:00.000Z",
|
||||
"2019-02-28T23:30:00.000Z",
|
||||
"2019-03-31T21:30:00.000Z",
|
||||
"2019-03-31T22:30:00.000Z",
|
||||
"2019-12-31T22:30:00.000Z",
|
||||
"2019-12-31T23:30:00.000Z",
|
||||
].map((iso) => new Date(iso));
|
||||
|
||||
const search = (term: string, locale: string): PickerPageRequest => ({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
locale,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "period-search",
|
||||
email: "period-search@example.test",
|
||||
timezone: TZ,
|
||||
modulePreferencesJson: {
|
||||
labs: true,
|
||||
illness: true,
|
||||
inboundDocuments: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
expiresAt: new Date(Date.now() + 7 * DAY_MS),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function get<T>(
|
||||
handler: (request: NextRequest) => Promise<Response>,
|
||||
url: string,
|
||||
): Promise<T> {
|
||||
const response = await handler(new NextRequest(`http://localhost${url}`));
|
||||
expect(response.status, url).toBe(200);
|
||||
return ((await response.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/** Ids of the instants whose day in the person's zone starts with `prefix`. */
|
||||
function expectedIds(
|
||||
rows: ReadonlyArray<{ id: string; at: Date }>,
|
||||
prefix: string,
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
rows
|
||||
.filter((row) => userDayKey(row.at, TZ).startsWith(prefix))
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Walk an offset-paged search to its end, newest first, without repeats. */
|
||||
async function walkOffsets<T extends { id: string }>(
|
||||
fetchPage: (cursor: string | null) => Promise<T[]>,
|
||||
at: (row: T) => string,
|
||||
): Promise<{ ids: Set<string>; pages: number }> {
|
||||
const ids = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
let previous = Number.POSITIVE_INFINITY;
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const rows = await fetchPage(cursor);
|
||||
pages += 1;
|
||||
for (const row of rows) {
|
||||
expect(ids.has(row.id), row.id).toBe(false);
|
||||
ids.add(row.id);
|
||||
const when = new Date(at(row)).getTime();
|
||||
expect(when).toBeLessThanOrEqual(previous);
|
||||
previous = when;
|
||||
}
|
||||
cursor = nextOffset(cursor, rows.length);
|
||||
if (cursor === null) break;
|
||||
}
|
||||
return { ids, pages };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedOwner();
|
||||
});
|
||||
|
||||
describe("lab results: a period search walks exactly the period", () => {
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
// A date-only reading is stored at noon UTC; the edges carry a time.
|
||||
const instants = [
|
||||
...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.labResult.createMany({
|
||||
data: instants.map((takenAt, i) => ({
|
||||
userId: OWNER_ID,
|
||||
panel: "Metabolic",
|
||||
analyte: `Glucose ${i}`,
|
||||
value: 90,
|
||||
unit: "mg/dL",
|
||||
takenAt,
|
||||
})),
|
||||
});
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, takenAt: true },
|
||||
});
|
||||
return rows.map((row) => ({ id: row.id, at: row.takenAt }));
|
||||
}
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const page = await get<{
|
||||
results: Array<{ id: string; takenAt: string }>;
|
||||
}>(listLabs as never, labPickerUrl({ ...request, cursor }));
|
||||
return page.results;
|
||||
},
|
||||
(row) => row.takenAt,
|
||||
);
|
||||
|
||||
it("a year", async () => {
|
||||
const rows = await seed();
|
||||
const expected = expectedIds(rows, "2019-");
|
||||
expect(expected.size).toBe(365 + 6);
|
||||
const { ids, pages } = await walk(search("2019", "en"));
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", "März 2019"],
|
||||
["de", "03.2019"],
|
||||
["en", "Mar 2019"],
|
||||
["pl", "marzec 2019"],
|
||||
["ko", "2019년 3월"],
|
||||
])("a month (%s %j)", async (locale, term) => {
|
||||
const rows = await seed();
|
||||
const expected = expectedIds(rows, "2019-03-");
|
||||
expect(expected.size).toBe(31 + 2);
|
||||
const { ids } = await walk(search(term, locale));
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
it("a plain term is still a text search", async () => {
|
||||
await seed();
|
||||
const { ids } = await walk(search("Glucose 7", "en"));
|
||||
// "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only.
|
||||
expect(ids.size).toBeGreaterThan(0);
|
||||
expect(ids.size).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditions: a period search walks exactly the period", () => {
|
||||
it("a year and a month", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const onsets = [
|
||||
...DAYS.filter((_, i) => i % 2 === 0).map(
|
||||
(day) => new Date(day.getTime() + 5 * 60 * 60 * 1000),
|
||||
),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.illnessEpisode.createMany({
|
||||
data: onsets.map((onsetAt, i) => ({
|
||||
userId: OWNER_ID,
|
||||
label: `Cold ${i}`,
|
||||
type: "INFECTION" as const,
|
||||
onsetAt,
|
||||
resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS),
|
||||
})),
|
||||
});
|
||||
const rows = (
|
||||
await prisma.illnessEpisode.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, onsetAt: true },
|
||||
})
|
||||
).map((row) => ({ id: row.id, at: row.onsetAt }));
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const page = await get<Array<{ id: string; onsetAt: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ ...request, cursor }),
|
||||
);
|
||||
return page;
|
||||
},
|
||||
(row) => row.onsetAt,
|
||||
);
|
||||
|
||||
const year = await walk(search("2019", "fr"));
|
||||
expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
|
||||
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
|
||||
|
||||
const month = await walk(search("mars 2019", "fr"));
|
||||
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visits: the route without text search still searches a period", () => {
|
||||
it("a year and a month", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const instants = [
|
||||
...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.encounter.createMany({
|
||||
data: instants.map((occurredAt) => ({
|
||||
userId: OWNER_ID,
|
||||
occurredAt,
|
||||
status: "DONE" as const,
|
||||
kind: "ROUTINE" as const,
|
||||
})),
|
||||
});
|
||||
const rows = (
|
||||
await prisma.encounter.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, occurredAt: true },
|
||||
})
|
||||
).map((row) => ({ id: row.id, at: row.occurredAt }));
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const list = await get<{
|
||||
upcoming: Array<{ id: string; occurredAt: string }>;
|
||||
past: Array<{ id: string; occurredAt: string }>;
|
||||
}>(
|
||||
listEncounters as never,
|
||||
encounterPickerUrl({ ...request, cursor }),
|
||||
);
|
||||
expect(list.upcoming).toEqual([]);
|
||||
return list.past;
|
||||
},
|
||||
(row) => row.occurredAt,
|
||||
);
|
||||
|
||||
const year = await walk(search("2019", "it"));
|
||||
expect(year.pages).toBeGreaterThan(1);
|
||||
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
|
||||
|
||||
const month = await walk(search("marzo 2019", "it"));
|
||||
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents: the day-granular window is the period's calendar days", () => {
|
||||
it("a year and a month, across keyset pages", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.inboundDocument.createMany({
|
||||
data: DAYS.map((day, i) => ({
|
||||
userId: OWNER_ID,
|
||||
kind: "OTHER" as const,
|
||||
filename: `doc-${i}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 4,
|
||||
status: "STORED" as const,
|
||||
contentEncrypted: Buffer.from("test"),
|
||||
contentCodec: "v1",
|
||||
documentDate: day,
|
||||
})),
|
||||
});
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, documentDate: true },
|
||||
});
|
||||
const expected = (prefix: string) =>
|
||||
new Set(
|
||||
rows
|
||||
.filter((row) => row.documentDate!.toISOString().startsWith(prefix))
|
||||
.map((row) => row.id),
|
||||
);
|
||||
|
||||
async function walk(request: PickerPageRequest) {
|
||||
const ids = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const page: {
|
||||
documents: Array<{ id: string }>;
|
||||
nextCursor: string | null;
|
||||
} = await get(
|
||||
listDocuments as never,
|
||||
documentPickerUrl({ ...request, cursor }),
|
||||
);
|
||||
pages += 1;
|
||||
for (const doc of page.documents) {
|
||||
expect(ids.has(doc.id)).toBe(false);
|
||||
ids.add(doc.id);
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) break;
|
||||
}
|
||||
return { ids, pages };
|
||||
}
|
||||
|
||||
const year = await walk(search("2019", "es"));
|
||||
expect(year.pages).toBeGreaterThan(1);
|
||||
expect(year.ids).toEqual(expected("2019-"));
|
||||
expect(year.ids.size).toBe(365);
|
||||
|
||||
const month = await walk(search("marzo de 2019", "es"));
|
||||
expect(month.ids).toEqual(expected("2019-03-"));
|
||||
expect(month.ids.size).toBe(31);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user