From baccbee7063a9fe30dccbce7c3df193ddcd34c7f Mon Sep 17 00:00:00 2001 From: abhishekbhakat Date: Fri, 19 Dec 2025 13:37:55 +0530 Subject: [PATCH] feat: implement biomarker models, reference rules, and data seeding logic --- backend/seed.yaml | 46 + backend/seed_biomarkers.yaml | 1803 +++++++++++++++++ backend/src/cli.rs | 8 +- backend/src/db.rs | 10 +- backend/src/main.rs | 30 +- backend/src/models/bio/biomarker.rs | 66 + backend/src/models/bio/biomarker_category.rs | 32 + backend/src/models/bio/biomarker_entry.rs | 43 +- .../models/bio/biomarker_reference_rule.rs | 72 + backend/src/models/bio/biomarker_type.rs | 43 - backend/src/models/bio/mod.rs | 10 +- backend/src/models/mod.rs | 4 +- backend/src/seed.rs | 452 +++++ 13 files changed, 2546 insertions(+), 73 deletions(-) create mode 100644 backend/seed.yaml create mode 100644 backend/seed_biomarkers.yaml create mode 100644 backend/src/models/bio/biomarker.rs create mode 100644 backend/src/models/bio/biomarker_category.rs create mode 100644 backend/src/models/bio/biomarker_reference_rule.rs delete mode 100644 backend/src/models/bio/biomarker_type.rs create mode 100644 backend/src/seed.rs diff --git a/backend/seed.yaml b/backend/seed.yaml new file mode 100644 index 0000000..f88eae6 --- /dev/null +++ b/backend/seed.yaml @@ -0,0 +1,46 @@ +# Seed Data for zhealth +# Run `zhealth seed` to sync this data to the database. + +roles: + - name: admin + description: Full access to everything + - name: user + description: Can manage their own biomarker data + - name: reader + description: Read-only access to analytics + +biomarker_categories: + - name: blood + description: Blood cell counts and hemogram markers + - name: cardiac + description: Heart and cardiovascular markers + - name: electrolytes + description: Electrolyte balance markers + - name: hormones + description: Hormone and steroid levels + - name: inflammation + description: Inflammation and arthritis markers + - name: lipid_panel + description: Cholesterol and lipid markers + - name: liver + description: Liver function markers + - name: metabolic + description: Metabolic and diabetes markers + - name: minerals + description: Essential mineral levels + - name: renal + description: Kidney function markers + - name: thyroid + description: Thyroid function markers + - name: toxicology + description: Toxic element levels + - name: urine + description: Urinalysis markers + - name: vitamins + description: Vitamin levels + - name: vitals + description: Vital signs + - name: body + description: Body measurements + - name: activity + description: Physical activity metrics diff --git a/backend/seed_biomarkers.yaml b/backend/seed_biomarkers.yaml new file mode 100644 index 0000000..95c10f3 --- /dev/null +++ b/backend/seed_biomarkers.yaml @@ -0,0 +1,1803 @@ +# Biomarker Seed Data for zhealth +# Normalized schema with structured reference ranges +# +# Reference Range Structure Types: +# 1. Simple universal: reference: { min, max } +# 2. Gender-specific: reference: { male: {min, max}, female: {min, max} } +# 3. Age-based: reference: { by_age: [ {age_min, age_max, min, max} ] } +# 4. Female phases: reference: { male: {...}, female: { menstrual: {...}, postmenopausal: {...} } } +# 5. Scale/interpretation: scale: [ {min, max, label} ] +# 6. Time-based: reference: { by_time: { "08:00": {...}, "16:00": {...} } } + +biomarkers: + # ============================================================================ + # TOXIC ELEMENTS + # ============================================================================ + - name: "ARSENIC" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "Inductively Coupled Plasma Mass Spectrometry" + reference: + min: 0.0 + max: 5.0 + + - name: "CADMIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 1.5 + + - name: "MERCURY" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 5.0 + + - name: "LEAD" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 150.0 + + - name: "CHROMIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 30.0 + + - name: "BARIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 30.0 + + - name: "COBALT" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.1 + max: 1.5 + + - name: "CAESIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 5.0 + + - name: "THALLIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 1.0 + + - name: "URANIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 1.0 + + - name: "STRONTIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 8.0 + max: 38.0 + + - name: "ANTIMONY" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.1 + max: 18.0 + + - name: "TIN" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 2.0 + + - name: "MOLYBDENUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.7 + max: 4.0 + + - name: "SILVER" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 4.0 + + - name: "VANADIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 0.8 + + - name: "BERYLLIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 0.1 + + - name: "BISMUTH" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.1 + max: 0.8 + + - name: "SELENIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 60.0 + max: 340.0 + + - name: "ALUMINIUM" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 30.0 + + - name: "NICKEL" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 0.0 + max: 15.0 + + - name: "MANGANESE" + test_category: TOXIC ELEMENTS + category: toxicology + unit: "µg/L" + methodology: "ICP - MASS SPECTROMETRY" + reference: + min: 7.1 + max: 20.0 + + # ============================================================================ + # DIABETES / METABOLIC - Scale-based interpretations + # ============================================================================ + - name: "HbA1c" + test_category: DIABETES + category: metabolic + unit: "%" + methodology: "High Performance Liquid Chromatography" + scale: + - { max: 5.7, label: "Normal" } + - { min: 5.7, max: 6.4, label: "Prediabetes" } + - { min: 6.5, label: "Diabetes" } + scale_diabetic_control: + - { max: 6.5, label: "Good Control" } + - { min: 6.5, max: 7.0, label: "Fair Control" } + - { min: 7.0, max: 8.0, label: "Unsatisfactory Control" } + - { min: 8.0, label: "Poor Control" } + + - name: "AVERAGE BLOOD GLUCOSE (ABG)" + test_category: DIABETES + category: metabolic + unit: "mg/dL" + methodology: "Calculated from HbA1c values" + scale: + - { min: 90, max: 120, label: "Good Control" } + - { min: 121, max: 150, label: "Fair Control" } + - { min: 151, max: 180, label: "Unsatisfactory Control" } + - { min: 181, label: "Poor Control" } + + - name: "FASTING BLOOD SUGAR (GLUCOSE)" + test_category: DIABETES + category: metabolic + unit: "mg/dL" + methodology: "Glucose Oxidase Peroxidase Method" + scale: + - { min: 70, max: 100, label: "Normal" } + - { min: 100, max: 125, label: "Prediabetes" } + - { min: 126, label: "Diabetes" } + + - name: "INSULIN FASTING" + test_category: DIABETES + category: metabolic + unit: "µU/mL" + methodology: "Chemiluminescent Immunoassay" + reference: + min: 1.9 + max: 23.0 + + - name: "FRUCTOSAMINE" + test_category: DIABETES + category: metabolic + unit: "µmol/L" + methodology: "Nitroblue Tetrazolium Assay" + reference: + max: 286.0 + + - name: "BLOOD KETONE (D3HB)" + test_category: DIABETES + category: metabolic + unit: "mg/dL" + methodology: "Enzymatic Kinetic Assay" + reference: + min: 0.21 + max: 2.81 + + # ============================================================================ + # INFLAMMATION + # ============================================================================ + - name: "ERYTHROCYTE SEDIMENTATION RATE (ESR)" + test_category: INFLAMMATION + category: inflammation + unit: "mm/hr" + methodology: "Modified Westergren Method" + reference: + male: + min: 0.0 + max: 15.0 + female: + min: 0.0 + max: 20.0 + + - name: "ANTI CCP (ACCP)" + test_category: ARTHRITIS + category: inflammation + unit: "U/mL" + methodology: "Chemiluminescent Microparticle Immunoassay" + scale: + - { max: 5.0, label: "Negative" } + - { min: 5.0, label: "Positive" } + + - name: "ANTI NUCLEAR ANTIBODIES (ANA)" + test_category: ARTHRITIS + category: inflammation + unit: "AU/mL" + methodology: "C.M.I.A" + scale: + - { max: 20.0, label: "Negative" } + - { min: 20.0, label: "Positive" } + + # ============================================================================ + # HEMOGRAM / COMPLETE BLOOD COUNT + # ============================================================================ + - name: "HEMOGLOBIN" + test_category: HEMOGRAM + category: blood + unit: "g/dL" + methodology: "Sodium Lauryl Sulfate Hemoglobin Method" + reference: + male: + min: 13.0 + max: 17.0 + female: + min: 12.0 + max: 15.0 + + - name: "HEMATOCRIT (PCV)" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Cumulative Pulse Height Detection" + reference: + male: + min: 40.0 + max: 50.0 + female: + min: 36.0 + max: 44.0 + + - name: "TOTAL RBC" + test_category: HEMOGRAM + category: blood + unit: "10^6/µL" + methodology: "Hydrodynamic Focusing and Electrical Impedance" + reference: + male: + min: 4.5 + max: 5.5 + female: + min: 4.0 + max: 5.0 + + - name: "MEAN CORPUSCULAR VOLUME (MCV)" + test_category: HEMOGRAM + category: blood + unit: "fL" + methodology: "Calculated from RBC and Hematocrit" + reference: + min: 83.0 + max: 101.0 + + - name: "MEAN CORPUSCULAR HEMOGLOBIN (MCH)" + test_category: HEMOGRAM + category: blood + unit: "pg" + methodology: "Calculated from Hemoglobin and RBC" + reference: + min: 27.0 + max: 32.0 + + - name: "MEAN CORP. HEMO. CONC (MCHC)" + test_category: HEMOGRAM + category: blood + unit: "g/dL" + methodology: "Calculated from Hemoglobin and Hematocrit" + reference: + min: 31.5 + max: 34.5 + + - name: "RED CELL DISTRIBUTION WIDTH - SD (RDW-SD)" + test_category: HEMOGRAM + category: blood + unit: "fL" + methodology: "Calculated from Red Cell Volume Distribution" + reference: + min: 39.0 + max: 46.0 + + - name: "RED CELL DISTRIBUTION WIDTH (RDW-CV)" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Calculated from Red Cell Volume Distribution" + reference: + min: 11.6 + max: 14.0 + + - name: "TOTAL LEUCOCYTE COUNT (WBC)" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Hydrodynamic Focusing and Flow Cytometry" + reference: + min: 4.0 + max: 10.0 + + - name: "NEUTROPHILS PERCENTAGE" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 40.0 + max: 80.0 + + - name: "LYMPHOCYTES PERCENTAGE" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 20.0 + max: 40.0 + + - name: "MONOCYTES PERCENTAGE" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 2.0 + max: 10.0 + + - name: "EOSINOPHILS PERCENTAGE" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 1.0 + max: 6.0 + + - name: "BASOPHILS PERCENTAGE" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 0.0 + max: 2.0 + + - name: "IMMATURE GRANULOCYTE PERCENTAGE (IG%)" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 0.0 + max: 0.5 + + - name: "NUCLEATED RED BLOOD CELLS %" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Flow Cytometry" + reference: + min: 0.0 + max: 5.0 + + - name: "NEUTROPHILS ABSOLUTE COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 2.0 + max: 7.0 + + - name: "LYMPHOCYTES ABSOLUTE COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 1.0 + max: 3.0 + + - name: "MONOCYTES - ABSOLUTE COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 0.2 + max: 1.0 + + - name: "BASOPHILS ABSOLUTE COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 0.02 + max: 0.1 + + - name: "EOSINOPHILS ABSOLUTE COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 0.02 + max: 0.5 + + - name: "IMMATURE GRANULOCYTES (IG)" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 0.0 + max: 0.3 + + - name: "NUCLEATED RED BLOOD CELLS" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Calculated from Total WBC and Differential Count" + reference: + min: 0.0 + max: 0.5 + + - name: "PLATELET COUNT" + test_category: HEMOGRAM + category: blood + unit: "10^3/µL" + methodology: "Hydrodynamic Focusing and Electrical Impedance" + reference: + min: 150.0 + max: 410.0 + + - name: "MEAN PLATELET VOLUME (MPV)" + test_category: HEMOGRAM + category: blood + unit: "fL" + methodology: "Calculated from Platelet Size Distribution" + reference: + min: 6.5 + max: 12.0 + + - name: "PLATELET DISTRIBUTION WIDTH (PDW)" + test_category: HEMOGRAM + category: blood + unit: "fL" + methodology: "Calculated from Platelet Size Distribution" + reference: + min: 9.6 + max: 15.2 + + - name: "PLATELET TO LARGE CELL RATIO (PLCR)" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Calculated from Platelet Size Distribution" + reference: + min: 19.7 + max: 42.4 + + - name: "PLATELETCRIT (PCT)" + test_category: HEMOGRAM + category: blood + unit: "%" + methodology: "Calculated from Platelet Count and Volume" + reference: + min: 0.19 + max: 0.39 + + # ============================================================================ + # VITAMINS - Age-based references + # ============================================================================ + - name: "VITAMIN A" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + by_age: + - { age_min: 1, age_max: 6, min: 200, max: 400 } + - { age_min: 7, age_max: 12, min: 260, max: 490 } + - { age_min: 13, age_max: 19, min: 260, max: 720 } + - { age_min: 18, min: 300, max: 800 } + + - name: "VITAMIN E" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + by_age: + - { age_max: 0.083, min: 1000, max: 3500 } # < 1 month + - { age_min: 0.083, age_max: 0.42, min: 2000, max: 6000 } # 2-5 months + - { age_min: 0.5, age_max: 1, min: 3500, max: 8000 } # 6M - 1 year + - { age_min: 2, age_max: 12, min: 5500, max: 9000 } + - { age_min: 13, min: 5500, max: 18000 } + + - name: "VITAMIN K" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 0.13 + max: 1.19 + + - name: "VITAMIN B1/THIAMIN" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 0.5 + max: 4.0 + + - name: "VITAMIN B2/RIBOFLAVIN" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 1.6 + max: 68.2 + + - name: "VITAMIN B3/NICOTINIC ACID" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + max: 5.0 + + - name: "VITAMIN B5/PANTOTHENIC" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 11.0 + max: 150.0 + + - name: "VITAMIN B6/P5P" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 5.0 + max: 50.0 + + - name: "VITAMIN B7/BIOTIN" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 0.2 + max: 3.0 + + - name: "VITAMIN B9/FOLIC ACID" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 0.2 + max: 20.0 + + - name: "VITAMIN B-12" + test_category: VITAMIN + category: vitamins + unit: "pg/mL" + methodology: "C.M.I.A" + reference: + min: 187.0 + max: 883.0 + + - name: "VITAMIN D TOTAL" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + scale: + - { max: 20, label: "Deficiency" } + - { min: 20, max: 30, label: "Insufficiency" } + - { min: 30, max: 100, label: "Sufficiency" } + - { min: 100, label: "Toxicity" } + + - name: "VITAMIN D2" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + + - name: "VITAMIN D3" + test_category: VITAMIN + category: vitamins + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + + # ============================================================================ + # STEROID HORMONES - Complex gender/phase references + # ============================================================================ + - name: "CORTISOL" + test_category: STEROID + category: hormones + unit: "µg/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + by_time: + "08:00": + min: 5.0 + max: 23.0 + "16:00": + min: 3.0 + max: 16.0 + + - name: "CORTICOSTERONE" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + by_age: + - { age_max: 18, min: 18, max: 1970 } + - { age_min: 18, min: 53, max: 1560 } + + - name: "ANDROSTENEDIONE" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + prepubertal: + max: 5 + adult: + min: 75 + max: 205 + female: + postmenopausal: + min: 82 + max: 275 + + - name: "ESTRADIOL" + test_category: STEROID + category: hormones + unit: "pg/mL" + methodology: "FULLY AUTOMATED CHEMILUMINESCENT MICROPARTICLE IMMUNOASSAY" + reference: + male: + min: 11.0 + max: 44.0 + female: + menstrual: + follicular: + min: 21.0 + max: 251.0 + midcycle: + min: 38.0 + max: 649.0 + luteal: + min: 21.0 + max: 312.0 + postmenopausal: + no_hrt: + min: 0.0 + max: 28.0 + on_hrt: + min: 0.0 + max: 144.0 + + - name: "TESTOSTERONE" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + male: + by_age: + - { age_min: 1, age_max: 5, min: 2, max: 25 } + - { age_min: 6, age_max: 9, min: 3, max: 30 } + - { age_min: 18, min: 260, max: 1000 } + female: + by_age: + - { age_min: 1, age_max: 5, min: 2, max: 10 } + - { age_min: 6, age_max: 9, min: 2, max: 20 } + - { age_min: 18, min: 15, max: 70 } + + - name: "PROGESTERONE" + test_category: STEROID + category: hormones + unit: "ng/mL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + male: + by_age: + - { age_min: 5, age_max: 9, max: 0.7 } + - { age_min: 10, age_max: 13, max: 1.2 } + - { age_min: 14, age_max: 17, max: 0.8 } + - { age_min: 18, age_max: 29, max: 0.3 } + - { age_min: 30, max: 0.2 } + female: + by_age: + - { age_min: 5, age_max: 9, max: 0.6 } + - { age_min: 10, age_max: 13, max: 10.2 } + - { age_min: 14, age_max: 17, max: 11.9 } + menstrual: + follicular: + max: 2.7 + luteal: + max: 31.4 + postmenopausal: + max: 0.2 + + - name: "17-HYDROXYPROGESTERONE" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + male: + prepubertal: + min: 3 + max: 90 + adult: + min: 27 + max: 199 + female: + prepubertal: + min: 3 + max: 90 + menstrual: + follicular: + min: 15 + max: 70 + luteal: + min: 35 + max: 290 + postmenopausal: + max: 70 + pregnancy: + min: 200 + max: 1200 + post_acth: + max: 320 + + - name: "DEHYDROEPIANDROSTERONE (DHEA)" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + male: + by_age: + - { age_min: 6, age_max: 9, min: 13, max: 187 } + - { age_min: 10, age_max: 11, min: 31, max: 205 } + - { age_min: 12, age_max: 14, min: 83, max: 258 } + - { age_min: 18, min: 180, max: 1250 } + female: + by_age: + - { age_min: 6, age_max: 9, min: 18, max: 189 } + - { age_min: 10, age_max: 11, min: 112, max: 224 } + - { age_min: 12, age_max: 14, min: 93, max: 360 } + - { age_min: 18, min: 130, max: 980 } + + - name: "DHEA - SULPHATE (DHEAS)" + test_category: STEROID + category: hormones + unit: "µg/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + male: + by_age: + - { age_min: 6, age_max: 9, min: 2.5, max: 145 } + - { age_min: 10, age_max: 11, min: 15, max: 115 } + - { age_min: 12, age_max: 17, min: 20, max: 555 } + - { age_min: 18, age_max: 30, min: 125, max: 619 } + - { age_min: 31, age_max: 50, min: 5, max: 532 } + - { age_min: 51, age_max: 60, min: 20, max: 413 } + - { age_min: 61, age_max: 83, min: 10, max: 285 } + female: + by_age: + - { age_min: 6, age_max: 9, min: 2.5, max: 140 } + - { age_min: 10, age_max: 11, min: 15, max: 260 } + - { age_min: 12, age_max: 17, min: 20, max: 535 } + - { age_min: 18, age_max: 30, min: 45, max: 380 } + - { age_min: 31, age_max: 50, min: 12, max: 379 } + postmenopausal: + min: 30 + max: 260 + + - name: "DEOXYCORTISOL" + test_category: STEROID + category: hormones + unit: "ng/dL" + methodology: "Liquid Chromatography Tandem Mass Spectrometry" + reference: + min: 20 + max: 158 + + - name: "ALPHA-1-ANTITRYPSIN (AAT)" + test_category: PROTEIN + category: metabolic + unit: "mg/dL" + methodology: "IMMUNOTURBIDIMETRY" + reference: + min: 90.0 + max: 200.0 + + # ============================================================================ + # CARDIAC / CARDIOVASCULAR MARKERS + # ============================================================================ + - name: "HOMOCYSTEINE" + test_category: CARDIAC + category: cardiac + unit: "µmol/L" + methodology: "Small Molecule Photometry Technology" + scale: + - { max: 15, label: "Normal" } + - { min: 15, max: 30, label: "Mild Hyperhomocysteinemia" } + - { min: 30, max: 100, label: "Moderate Hyperhomocysteinemia" } + - { min: 100, label: "Severe Hyperhomocysteinemia" } + + - name: "TROPONIN I" + test_category: CARDIAC + category: cardiac + unit: "pg/mL" + methodology: "C.M.I.A" + reference: + male: + max: 26.2 + female: + max: 15.6 + scale_risk: + male: + - { max: 6, label: "Low risk of future heart attack" } + - { min: 6, max: 12, label: "Moderate risk of future heart attack" } + - { min: 12, label: "Elevated risk of future heart attack" } + female: + - { max: 4, label: "Low risk of future heart attack" } + - { min: 4, max: 10, label: "Moderate risk of future heart attack" } + - { min: 10, label: "Elevated risk of future heart attack" } + + - name: "HS-CRP" + test_category: CARDIAC + category: cardiac + unit: "mg/L" + methodology: "IMMUNOTURBIDIMETRY" + scale: + - { max: 1.0, label: "Low Risk" } + - { min: 1.0, max: 3.0, label: "Average Risk" } + - { min: 3.0, max: 10.0, label: "High Risk" } + - { min: 10.0, label: "Non-Cardiac Inflammation" } + + - name: "LIPOPROTEIN (A) [Lp(a)]" + test_category: CARDIAC + category: cardiac + unit: "mg/dL" + methodology: "IMMUNOTURBIDIMETRY" + reference: + max: 30.0 + + - name: "LP-PLA2" + test_category: CARDIAC + category: cardiac + unit: "nmol/min/mL" + methodology: "ENZYMATIC ASSAY" + scale: + - { max: 225, label: "Low Risk" } + - { min: 225, label: "High Risk" } + + # ============================================================================ + # RENAL / KIDNEY MARKERS + # ============================================================================ + - name: "CYSTATIN C" + test_category: RENAL + category: renal + unit: "mg/L" + methodology: "IMMUNOTURBIDIMETRY" + reference: + by_age: + - { age_max: 60, max: 1.03 } + - { age_min: 60, max: 1.50 } + + - name: "BLOOD UREA NITROGEN (BUN)" + test_category: RENAL + category: renal + unit: "mg/dL" + methodology: "KINETIC UV ASSAY" + reference: + min: 7.94 + max: 20.07 + + - name: "UREA (CALCULATED)" + test_category: RENAL + category: renal + unit: "mg/dL" + methodology: "CALCULATED" + reference: + min: 17 + max: 43 + + - name: "CREATININE - SERUM" + test_category: RENAL + category: renal + unit: "mg/dL" + methodology: "ENZYMATIC" + reference: + male: + min: 0.72 + max: 1.18 + female: + min: 0.55 + max: 1.02 + + - name: "UREA / SR.CREATININE RATIO" + test_category: RENAL + category: renal + unit: "Ratio" + methodology: "CALCULATED" + reference: + max: 52 + + - name: "BUN / SR.CREATININE RATIO" + test_category: RENAL + category: renal + unit: "Ratio" + methodology: "CALCULATED" + reference: + min: 9 + max: 23 + + - name: "CALCIUM" + test_category: RENAL + category: renal + unit: "mg/dL" + methodology: "ARSENAZO III" + reference: + min: 8.8 + max: 10.6 + + - name: "URIC ACID" + test_category: RENAL + category: renal + unit: "mg/dL" + methodology: "URICASE/PEROXIDASE" + reference: + male: + min: 4.2 + max: 7.3 + female: + min: 2.6 + max: 6.0 + + - name: "eGFR" + test_category: RENAL + category: renal + unit: "mL/min/1.73m²" + methodology: "2021 CKD EPI EQUATION" + scale: + - { min: 90, label: "Normal" } + - { min: 60, max: 89, label: "Mild Decrease" } + - { min: 45, max: 59, label: "Mild to Moderate Decrease" } + - { min: 30, max: 44, label: "Moderate to Severe Decrease" } + - { min: 15, max: 29, label: "Severe Decrease" } + - { max: 15, label: "Kidney Failure" } + + # ============================================================================ + # LIPID PANEL + # ============================================================================ + - name: "TOTAL CHOLESTEROL" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "ENZYMATIC" + scale: + - { max: 200, label: "Desirable" } + - { min: 200, max: 239, label: "Borderline High" } + - { min: 240, label: "High" } + + - name: "HDL CHOLESTEROL - DIRECT" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "DIRECT ENZYMATIC" + scale: + - { max: 40, label: "Low" } + - { min: 60, label: "High (Optimal)" } + reference: + min: 40 + max: 60 + + - name: "LDL CHOLESTEROL - DIRECT" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "DIRECT MEASURE" + scale: + - { max: 100, label: "Optimal" } + - { min: 100, max: 129, label: "Near Optimal" } + - { min: 130, max: 159, label: "Borderline High" } + - { min: 160, max: 189, label: "High" } + - { min: 190, label: "Very High" } + + - name: "TRIGLYCERIDES" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "ENZYMATIC" + scale: + - { max: 150, label: "Normal" } + - { min: 150, max: 199, label: "Borderline High" } + - { min: 200, max: 499, label: "High" } + - { min: 500, label: "Very High" } + + - name: "VLDL CHOLESTEROL" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "CALCULATED" + reference: + min: 5 + max: 40 + + - name: "NON-HDL CHOLESTEROL" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "CALCULATED" + reference: + max: 160 + + - name: "TC / HDL CHOLESTEROL RATIO" + test_category: LIPID + category: lipid_panel + unit: "Ratio" + methodology: "CALCULATED" + reference: + min: 3.0 + max: 5.0 + + - name: "LDL / HDL RATIO" + test_category: LIPID + category: lipid_panel + unit: "Ratio" + methodology: "CALCULATED" + reference: + min: 1.5 + max: 3.5 + + - name: "HDL / LDL RATIO" + test_category: LIPID + category: lipid_panel + unit: "Ratio" + methodology: "CALCULATED" + reference: + min: 0.4 + + - name: "TRIG / HDL RATIO" + test_category: LIPID + category: lipid_panel + unit: "Ratio" + methodology: "CALCULATED" + reference: + max: 3.12 + + - name: "APOLIPOPROTEIN - A1 (APO-A1)" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "IMMUNOTURBIDIMETRY" + reference: + male: + min: 86 + max: 152 + female: + min: 94 + max: 162 + + - name: "APOLIPOPROTEIN - B (APO-B)" + test_category: LIPID + category: lipid_panel + unit: "mg/dL" + methodology: "IMMUNOTURBIDIMETRY" + reference: + male: + min: 56 + max: 145 + female: + min: 53 + max: 138 + + - name: "APO B / APO A1 RATIO" + test_category: LIPID + category: lipid_panel + unit: "Ratio" + methodology: "CALCULATED" + reference: + male: + min: 0.40 + max: 1.26 + female: + min: 0.38 + max: 1.14 + + # ============================================================================ + # IRON PROFILE + # ============================================================================ + - name: "IRON" + test_category: IRON + category: blood + unit: "µg/dL" + methodology: "FERROZINE" + reference: + male: + min: 65 + max: 175 + female: + min: 50 + max: 170 + + - name: "TOTAL IRON BINDING CAPACITY (TIBC)" + test_category: IRON + category: blood + unit: "µg/dL" + methodology: "SPECTROPHOTOMETRIC" + reference: + male: + min: 225 + max: 535 + female: + min: 215 + max: 535 + + - name: "% TRANSFERRIN SATURATION" + test_category: IRON + category: blood + unit: "%" + methodology: "CALCULATED" + reference: + min: 13 + max: 45 + + - name: "FERRITIN" + test_category: IRON + category: blood + unit: "ng/mL" + methodology: "C.M.I.A" + reference: + male: + min: 21.81 + max: 274.66 + female: + min: 4.63 + max: 204.00 + + - name: "UNSAT. IRON-BINDING CAPACITY (UIBC)" + test_category: IRON + category: blood + unit: "µg/dL" + methodology: "SPECTROPHOTOMETRIC" + reference: + min: 162 + max: 368 + + # ============================================================================ + # LIVER FUNCTION + # ============================================================================ + - name: "ALKALINE PHOSPHATASE" + test_category: LIVER + category: liver + unit: "U/L" + methodology: "MODIFIED IFCC" + reference: + min: 45 + max: 129 + + - name: "BILIRUBIN - TOTAL" + test_category: LIVER + category: liver + unit: "mg/dL" + methodology: "DIAZONIUM SALT DPD" + reference: + min: 0.3 + max: 1.2 + + - name: "BILIRUBIN - DIRECT" + test_category: LIVER + category: liver + unit: "mg/dL" + methodology: "DIAZONIUM SALT DPD" + reference: + max: 0.2 + + - name: "BILIRUBIN (INDIRECT)" + test_category: LIVER + category: liver + unit: "mg/dL" + methodology: "CALCULATED" + reference: + max: 0.9 + + - name: "GAMMA GLUTAMYL TRANSFERASE (GGT)" + test_category: LIVER + category: liver + unit: "U/L" + methodology: "MODIFIED IFCC" + reference: + max: 55 + + - name: "ASPARTATE AMINOTRANSFERASE (SGOT)" + test_category: LIVER + category: liver + unit: "U/L" + methodology: "IFCC" + reference: + max: 35 + + - name: "ALANINE TRANSAMINASE (SGPT)" + test_category: LIVER + category: liver + unit: "U/L" + methodology: "IFCC" + reference: + max: 45 + + - name: "SGOT / SGPT RATIO" + test_category: LIVER + category: liver + unit: "Ratio" + methodology: "CALCULATED" + reference: + max: 2 + + - name: "PROTEIN - TOTAL" + test_category: LIVER + category: liver + unit: "gm/dL" + methodology: "BIURET" + reference: + min: 5.7 + max: 8.2 + + - name: "ALBUMIN - SERUM" + test_category: LIVER + category: liver + unit: "gm/dL" + methodology: "BCG COLORIMETRIC" + reference: + min: 3.2 + max: 4.8 + + - name: "SERUM GLOBULIN" + test_category: LIVER + category: liver + unit: "gm/dL" + methodology: "CALCULATED" + reference: + min: 2.5 + max: 3.4 + + - name: "SERUM ALB/GLOBULIN RATIO" + test_category: LIVER + category: liver + unit: "Ratio" + methodology: "CALCULATED" + reference: + min: 0.9 + max: 2.0 + + # ============================================================================ + # ELECTROLYTES + # ============================================================================ + - name: "SODIUM" + test_category: ELECTROLYTE + category: electrolytes + unit: "mmol/L" + methodology: "ISE - INDIRECT" + reference: + min: 136 + max: 145 + + - name: "POTASSIUM" + test_category: ELECTROLYTE + category: electrolytes + unit: "mmol/L" + methodology: "ISE - INDIRECT" + reference: + min: 3.5 + max: 5.1 + + - name: "CHLORIDE" + test_category: ELECTROLYTE + category: electrolytes + unit: "mmol/L" + methodology: "ISE - INDIRECT" + reference: + min: 98 + max: 107 + + - name: "MAGNESIUM" + test_category: ELECTROLYTE + category: electrolytes + unit: "mg/dL" + methodology: "XYLIDYL BLUE" + reference: + min: 1.9 + max: 3.1 + + # ============================================================================ + # THYROID + # ============================================================================ + - name: "TOTAL TRIIODOTHYRONINE (T3)" + test_category: THYROID + category: thyroid + unit: "ng/dL" + methodology: "C.L.I.A" + reference: + min: 60 + max: 200 + + - name: "TOTAL THYROXINE (T4)" + test_category: THYROID + category: thyroid + unit: "µg/dL" + methodology: "C.L.I.A" + reference: + min: 4.5 + max: 12.0 + + - name: "TSH ULTRASENSITIVE" + test_category: THYROID + category: thyroid + unit: "µIU/mL" + methodology: "C.L.I.A" + reference: + min: 0.55 + max: 4.78 + + # ============================================================================ + # MINERALS + # ============================================================================ + - name: "SERUM COPPER" + test_category: ELEMENTS + category: minerals + unit: "µg/dL" + methodology: "3,5-DIBR-PAESA" + reference: + male: + min: 63.5 + max: 150 + female: + min: 80 + max: 155 + + - name: "SERUM ZINC" + test_category: ELEMENTS + category: minerals + unit: "µg/dL" + methodology: "NITRO-PAPS" + reference: + min: 52 + max: 286 + + # ============================================================================ + # PANCREAS + # ============================================================================ + - name: "AMYLASE" + test_category: PANCREAS + category: metabolic + unit: "U/L" + methodology: "ENZYMATIC COLORIMETRIC" + reference: + min: 28 + max: 100 + + - name: "LIPASE" + test_category: PANCREAS + category: metabolic + unit: "U/L" + methodology: "ENZYMATIC COLORIMETRIC" + reference: + min: 5.6 + max: 51.3 + + # ============================================================================ + # URINE - DIABETES SCREEN + # ============================================================================ + - name: "URINARY MICROALBUMIN" + test_category: URINE DIABETES + category: urine + unit: "µg/mL" + methodology: "IMMUNOTURBIDIMETRY" + reference: + max: 25 + + - name: "CREATININE - URINE" + test_category: URINE DIABETES + category: urine + unit: "mg/dL" + methodology: "JAFFE" + reference: + male: + min: 39 + max: 259 + female: + min: 28 + max: 217 + + - name: "URI. ALBUMIN/CREATININE RATIO" + test_category: URINE DIABETES + category: urine + unit: "µg/mg" + methodology: "CALCULATED" + reference: + max: 30 + + # ============================================================================ + # URINOGRAM - Physical & Chemical + # ============================================================================ + - name: "URINE COLOUR" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "VISUAL" + reference: + expected: "PALE YELLOW" + + - name: "URINE APPEARANCE" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "VISUAL" + reference: + expected: "CLEAR" + + - name: "URINE SPECIFIC GRAVITY" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "pKa CHANGE" + reference: + min: 1.003 + max: 1.030 + + - name: "URINE PH" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "pH INDICATOR" + reference: + min: 5 + max: 8 + + - name: "URINARY PROTEIN" + test_category: URINOGRAM + category: urine + unit: "mg/dL" + methodology: "PEI" + reference: + expected: "ABSENT" + + - name: "URINARY GLUCOSE" + test_category: URINOGRAM + category: urine + unit: "mg/dL" + methodology: "GOD-POD" + reference: + expected: "ABSENT" + + - name: "URINE KETONE" + test_category: URINOGRAM + category: urine + unit: "mg/dL" + methodology: "NITROPRUSSIDE" + reference: + expected: "ABSENT" + + - name: "URINARY BILIRUBIN" + test_category: URINOGRAM + category: urine + unit: "mg/dL" + methodology: "DIAZO COUPLING" + reference: + expected: "ABSENT" + + - name: "UROBILINOGEN" + test_category: URINOGRAM + category: urine + unit: "mg/dL" + methodology: "DIAZO COUPLING" + reference: + max: 0.2 + + - name: "BILE SALT" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "HAYS SULPHUR" + reference: + expected: "ABSENT" + + - name: "BILE PIGMENT" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "EHRLICH REACTION" + reference: + expected: "ABSENT" + + - name: "URINE BLOOD" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "PEROXIDASE REACTION" + reference: + expected: "ABSENT" + + - name: "NITRITE" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "DIAZO COUPLING" + reference: + expected: "ABSENT" + + - name: "LEUCOCYTE ESTERASE" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "ESTERASE REACTION" + reference: + expected: "ABSENT" + + # ============================================================================ + # URINOGRAM - Microscopic + # ============================================================================ + - name: "MUCUS" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + - name: "URINE RBC" + test_category: URINOGRAM + category: urine + unit: "cells/HPF" + methodology: "MICROSCOPY" + reference: + min: 0 + max: 5 + + - name: "URINARY LEUCOCYTES (PUS CELLS)" + test_category: URINOGRAM + category: urine + unit: "cells/HPF" + methodology: "MICROSCOPY" + reference: + min: 0 + max: 5 + + - name: "EPITHELIAL CELLS" + test_category: URINOGRAM + category: urine + unit: "cells/HPF" + methodology: "MICROSCOPY" + reference: + min: 0 + max: 5 + + - name: "CASTS" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + - name: "CRYSTALS" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + - name: "BACTERIA" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + - name: "YEAST" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + - name: "PARASITE" + test_category: URINOGRAM + category: urine + unit: "-" + methodology: "MICROSCOPY" + reference: + expected: "ABSENT" + + # ============================================================================ + # BODY MEASUREMENTS + # ============================================================================ + - name: "WEIGHT" + test_category: BODY + category: body + unit: "kg" + + - name: "HEIGHT" + test_category: BODY + category: body + unit: "cm" + + - name: "BMI" + test_category: BODY + category: body + unit: "kg/m²" + scale: + - { max: 18.5, label: "Underweight" } + - { min: 18.5, max: 24.9, label: "Normal" } + - { min: 25, max: 29.9, label: "Overweight" } + - { min: 30, label: "Obese" } + + # ============================================================================ + # VITALS + # ============================================================================ + - name: "HEART RATE" + test_category: VITALS + category: vitals + unit: "bpm" + reference: + min: 60 + max: 100 + + - name: "BLOOD PRESSURE SYSTOLIC" + test_category: VITALS + category: vitals + unit: "mmHg" + scale: + - { max: 120, label: "Normal" } + - { min: 120, max: 129, label: "Elevated" } + - { min: 130, max: 139, label: "High Blood Pressure Stage 1" } + - { min: 140, label: "High Blood Pressure Stage 2" } + + - name: "BLOOD PRESSURE DIASTOLIC" + test_category: VITALS + category: vitals + unit: "mmHg" + scale: + - { max: 80, label: "Normal" } + - { min: 80, max: 89, label: "High Blood Pressure Stage 1" } + - { min: 90, label: "High Blood Pressure Stage 2" } + + - name: "SPO2" + test_category: VITALS + category: vitals + unit: "%" + reference: + min: 95 + max: 100 + + - name: "BODY TEMPERATURE" + test_category: VITALS + category: vitals + unit: "°C" + reference: + min: 36.1 + max: 37.2 + + # ============================================================================ + # ACTIVITY + # ============================================================================ + - name: "STEPS" + test_category: ACTIVITY + category: activity + unit: "steps" + + - name: "CALORIES BURNED" + test_category: ACTIVITY + category: activity + unit: "kcal" diff --git a/backend/src/cli.rs b/backend/src/cli.rs index 18062a7..9e9b10e 100644 --- a/backend/src/cli.rs +++ b/backend/src/cli.rs @@ -14,10 +14,11 @@ pub struct Args { pub enum Command { Serve(ServeCommand), Migrate(MigrateCommand), + Seed(SeedCommand), Version(VersionCommand), } -/// Start the server (runs migrations first) +/// Start the server (runs migrations and seed first) #[derive(FromArgs)] #[argh(subcommand, name = "serve")] pub struct ServeCommand {} @@ -27,6 +28,11 @@ pub struct ServeCommand {} #[argh(subcommand, name = "migrate")] pub struct MigrateCommand {} +/// Sync seed data from seed.yaml +#[derive(FromArgs)] +#[argh(subcommand, name = "seed")] +pub struct SeedCommand {} + /// Show version information #[derive(FromArgs)] #[argh(subcommand, name = "version")] diff --git a/backend/src/db.rs b/backend/src/db.rs index a6ff3e3..db23b07 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S use sea_orm::sea_query::SqliteQueryBuilder; use crate::config::Config; -use crate::models::bio::{biomarker_entry, biomarker_type}; +use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule}; use crate::models::user::{role, session, user}; /// Connect to the SQLite database. @@ -26,10 +26,12 @@ pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> { // Create table statements (order matters for foreign keys) let statements = vec![ - schema.create_table_from_entity(role::Entity), // roles first - schema.create_table_from_entity(user::Entity), // users references roles + schema.create_table_from_entity(role::Entity), + schema.create_table_from_entity(user::Entity), schema.create_table_from_entity(session::Entity), - schema.create_table_from_entity(biomarker_type::Entity), + schema.create_table_from_entity(biomarker_category::Entity), + schema.create_table_from_entity(biomarker::Entity), + schema.create_table_from_entity(biomarker_reference_rule::Entity), schema.create_table_from_entity(biomarker_entry::Entity), ]; diff --git a/backend/src/main.rs b/backend/src/main.rs index eafe128..1dc4715 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod db; mod models; +mod seed; use axum::{routing::get, Router}; use std::net::SocketAddr; @@ -28,6 +29,22 @@ async fn main() -> anyhow::Result<()> { db::run_migrations(&db).await?; tracing::info!("Migrations complete."); } + Command::Seed(_) => { + let config = config::Config::load("config.yaml")?; + init_logging(&config); + + let db = db::connect(&config).await?; + + tracing::info!("Syncing seed data..."); + let seed_data = seed::SeedData::load("seed.yaml")?; + seed::sync_seed_data(&db, &seed_data).await?; + + tracing::info!("Syncing biomarker data..."); + let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?; + seed::sync_biomarker_data(&db, &biomarker_data).await?; + + tracing::info!("Seed data synced."); + } Command::Serve(_) => { let config = config::Config::load("config.yaml")?; init_logging(&config); @@ -38,8 +55,19 @@ async fn main() -> anyhow::Result<()> { db::run_migrations(&db).await?; tracing::info!("Migrations complete."); + // Sync seed data + tracing::info!("Syncing seed data..."); + let seed_data = seed::SeedData::load("seed.yaml")?; + seed::sync_seed_data(&db, &seed_data).await?; + + tracing::info!("Syncing biomarker data..."); + let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?; + seed::sync_biomarker_data(&db, &biomarker_data).await?; + + tracing::info!("Seed data synced."); + // Start server - tracing::info!("Starting zhealth-backend..."); + tracing::info!("Starting zhealth..."); let app = Router::new() .route("/", get(root)) .route("/health", get(health_check)); diff --git a/backend/src/models/bio/biomarker.rs b/backend/src/models/bio/biomarker.rs new file mode 100644 index 0000000..4627701 --- /dev/null +++ b/backend/src/models/bio/biomarker.rs @@ -0,0 +1,66 @@ +//! Biomarker entity - individual biomarker definitions. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "biomarkers")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + /// Foreign key to biomarker_categories (our internal category for analytics) + pub category_id: i32, + + /// Biomarker name: HEMOGLOBIN, LDL CHOLESTEROL, etc. + #[sea_orm(unique)] + pub name: String, + + /// Lab report category: HEMOGRAM, LIPID, LIVER, etc. + pub test_category: String, + + /// Unit of measurement: g/dL, mg/dL, etc. + pub unit: String, + + /// Testing methodology: ICP-MS, HPLC, Enzymatic, etc. + pub methodology: Option, + + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::biomarker_category::Entity", + from = "Column::CategoryId", + to = "super::biomarker_category::Column::Id" + )] + Category, + + #[sea_orm(has_many = "super::biomarker_entry::Entity")] + Entries, + + #[sea_orm(has_many = "super::biomarker_reference_rule::Entity")] + ReferenceRules, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Category.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Entries.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReferenceRules.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/biomarker_category.rs b/backend/src/models/bio/biomarker_category.rs new file mode 100644 index 0000000..2158a9b --- /dev/null +++ b/backend/src/models/bio/biomarker_category.rs @@ -0,0 +1,32 @@ +//! BiomarkerCategory entity - categories for grouping biomarkers. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "biomarker_categories")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + /// Category name: body, vitals, activity, lipid_panel, metabolic, vitamins, hormones + #[sea_orm(unique)] + pub name: String, + + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::biomarker::Entity")] + Biomarkers, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Biomarkers.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/biomarker_entry.rs b/backend/src/models/bio/biomarker_entry.rs index 0ca4969..98758f5 100644 --- a/backend/src/models/bio/biomarker_entry.rs +++ b/backend/src/models/bio/biomarker_entry.rs @@ -1,4 +1,5 @@ -//! BiomarkerEntry entity - user-logged biomarker values. +//! BiomarkerEntry entity - user biomarker measurements. +//! Uses composite primary key: (biomarker_id, user_id, measured_at) use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -6,18 +7,22 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "biomarker_entries")] pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, + /// Foreign key to biomarkers table (part of composite PK) + #[sea_orm(primary_key, auto_increment = false)] + pub biomarker_id: i32, + /// Foreign key to users table (part of composite PK) + #[sea_orm(primary_key, auto_increment = false)] pub user_id: i32, - pub biomarker_type_id: i32, + + /// Date/time of measurement (part of composite PK) + #[sea_orm(primary_key, auto_increment = false)] + pub measured_at: DateTime, /// The measured value pub value: f64, - /// Date when the measurement was taken - pub measured_at: Date, - + /// Optional notes about this measurement #[sea_orm(column_type = "Text", nullable)] pub notes: Option, @@ -26,19 +31,25 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm( + belongs_to = "super::biomarker::Entity", + from = "Column::BiomarkerId", + to = "super::biomarker::Column::Id" + )] + Biomarker, + #[sea_orm( belongs_to = "crate::models::user::user::Entity", from = "Column::UserId", to = "crate::models::user::user::Column::Id" )] User, +} - #[sea_orm( - belongs_to = "super::biomarker_type::Entity", - from = "Column::BiomarkerTypeId", - to = "super::biomarker_type::Column::Id" - )] - BiomarkerType, +impl Related for Entity { + fn to() -> RelationDef { + Relation::Biomarker.def() + } } impl Related for Entity { @@ -47,10 +58,4 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::BiomarkerType.def() - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/biomarker_reference_rule.rs b/backend/src/models/bio/biomarker_reference_rule.rs new file mode 100644 index 0000000..ab076be --- /dev/null +++ b/backend/src/models/bio/biomarker_reference_rule.rs @@ -0,0 +1,72 @@ +//! BiomarkerReferenceRule entity - flexible reference rules for biomarker interpretation. +//! Supports: ranges, scales, age-based, sex-based, time-based, and life-stage-based references. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "biomarker_reference_rules")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + /// Foreign key to biomarkers + pub biomarker_id: i32, + + /// Rule type: "range" (normal range), "scale" (interpretation scale), "expected" (qualitative) + pub rule_type: String, + + /// Sex filter: "any", "male", "female" + #[sea_orm(default_value = "any")] + pub sex: String, + + /// Age range lower bound in years (NULL = no lower bound) + pub age_min: Option, + + /// Age range upper bound in years (NULL = no upper bound) + pub age_max: Option, + + /// Time of day for diurnal variation tests (e.g., "08:00", "16:00") + pub time_of_day: Option, + + /// Life stage: "prepubertal", "adult", "follicular", "luteal", "postmenopausal", "pregnancy" + pub life_stage: Option, + + /// Value range lower bound (NULL = no lower bound, e.g., "<15") + pub value_min: Option, + + /// Value range upper bound (NULL = no upper bound, e.g., ">=90") + pub value_max: Option, + + /// Expected value for qualitative tests: "ABSENT", "NEGATIVE", "PALE YELLOW" + pub expected_value: Option, + + /// Label for this rule: "Normal", "Deficiency", "Kidney Failure", etc. + pub label: String, + + /// Severity level: 0=normal, 1=mild, 2=moderate, 3=severe, 4=critical + #[sea_orm(default_value = 0)] + pub severity: i32, + + /// Sort order for scales (1, 2, 3...) to ensure correct order + #[sea_orm(default_value = 0)] + pub sort_order: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::biomarker::Entity", + from = "Column::BiomarkerId", + to = "super::biomarker::Column::Id" + )] + Biomarker, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Biomarker.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/biomarker_type.rs b/backend/src/models/bio/biomarker_type.rs deleted file mode 100644 index beb09cb..0000000 --- a/backend/src/models/bio/biomarker_type.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! BiomarkerType entity - the knowledge base of available biomarkers. - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "biomarker_types")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - - #[sea_orm(unique)] - pub name: String, - - /// Category: lipid_panel, metabolic, vitamins, hormones, etc. - pub category: String, - - /// Unit of measurement: mg/dL, mmol/L, ng/mL, etc. - pub unit: String, - - /// Lower bound of normal reference range - pub reference_min: Option, - - /// Upper bound of normal reference range - pub reference_max: Option, - - #[sea_orm(column_type = "Text", nullable)] - pub description: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::biomarker_entry::Entity")] - Entries, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Entries.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/mod.rs b/backend/src/models/bio/mod.rs index 77fed3a..bfd7e57 100644 --- a/backend/src/models/bio/mod.rs +++ b/backend/src/models/bio/mod.rs @@ -1,7 +1,11 @@ -//! Biomarker entities for health data tracking. +//! Biomarker-related entities. +pub mod biomarker; +pub mod biomarker_category; pub mod biomarker_entry; -pub mod biomarker_type; +pub mod biomarker_reference_rule; +pub use biomarker::Entity as Biomarker; +pub use biomarker_category::Entity as BiomarkerCategory; pub use biomarker_entry::Entity as BiomarkerEntry; -pub use biomarker_type::Entity as BiomarkerType; +pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule; diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 32302da..17ec041 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -3,5 +3,5 @@ pub mod bio; pub mod user; -pub use bio::{BiomarkerEntry, BiomarkerType}; -pub use user::{Session, User}; +pub use bio::{Biomarker, BiomarkerCategory, BiomarkerEntry}; +pub use user::{Role, Session, User}; diff --git a/backend/src/seed.rs b/backend/src/seed.rs new file mode 100644 index 0000000..51c3853 --- /dev/null +++ b/backend/src/seed.rs @@ -0,0 +1,452 @@ +//! Seed data loading and syncing. + +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; +use serde::Deserialize; +use serde_yaml::Value; +use std::fs; +use std::path::Path; + +use crate::models::bio::{biomarker, biomarker_category, biomarker_reference_rule}; +use crate::models::user::role; + +// ============================================================================ +// Seed Data Structures +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct SeedData { + pub roles: Vec, + pub biomarker_categories: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BiomarkerSeedData { + pub biomarkers: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct RoleSeed { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BiomarkerCategorySeed { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BiomarkerSeed { + pub name: String, + pub test_category: String, + pub category: String, + pub unit: String, + pub methodology: Option, + pub description: Option, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub scale: Option, + #[serde(default)] + pub scale_risk: Option, + #[serde(default)] + pub scale_diabetic_control: Option, +} + +impl SeedData { + pub fn load>(path: P) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let data: SeedData = serde_yaml::from_str(&content)?; + Ok(data) + } +} + +impl BiomarkerSeedData { + pub fn load>(path: P) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let data: BiomarkerSeedData = serde_yaml::from_str(&content)?; + Ok(data) + } +} + +// ============================================================================ +// Reference Rule Builder +// ============================================================================ + +#[derive(Debug, Default)] +struct RuleBuilder { + rules: Vec, +} + +#[derive(Debug, Clone)] +struct RuleData { + rule_type: String, + sex: String, + age_min: Option, + age_max: Option, + time_of_day: Option, + life_stage: Option, + value_min: Option, + value_max: Option, + expected_value: Option, + label: String, + severity: i32, + sort_order: i32, +} + +impl RuleBuilder { + fn new() -> Self { + Self { rules: Vec::new() } + } + + /// Parse a reference/scale value and generate rules + fn parse_reference(&mut self, value: &Value, sex: &str, life_stage: Option<&str>) { + match value { + Value::Mapping(map) => { + // Check for special keys + if let Some(min) = map.get(&Value::String("min".into())) { + // Simple {min, max} structure + let max = map.get(&Value::String("max".into())); + self.add_range_rule(sex, life_stage, + min.as_f64(), + max.and_then(|v| v.as_f64()), + "Normal", 0); + return; + } + + if let Some(max_only) = map.get(&Value::String("max".into())) { + if map.len() == 1 { + // Only max specified + self.add_range_rule(sex, life_stage, None, max_only.as_f64(), "Normal", 0); + return; + } + } + + if let Some(expected) = map.get(&Value::String("expected".into())) { + // Expected value for qualitative tests + self.add_expected_rule(sex, expected.as_str().unwrap_or(""), "Normal", 0); + return; + } + + if let Some(by_age) = map.get(&Value::String("by_age".into())) { + // Age-based reference + self.parse_by_age(by_age, sex, life_stage); + return; + } + + if let Some(by_time) = map.get(&Value::String("by_time".into())) { + // Time-based reference (diurnal variation) + self.parse_by_time(by_time, sex); + return; + } + + // Check for sex-specific keys + for (key, val) in map { + if let Some(key_str) = key.as_str() { + match key_str { + "male" => self.parse_reference(val, "male", life_stage), + "female" => self.parse_reference(val, "female", life_stage), + "prepubertal" => self.parse_reference(val, sex, Some("prepubertal")), + "adult" => self.parse_reference(val, sex, Some("adult")), + "menstrual" => self.parse_menstrual(val, sex), + "postmenopausal" => self.parse_reference(val, sex, Some("postmenopausal")), + "pregnancy" => self.parse_reference(val, sex, Some("pregnancy")), + "post_acth" => self.parse_reference(val, sex, Some("post_acth")), + "no_hrt" => self.parse_reference(val, sex, Some("postmenopausal_no_hrt")), + "on_hrt" => self.parse_reference(val, sex, Some("postmenopausal_on_hrt")), + _ => {} + } + } + } + } + _ => {} + } + } + + fn parse_menstrual(&mut self, value: &Value, sex: &str) { + if let Value::Mapping(map) = value { + for (key, val) in map { + if let Some(key_str) = key.as_str() { + match key_str { + "follicular" => self.parse_reference(val, sex, Some("follicular")), + "midcycle" => self.parse_reference(val, sex, Some("midcycle")), + "luteal" => self.parse_reference(val, sex, Some("luteal")), + _ => {} + } + } + } + } + } + + fn parse_by_age(&mut self, value: &Value, sex: &str, life_stage: Option<&str>) { + if let Value::Sequence(arr) = value { + for item in arr { + if let Value::Mapping(map) = item { + let age_min = map.get(&Value::String("age_min".into())) + .and_then(|v| v.as_i64()).map(|v| v as i32); + let age_max = map.get(&Value::String("age_max".into())) + .and_then(|v| v.as_i64()).map(|v| v as i32); + let val_min = map.get(&Value::String("min".into())).and_then(|v| v.as_f64()); + let val_max = map.get(&Value::String("max".into())).and_then(|v| v.as_f64()); + + self.rules.push(RuleData { + rule_type: "range".into(), + sex: sex.into(), + age_min, + age_max, + time_of_day: None, + life_stage: life_stage.map(String::from), + value_min: val_min, + value_max: val_max, + expected_value: None, + label: "Normal".into(), + severity: 0, + sort_order: self.rules.len() as i32, + }); + } + } + } + } + + fn parse_by_time(&mut self, value: &Value, sex: &str) { + if let Value::Mapping(map) = value { + for (key, val) in map { + if let Some(time_str) = key.as_str() { + if let Value::Mapping(range_map) = val { + let val_min = range_map.get(&Value::String("min".into())).and_then(|v| v.as_f64()); + let val_max = range_map.get(&Value::String("max".into())).and_then(|v| v.as_f64()); + + self.rules.push(RuleData { + rule_type: "range".into(), + sex: sex.into(), + age_min: None, + age_max: None, + time_of_day: Some(time_str.into()), + life_stage: None, + value_min: val_min, + value_max: val_max, + expected_value: None, + label: "Normal".into(), + severity: 0, + sort_order: self.rules.len() as i32, + }); + } + } + } + } + } + + fn parse_scale(&mut self, value: &Value, sex: &str) { + if let Value::Sequence(arr) = value { + for (idx, item) in arr.iter().enumerate() { + if let Value::Mapping(map) = item { + let val_min = map.get(&Value::String("min".into())).and_then(|v| v.as_f64()); + let val_max = map.get(&Value::String("max".into())).and_then(|v| v.as_f64()); + let label = map.get(&Value::String("label".into())) + .and_then(|v| v.as_str()).unwrap_or("Unknown"); + + // Determine severity based on label keywords + let severity = Self::infer_severity(label); + + self.rules.push(RuleData { + rule_type: "scale".into(), + sex: sex.into(), + age_min: None, + age_max: None, + time_of_day: None, + life_stage: None, + value_min: val_min, + value_max: val_max, + expected_value: None, + label: label.into(), + severity, + sort_order: idx as i32, + }); + } + } + } + } + + fn add_range_rule(&mut self, sex: &str, life_stage: Option<&str>, + val_min: Option, val_max: Option, label: &str, severity: i32) { + self.rules.push(RuleData { + rule_type: "range".into(), + sex: sex.into(), + age_min: None, + age_max: None, + time_of_day: None, + life_stage: life_stage.map(String::from), + value_min: val_min, + value_max: val_max, + expected_value: None, + label: label.into(), + severity, + sort_order: self.rules.len() as i32, + }); + } + + fn add_expected_rule(&mut self, sex: &str, expected: &str, label: &str, severity: i32) { + self.rules.push(RuleData { + rule_type: "expected".into(), + sex: sex.into(), + age_min: None, + age_max: None, + time_of_day: None, + life_stage: None, + value_min: None, + value_max: None, + expected_value: Some(expected.into()), + label: label.into(), + severity, + sort_order: self.rules.len() as i32, + }); + } + + fn infer_severity(label: &str) -> i32 { + let lower = label.to_lowercase(); + if lower.contains("normal") || lower.contains("optimal") || lower.contains("good") + || lower.contains("negative") || lower.contains("sufficiency") || lower.contains("desirable") { + 0 + } else if lower.contains("mild") || lower.contains("borderline") || lower.contains("low risk") + || lower.contains("insufficiency") || lower.contains("prediabetes") || lower.contains("fair") { + 1 + } else if lower.contains("moderate") || lower.contains("average risk") || lower.contains("elevated") { + 2 + } else if lower.contains("severe") || lower.contains("high risk") || lower.contains("high") { + 3 + } else if lower.contains("critical") || lower.contains("failure") || lower.contains("diabetes") + || lower.contains("very high") || lower.contains("toxicity") || lower.contains("poor") { + 4 + } else if lower.contains("positive") { + 2 // Positive results for autoimmune tests are concerning + } else { + 1 // Default to mild concern for unknown labels + } + } +} + +// ============================================================================ +// Sync Functions +// ============================================================================ + +/// Sync seed data to the database (upsert by name). +pub async fn sync_seed_data(db: &DatabaseConnection, seed: &SeedData) -> anyhow::Result<()> { + // Sync roles + for role_seed in &seed.roles { + let existing = role::Entity::find() + .filter(role::Column::Name.eq(&role_seed.name)) + .one(db) + .await?; + + if existing.is_none() { + let new_role = role::ActiveModel { + name: Set(role_seed.name.clone()), + description: Set(role_seed.description.clone()), + ..Default::default() + }; + new_role.insert(db).await?; + tracing::info!("Inserted role: {}", role_seed.name); + } + } + + // Sync biomarker categories + for cat_seed in &seed.biomarker_categories { + let existing = biomarker_category::Entity::find() + .filter(biomarker_category::Column::Name.eq(&cat_seed.name)) + .one(db) + .await?; + + if existing.is_none() { + let new_cat = biomarker_category::ActiveModel { + name: Set(cat_seed.name.clone()), + description: Set(cat_seed.description.clone()), + ..Default::default() + }; + new_cat.insert(db).await?; + tracing::info!("Inserted category: {}", cat_seed.name); + } + } + + Ok(()) +} + +/// Sync biomarker seed data from seed_biomarkers.yaml +pub async fn sync_biomarker_data(db: &DatabaseConnection, seed: &BiomarkerSeedData) -> anyhow::Result<()> { + for bm_seed in &seed.biomarkers { + let existing = biomarker::Entity::find() + .filter(biomarker::Column::Name.eq(&bm_seed.name)) + .one(db) + .await?; + + if existing.is_some() { + continue; + } + + // Look up category + let category = biomarker_category::Entity::find() + .filter(biomarker_category::Column::Name.eq(&bm_seed.category)) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("Category not found: {}", bm_seed.category))?; + + // Insert biomarker + let new_bm = biomarker::ActiveModel { + category_id: Set(category.id), + name: Set(bm_seed.name.clone()), + test_category: Set(bm_seed.test_category.clone()), + unit: Set(bm_seed.unit.clone()), + methodology: Set(bm_seed.methodology.clone()), + description: Set(bm_seed.description.clone()), + ..Default::default() + }; + let inserted = new_bm.insert(db).await?; + tracing::info!("Inserted biomarker: {}", bm_seed.name); + + // Parse and insert reference rules + let mut builder = RuleBuilder::new(); + + if let Some(ref reference) = bm_seed.reference { + builder.parse_reference(reference, "any", None); + } + + if let Some(ref scale) = bm_seed.scale { + builder.parse_scale(scale, "any"); + } + + if let Some(ref scale_risk) = bm_seed.scale_risk { + // For sex-specific scale_risk like Troponin + if let Value::Mapping(map) = scale_risk { + if let Some(male_scale) = map.get(&Value::String("male".into())) { + builder.parse_scale(male_scale, "male"); + } + if let Some(female_scale) = map.get(&Value::String("female".into())) { + builder.parse_scale(female_scale, "female"); + } + } + } + + // Insert all generated rules + for rule in builder.rules { + let new_rule = biomarker_reference_rule::ActiveModel { + biomarker_id: Set(inserted.id), + rule_type: Set(rule.rule_type), + sex: Set(rule.sex), + age_min: Set(rule.age_min), + age_max: Set(rule.age_max), + time_of_day: Set(rule.time_of_day), + life_stage: Set(rule.life_stage), + value_min: Set(rule.value_min), + value_max: Set(rule.value_max), + expected_value: Set(rule.expected_value), + label: Set(rule.label), + severity: Set(rule.severity), + sort_order: Set(rule.sort_order), + ..Default::default() + }; + new_rule.insert(db).await?; + } + } + + Ok(()) +}