feat: implement biomarker models, reference rules, and data seeding logic

This commit is contained in:
2025-12-19 13:37:55 +05:30
parent 2b02470957
commit baccbee706
13 changed files with 2546 additions and 73 deletions

46
backend/seed.yaml Normal file
View File

@@ -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

1803
backend/seed_biomarkers.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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")]

View File

@@ -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),
];

View File

@@ -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));

View File

@@ -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<String>,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
}
#[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<super::biomarker_category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl Related<super::biomarker_entry::Entity> for Entity {
fn to() -> RelationDef {
Relation::Entries.def()
}
}
impl Related<super::biomarker_reference_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::ReferenceRules.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::biomarker::Entity")]
Biomarkers,
}
impl Related<super::biomarker::Entity> for Entity {
fn to() -> RelationDef {
Relation::Biomarkers.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
@@ -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<super::biomarker::Entity> for Entity {
fn to() -> RelationDef {
Relation::Biomarker.def()
}
}
impl Related<crate::models::user::user::Entity> for Entity {
@@ -47,10 +58,4 @@ impl Related<crate::models::user::user::Entity> for Entity {
}
}
impl Related<super::biomarker_type::Entity> for Entity {
fn to() -> RelationDef {
Relation::BiomarkerType.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<i32>,
/// Age range upper bound in years (NULL = no upper bound)
pub age_max: Option<i32>,
/// Time of day for diurnal variation tests (e.g., "08:00", "16:00")
pub time_of_day: Option<String>,
/// Life stage: "prepubertal", "adult", "follicular", "luteal", "postmenopausal", "pregnancy"
pub life_stage: Option<String>,
/// Value range lower bound (NULL = no lower bound, e.g., "<15")
pub value_min: Option<f64>,
/// Value range upper bound (NULL = no upper bound, e.g., ">=90")
pub value_max: Option<f64>,
/// Expected value for qualitative tests: "ABSENT", "NEGATIVE", "PALE YELLOW"
pub expected_value: Option<String>,
/// 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<super::biomarker::Entity> for Entity {
fn to() -> RelationDef {
Relation::Biomarker.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<f64>,
/// Upper bound of normal reference range
pub reference_max: Option<f64>,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
Entries,
}
impl Related<super::biomarker_entry::Entity> for Entity {
fn to() -> RelationDef {
Relation::Entries.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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;

View File

@@ -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};

452
backend/src/seed.rs Normal file
View File

@@ -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<RoleSeed>,
pub biomarker_categories: Vec<BiomarkerCategorySeed>,
}
#[derive(Debug, Deserialize)]
pub struct BiomarkerSeedData {
pub biomarkers: Vec<BiomarkerSeed>,
}
#[derive(Debug, Deserialize)]
pub struct RoleSeed {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BiomarkerCategorySeed {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BiomarkerSeed {
pub name: String,
pub test_category: String,
pub category: String,
pub unit: String,
pub methodology: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub reference: Option<Value>,
#[serde(default)]
pub scale: Option<Value>,
#[serde(default)]
pub scale_risk: Option<Value>,
#[serde(default)]
pub scale_diabetic_control: Option<Value>,
}
impl SeedData {
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let content = fs::read_to_string(path)?;
let data: SeedData = serde_yaml::from_str(&content)?;
Ok(data)
}
}
impl BiomarkerSeedData {
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
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<RuleData>,
}
#[derive(Debug, Clone)]
struct RuleData {
rule_type: String,
sex: String,
age_min: Option<i32>,
age_max: Option<i32>,
time_of_day: Option<String>,
life_stage: Option<String>,
value_min: Option<f64>,
value_max: Option<f64>,
expected_value: Option<String>,
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<f64>, val_max: Option<f64>, 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(())
}