feat: implement biomarker models, reference rules, and data seeding logic
This commit is contained in:
46
backend/seed.yaml
Normal file
46
backend/seed.yaml
Normal 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
1803
backend/seed_biomarkers.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,11 @@ pub struct Args {
|
|||||||
pub enum Command {
|
pub enum Command {
|
||||||
Serve(ServeCommand),
|
Serve(ServeCommand),
|
||||||
Migrate(MigrateCommand),
|
Migrate(MigrateCommand),
|
||||||
|
Seed(SeedCommand),
|
||||||
Version(VersionCommand),
|
Version(VersionCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the server (runs migrations first)
|
/// Start the server (runs migrations and seed first)
|
||||||
#[derive(FromArgs)]
|
#[derive(FromArgs)]
|
||||||
#[argh(subcommand, name = "serve")]
|
#[argh(subcommand, name = "serve")]
|
||||||
pub struct ServeCommand {}
|
pub struct ServeCommand {}
|
||||||
@@ -27,6 +28,11 @@ pub struct ServeCommand {}
|
|||||||
#[argh(subcommand, name = "migrate")]
|
#[argh(subcommand, name = "migrate")]
|
||||||
pub struct MigrateCommand {}
|
pub struct MigrateCommand {}
|
||||||
|
|
||||||
|
/// Sync seed data from seed.yaml
|
||||||
|
#[derive(FromArgs)]
|
||||||
|
#[argh(subcommand, name = "seed")]
|
||||||
|
pub struct SeedCommand {}
|
||||||
|
|
||||||
/// Show version information
|
/// Show version information
|
||||||
#[derive(FromArgs)]
|
#[derive(FromArgs)]
|
||||||
#[argh(subcommand, name = "version")]
|
#[argh(subcommand, name = "version")]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S
|
|||||||
use sea_orm::sea_query::SqliteQueryBuilder;
|
use sea_orm::sea_query::SqliteQueryBuilder;
|
||||||
|
|
||||||
use crate::config::Config;
|
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};
|
use crate::models::user::{role, session, user};
|
||||||
|
|
||||||
/// Connect to the SQLite database.
|
/// 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)
|
// Create table statements (order matters for foreign keys)
|
||||||
let statements = vec![
|
let statements = vec![
|
||||||
schema.create_table_from_entity(role::Entity), // roles first
|
schema.create_table_from_entity(role::Entity),
|
||||||
schema.create_table_from_entity(user::Entity), // users references roles
|
schema.create_table_from_entity(user::Entity),
|
||||||
schema.create_table_from_entity(session::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),
|
schema.create_table_from_entity(biomarker_entry::Entity),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod cli;
|
|||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod seed;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -28,6 +29,22 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
db::run_migrations(&db).await?;
|
db::run_migrations(&db).await?;
|
||||||
tracing::info!("Migrations complete.");
|
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(_) => {
|
Command::Serve(_) => {
|
||||||
let config = config::Config::load("config.yaml")?;
|
let config = config::Config::load("config.yaml")?;
|
||||||
init_logging(&config);
|
init_logging(&config);
|
||||||
@@ -38,8 +55,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
db::run_migrations(&db).await?;
|
db::run_migrations(&db).await?;
|
||||||
tracing::info!("Migrations complete.");
|
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
|
// Start server
|
||||||
tracing::info!("Starting zhealth-backend...");
|
tracing::info!("Starting zhealth...");
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/health", get(health_check));
|
.route("/health", get(health_check));
|
||||||
|
|||||||
66
backend/src/models/bio/biomarker.rs
Normal file
66
backend/src/models/bio/biomarker.rs
Normal 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 {}
|
||||||
32
backend/src/models/bio/biomarker_category.rs
Normal file
32
backend/src/models/bio/biomarker_category.rs
Normal 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 {}
|
||||||
@@ -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 sea_orm::entity::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -6,18 +7,22 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
#[sea_orm(table_name = "biomarker_entries")]
|
#[sea_orm(table_name = "biomarker_entries")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
/// Foreign key to biomarkers table (part of composite PK)
|
||||||
pub id: i32,
|
#[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 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
|
/// The measured value
|
||||||
pub value: f64,
|
pub value: f64,
|
||||||
|
|
||||||
/// Date when the measurement was taken
|
/// Optional notes about this measurement
|
||||||
pub measured_at: Date,
|
|
||||||
|
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
|
||||||
@@ -26,19 +31,25 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::biomarker::Entity",
|
||||||
|
from = "Column::BiomarkerId",
|
||||||
|
to = "super::biomarker::Column::Id"
|
||||||
|
)]
|
||||||
|
Biomarker,
|
||||||
|
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "crate::models::user::user::Entity",
|
belongs_to = "crate::models::user::user::Entity",
|
||||||
from = "Column::UserId",
|
from = "Column::UserId",
|
||||||
to = "crate::models::user::user::Column::Id"
|
to = "crate::models::user::user::Column::Id"
|
||||||
)]
|
)]
|
||||||
User,
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
#[sea_orm(
|
impl Related<super::biomarker::Entity> for Entity {
|
||||||
belongs_to = "super::biomarker_type::Entity",
|
fn to() -> RelationDef {
|
||||||
from = "Column::BiomarkerTypeId",
|
Relation::Biomarker.def()
|
||||||
to = "super::biomarker_type::Column::Id"
|
}
|
||||||
)]
|
|
||||||
BiomarkerType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<crate::models::user::user::Entity> for Entity {
|
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 {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|||||||
72
backend/src/models/bio/biomarker_reference_rule.rs
Normal file
72
backend/src/models/bio/biomarker_reference_rule.rs
Normal 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 {}
|
||||||
@@ -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 {}
|
|
||||||
@@ -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_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_entry::Entity as BiomarkerEntry;
|
||||||
pub use biomarker_type::Entity as BiomarkerType;
|
pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule;
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
pub mod bio;
|
pub mod bio;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
pub use bio::{BiomarkerEntry, BiomarkerType};
|
pub use bio::{Biomarker, BiomarkerCategory, BiomarkerEntry};
|
||||||
pub use user::{Session, User};
|
pub use user::{Role, Session, User};
|
||||||
|
|||||||
452
backend/src/seed.rs
Normal file
452
backend/src/seed.rs
Normal 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user