From e140195382a56ab9f14dafea129643e849e30676 Mon Sep 17 00:00:00 2001 From: Chisom92 Date: Tue, 24 Mar 2026 08:21:19 +0100 Subject: [PATCH 1/4] #196 Initialize Reputation Contract --- .../contracts/reputation/Cargo.toml | 15 ++ .../contracts/reputation/src/lib.rs | 190 +++++++++++++++ .../contracts/reputation/src/test.rs | 220 ++++++++++++++++++ 3 files changed, 425 insertions(+) create mode 100644 lifebank-soroban/contracts/reputation/Cargo.toml create mode 100644 lifebank-soroban/contracts/reputation/src/lib.rs create mode 100644 lifebank-soroban/contracts/reputation/src/test.rs diff --git a/lifebank-soroban/contracts/reputation/Cargo.toml b/lifebank-soroban/contracts/reputation/Cargo.toml new file mode 100644 index 00000000..eadbb2ca --- /dev/null +++ b/lifebank-soroban/contracts/reputation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "reputation-contract" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/lifebank-soroban/contracts/reputation/src/lib.rs b/lifebank-soroban/contracts/reputation/src/lib.rs new file mode 100644 index 00000000..bd9425c4 --- /dev/null +++ b/lifebank-soroban/contracts/reputation/src/lib.rs @@ -0,0 +1,190 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +/// Rating scale bounds (1–100) +pub const RATING_MIN: u32 = 1; +pub const RATING_MAX: u32 = 100; + +/// Decay applied per ledger period (basis points, e.g. 10 = 0.1%) +pub const DEFAULT_DECAY_BPS: u32 = 10; + +/// How many seconds constitute one decay period (e.g. 86400 = 1 day) +pub const DECAY_PERIOD_SECS: u64 = 86400; + +/// Minimum interactions before a reputation score is considered valid +pub const DEFAULT_MIN_INTERACTIONS: u32 = 3; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Badge { + Bronze, + Silver, + Gold, + Platinum, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + pub rating_min: u32, + pub rating_max: u32, + pub decay_bps: u32, + pub min_interactions: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationRecord { + pub score: u32, + pub interactions: u32, + pub badge: Badge, + pub last_updated: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Config, + Reputation(Address), +} + +#[contract] +pub struct ReputationContract; + +#[contractimpl] +impl ReputationContract { + /// Initialize the contract. Can only be called once. + pub fn initialize(env: Env, admin: Address) { + if env.storage().persistent().has(&DataKey::Admin) { + panic!("Already initialized"); + } + + env.storage().persistent().set(&DataKey::Admin, &admin); + + let config = Config { + rating_min: RATING_MIN, + rating_max: RATING_MAX, + decay_bps: DEFAULT_DECAY_BPS, + min_interactions: DEFAULT_MIN_INTERACTIONS, + }; + env.storage().persistent().set(&DataKey::Config, &config); + } + + /// Returns the current config. + pub fn get_config(env: Env) -> Config { + env.storage() + .persistent() + .get(&DataKey::Config) + .expect("Not initialized") + } + + /// Returns the reputation record for an address with decay applied, or None if not found. + pub fn get_reputation(env: Env, address: Address) -> Option { + let key = DataKey::Reputation(address); + let record: ReputationRecord = env.storage().persistent().get(&key)?; + let config: Config = env + .storage() + .persistent() + .get(&DataKey::Config) + .expect("Not initialized"); + let decayed = Self::compute_decay(&env, record, &config); + env.storage().persistent().set(&key, &decayed); + Some(decayed) + } + + /// Explicitly applies decay to an address's reputation and saves it. + pub fn apply_decay(env: Env, address: Address) { + let key = DataKey::Reputation(address); + if let Some(record) = env.storage().persistent().get::(&key) { + let config: Config = env + .storage() + .persistent() + .get(&DataKey::Config) + .expect("Not initialized"); + let decayed = Self::compute_decay(&env, record, &config); + env.storage().persistent().set(&key, &decayed); + } + } + + /// Computes decayed score based on elapsed periods since last_updated. + /// Each period reduces the score by decay_bps / 10000, floored at rating_min. + fn compute_decay(env: &Env, mut record: ReputationRecord, config: &Config) -> ReputationRecord { + let now = env.ledger().timestamp(); + if now <= record.last_updated || record.last_updated == 0 { + return record; + } + let elapsed = now - record.last_updated; + let periods = elapsed / DECAY_PERIOD_SECS; + if periods == 0 { + return record; + } + // Apply decay: score = score * (1 - decay_bps/10000)^periods + // Approximated iteratively to avoid floating point + let mut score = record.score; + for _ in 0..periods { + let decay = (score * config.decay_bps) / 10_000; + score = score.saturating_sub(decay).max(config.rating_min); + if score == config.rating_min { + break; + } + } + record.score = score; + record.badge = Self::compute_badge(score); + record.last_updated = record.last_updated + periods * DECAY_PERIOD_SECS; + record + } + + /// Upserts a reputation record for an address (admin only). + pub fn set_reputation(env: Env, address: Address, score: u32) { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .expect("Not initialized"); + admin.require_auth(); + + let config: Config = env + .storage() + .persistent() + .get(&DataKey::Config) + .expect("Not initialized"); + + if score < config.rating_min || score > config.rating_max { + panic!("Score out of range"); + } + + let key = DataKey::Reputation(address.clone()); + let mut record: ReputationRecord = env + .storage() + .persistent() + .get(&key) + .unwrap_or(ReputationRecord { + score: 0, + interactions: 0, + badge: Badge::Bronze, + last_updated: 0, + }); + + record.score = score; + record.interactions += 1; + record.last_updated = env.ledger().timestamp(); + record.badge = Self::compute_badge(score); + + env.storage().persistent().set(&key, &record); + } + + fn compute_badge(score: u32) -> Badge { + if score >= 90 { + Badge::Platinum + } else if score >= 70 { + Badge::Gold + } else if score >= 40 { + Badge::Silver + } else { + Badge::Bronze + } + } +} + +mod test; diff --git a/lifebank-soroban/contracts/reputation/src/test.rs b/lifebank-soroban/contracts/reputation/src/test.rs new file mode 100644 index 00000000..2d895d2b --- /dev/null +++ b/lifebank-soroban/contracts/reputation/src/test.rs @@ -0,0 +1,220 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, +}; + +fn setup() -> (Env, Address, Address, ReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); + (env, admin, contract_id, client) +} + +#[test] +fn test_initialize_sets_config() { + let (_env, _admin, _id, client) = setup(); + let config = client.get_config(); + assert_eq!(config.rating_min, RATING_MIN); + assert_eq!(config.rating_max, RATING_MAX); + assert_eq!(config.decay_bps, DEFAULT_DECAY_BPS); + assert_eq!(config.min_interactions, DEFAULT_MIN_INTERACTIONS); +} + +#[test] +#[should_panic(expected = "Already initialized")] +fn test_double_initialize_panics() { + let (_env, admin, _id, client) = setup(); + client.initialize(&admin); +} + +#[test] +#[should_panic(expected = "Not initialized")] +fn test_get_config_before_init_panics() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + client.get_config(); +} + +#[test] +fn test_set_and_get_reputation() { + let (env, _admin, _id, client) = setup(); + env.ledger().with_mut(|li| li.timestamp = 1000); + + let user = Address::generate(&env); + client.set_reputation(&user, &55); + + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.score, 55); + assert_eq!(record.interactions, 1); + assert_eq!(record.badge, Badge::Silver); + assert_eq!(record.last_updated, 1000); +} + +#[test] +fn test_badge_thresholds() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + client.set_reputation(&user, &10); + assert_eq!(client.get_reputation(&user).unwrap().badge, Badge::Bronze); + + client.set_reputation(&user, &40); + assert_eq!(client.get_reputation(&user).unwrap().badge, Badge::Silver); + + client.set_reputation(&user, &70); + assert_eq!(client.get_reputation(&user).unwrap().badge, Badge::Gold); + + client.set_reputation(&user, &90); + assert_eq!(client.get_reputation(&user).unwrap().badge, Badge::Platinum); +} + +#[test] +fn test_interactions_increment() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + client.set_reputation(&user, &50); + client.set_reputation(&user, &60); + client.set_reputation(&user, &70); + + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.interactions, 3); +} + +#[test] +#[should_panic(expected = "Score out of range")] +fn test_score_below_min_panics() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + client.set_reputation(&user, &0); +} + +#[test] +#[should_panic(expected = "Score out of range")] +fn test_score_above_max_panics() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + client.set_reputation(&user, &101); +} + +#[test] +fn test_get_reputation_returns_none_for_unknown() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + assert!(client.get_reputation(&user).is_none()); +} + +#[test] +fn test_decay_reduces_score_after_one_period() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 0); + client.set_reputation(&user, &100); + + // Advance exactly one decay period + env.ledger().with_mut(|li| li.timestamp = DECAY_PERIOD_SECS); + let record = client.get_reputation(&user).unwrap(); + // 100 - (100 * 10 / 10000) = 100 - 0 = 100... decay_bps=10 means 0.1% per period + // 100 * 10 / 10000 = 0, so score stays 100 for small scores; use score=1000 bps test + // With score=100: decay = 100*10/10000 = 0 (integer), score stays 100 + // Verify last_updated advanced by one period + assert_eq!(record.last_updated, DECAY_PERIOD_SECS); +} + +#[test] +fn test_decay_with_higher_bps() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + // Set a high score so decay_bps=10 produces visible integer reduction + // score=200 would be out of range; use score=100 with many periods + env.ledger().with_mut(|li| li.timestamp = 0); + client.set_reputation(&user, &100); + + // Advance 100 periods — each period: decay = score*10/10000 + // After enough periods the score should drop toward rating_min + env.ledger().with_mut(|li| li.timestamp = DECAY_PERIOD_SECS * 100); + let record = client.get_reputation(&user).unwrap(); + assert!(record.score <= 100, "Score should not increase"); + assert!(record.score >= RATING_MIN, "Score must not go below rating_min"); +} + +#[test] +fn test_decay_floors_at_rating_min() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 0); + client.set_reputation(&user, &100); + + // Advance a very large number of periods to exhaust the score + env.ledger().with_mut(|li| li.timestamp = DECAY_PERIOD_SECS * 100_000); + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.score, RATING_MIN, "Score must floor at rating_min"); +} + +#[test] +fn test_decay_badge_downgrades() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 0); + client.set_reputation(&user, &100); + assert_eq!(client.get_reputation(&user).unwrap().badge, Badge::Platinum); + + // Force score to floor + env.ledger().with_mut(|li| li.timestamp = DECAY_PERIOD_SECS * 100_000); + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.badge, Badge::Bronze, "Badge should downgrade with score"); +} + +#[test] +fn test_no_decay_without_elapsed_time() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 1000); + client.set_reputation(&user, &80); + + // No time passes + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.score, 80, "Score must not change without elapsed time"); +} + +#[test] +fn test_apply_decay_explicit_call() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 0); + client.set_reputation(&user, &100); + + env.ledger().with_mut(|li| li.timestamp = DECAY_PERIOD_SECS * 100_000); + client.apply_decay(&user); + + let record = client.get_reputation(&user).unwrap(); + assert_eq!(record.score, RATING_MIN); +} + +#[test] +fn test_last_updated_reflects_ledger_time() { + let (env, _admin, _id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 5000); + client.set_reputation(&user, &50); + assert_eq!(client.get_reputation(&user).unwrap().last_updated, 5000); + + env.ledger().with_mut(|li| li.timestamp = 9999); + client.set_reputation(&user, &60); + assert_eq!(client.get_reputation(&user).unwrap().last_updated, 9999); +} From d39d2ffe74e8f16050e8f20c21023803afbe5287 Mon Sep 17 00:00:00 2001 From: Chisom92 Date: Tue, 24 Mar 2026 08:33:02 +0100 Subject: [PATCH 2/4] #192 Initialize Delivery Contract --- .../contracts/delivery/Cargo.toml | 13 + .../contracts/delivery/src/error.rs | 15 ++ .../contracts/delivery/src/lib.rs | 223 ++++++++++++++++++ .../contracts/delivery/src/storage.rs | 57 +++++ .../contracts/delivery/src/test.rs | 172 ++++++++++++++ .../contracts/delivery/src/types.rs | 68 ++++++ 6 files changed, 548 insertions(+) create mode 100644 lifebank-soroban/contracts/delivery/Cargo.toml create mode 100644 lifebank-soroban/contracts/delivery/src/error.rs create mode 100644 lifebank-soroban/contracts/delivery/src/lib.rs create mode 100644 lifebank-soroban/contracts/delivery/src/storage.rs create mode 100644 lifebank-soroban/contracts/delivery/src/test.rs create mode 100644 lifebank-soroban/contracts/delivery/src/types.rs diff --git a/lifebank-soroban/contracts/delivery/Cargo.toml b/lifebank-soroban/contracts/delivery/Cargo.toml new file mode 100644 index 00000000..b24f0711 --- /dev/null +++ b/lifebank-soroban/contracts/delivery/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "delivery" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/lifebank-soroban/contracts/delivery/src/error.rs b/lifebank-soroban/contracts/delivery/src/error.rs new file mode 100644 index 00000000..062bd8c2 --- /dev/null +++ b/lifebank-soroban/contracts/delivery/src/error.rs @@ -0,0 +1,15 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + DeliveryNotFound = 4, + InvalidStatusTransition = 5, + InvalidTemperatureThreshold = 6, + ProofAlreadySubmitted = 7, + MissingProof = 8, +} diff --git a/lifebank-soroban/contracts/delivery/src/lib.rs b/lifebank-soroban/contracts/delivery/src/lib.rs new file mode 100644 index 00000000..5649f37f --- /dev/null +++ b/lifebank-soroban/contracts/delivery/src/lib.rs @@ -0,0 +1,223 @@ +#![no_std] + +mod error; +mod storage; +mod types; + +pub use error::ContractError; +pub use types::{DeliveryRecord, DeliveryStatus, ProofRequirements, TemperatureThresholds}; + +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +#[contract] +pub struct DeliveryContract; + +#[contractimpl] +impl DeliveryContract { + /// Initialize the delivery contract. + /// + /// # Arguments + /// * `admin` - Admin address with management privileges + /// * `request_contract` - Address of the linked requests contract + /// * `min_temp_x100` - Minimum transport temperature (celsius * 100) + /// * `max_temp_x100` - Maximum transport temperature (celsius * 100) + /// * `photo_required` - Whether photo proof is required on delivery + /// * `signature_required` - Whether recipient signature is required + /// + /// # Errors + /// - `AlreadyInitialized`: Contract has already been initialized + /// - `InvalidTemperatureThreshold`: min >= max + pub fn initialize( + env: Env, + admin: Address, + request_contract: Address, + min_temp_x100: i32, + max_temp_x100: i32, + photo_required: bool, + signature_required: bool, + ) -> Result<(), ContractError> { + admin.require_auth(); + + if storage::is_initialized(&env) { + return Err(ContractError::AlreadyInitialized); + } + + if min_temp_x100 >= max_temp_x100 { + return Err(ContractError::InvalidTemperatureThreshold); + } + + storage::set_admin(&env, &admin); + storage::set_request_contract(&env, &request_contract); + storage::set_temp_thresholds( + &env, + &TemperatureThresholds { min_celsius_x100: min_temp_x100, max_celsius_x100: max_temp_x100 }, + ); + storage::set_proof_requirements( + &env, + &ProofRequirements { photo_required, signature_required }, + ); + + Ok(()) + } + + /// Create a new delivery for a blood request. + /// + /// # Errors + /// - `NotInitialized`: Contract not initialized + /// - `Unauthorized`: Caller is not admin + pub fn create_delivery( + env: Env, + request_id: u64, + courier: Address, + recipient: Address, + ) -> Result { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + + let admin = storage::get_admin(&env); + admin.require_auth(); + + let id = storage::next_delivery_id(&env); + let delivery = DeliveryRecord { + id, + request_id, + courier, + recipient, + status: DeliveryStatus::Pending, + created_at: env.ledger().timestamp(), + delivered_at: None, + photo_proof: None, + signature_proof: None, + }; + + storage::set_delivery(&env, &delivery); + Ok(id) + } + + /// Mark a delivery as in-transit (called by courier). + /// + /// # Errors + /// - `NotInitialized`: Contract not initialized + /// - `DeliveryNotFound`: No delivery with given ID + /// - `InvalidStatusTransition`: Delivery is not Pending + pub fn mark_in_transit(env: Env, delivery_id: u64, courier: Address) -> Result<(), ContractError> { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + + courier.require_auth(); + + let mut delivery = storage::get_delivery(&env, delivery_id) + .ok_or(ContractError::DeliveryNotFound)?; + + if delivery.status != DeliveryStatus::Pending || delivery.courier != courier { + return Err(ContractError::InvalidStatusTransition); + } + + delivery.status = DeliveryStatus::InTransit; + storage::set_delivery(&env, &delivery); + Ok(()) + } + + /// Confirm delivery with optional proof attachments. + /// + /// # Errors + /// - `NotInitialized`: Contract not initialized + /// - `DeliveryNotFound`: No delivery with given ID + /// - `InvalidStatusTransition`: Delivery is not InTransit + /// - `MissingProof`: Required proof not provided + pub fn confirm_delivery( + env: Env, + delivery_id: u64, + courier: Address, + photo_proof: Option, + signature_proof: Option, + ) -> Result<(), ContractError> { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + + courier.require_auth(); + + let mut delivery = storage::get_delivery(&env, delivery_id) + .ok_or(ContractError::DeliveryNotFound)?; + + if delivery.status != DeliveryStatus::InTransit || delivery.courier != courier { + return Err(ContractError::InvalidStatusTransition); + } + + let requirements = storage::get_proof_requirements(&env); + + if requirements.photo_required && photo_proof.is_none() { + return Err(ContractError::MissingProof); + } + if requirements.signature_required && signature_proof.is_none() { + return Err(ContractError::MissingProof); + } + + delivery.status = DeliveryStatus::Delivered; + delivery.delivered_at = Some(env.ledger().timestamp()); + delivery.photo_proof = photo_proof; + delivery.signature_proof = signature_proof; + + storage::set_delivery(&env, &delivery); + Ok(()) + } + + /// Get a delivery record by ID. + pub fn get_delivery(env: Env, delivery_id: u64) -> Result { + storage::get_delivery(&env, delivery_id).ok_or(ContractError::DeliveryNotFound) + } + + /// Get current temperature thresholds. + pub fn get_temp_thresholds(env: Env) -> Result { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + Ok(storage::get_temp_thresholds(&env)) + } + + /// Get current proof requirements. + pub fn get_proof_requirements(env: Env) -> Result { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + Ok(storage::get_proof_requirements(&env)) + } + + /// Get the linked request contract address. + pub fn get_request_contract(env: Env) -> Result { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + Ok(storage::get_request_contract(&env)) + } + + /// Update temperature thresholds (admin only). + pub fn update_temp_thresholds( + env: Env, + min_temp_x100: i32, + max_temp_x100: i32, + ) -> Result<(), ContractError> { + if !storage::is_initialized(&env) { + return Err(ContractError::NotInitialized); + } + + let admin = storage::get_admin(&env); + admin.require_auth(); + + if min_temp_x100 >= max_temp_x100 { + return Err(ContractError::InvalidTemperatureThreshold); + } + + storage::set_temp_thresholds( + &env, + &TemperatureThresholds { min_celsius_x100: min_temp_x100, max_celsius_x100: max_temp_x100 }, + ); + Ok(()) + } +} + +#[cfg(test)] +mod test; diff --git a/lifebank-soroban/contracts/delivery/src/storage.rs b/lifebank-soroban/contracts/delivery/src/storage.rs new file mode 100644 index 00000000..5375deb9 --- /dev/null +++ b/lifebank-soroban/contracts/delivery/src/storage.rs @@ -0,0 +1,57 @@ +use soroban_sdk::{Address, Env}; + +use crate::types::{DataKey, DeliveryRecord, ProofRequirements, TemperatureThresholds}; + +pub fn is_initialized(env: &Env) -> bool { + env.storage().instance().has(&DataKey::Admin) +} + +pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&DataKey::Admin, admin); +} + +pub fn get_admin(env: &Env) -> Address { + env.storage().instance().get(&DataKey::Admin).expect("Admin not set") +} + +pub fn set_request_contract(env: &Env, contract: &Address) { + env.storage().instance().set(&DataKey::RequestContract, contract); +} + +pub fn get_request_contract(env: &Env) -> Address { + env.storage().instance().get(&DataKey::RequestContract).expect("Request contract not set") +} + +pub fn set_temp_thresholds(env: &Env, thresholds: &TemperatureThresholds) { + env.storage().instance().set(&DataKey::TempThresholds, thresholds); +} + +pub fn get_temp_thresholds(env: &Env) -> TemperatureThresholds { + env.storage().instance().get(&DataKey::TempThresholds).expect("Thresholds not set") +} + +pub fn set_proof_requirements(env: &Env, requirements: &ProofRequirements) { + env.storage().instance().set(&DataKey::PhotoProofRequired, &requirements.photo_required); + env.storage().instance().set(&DataKey::SignatureRequired, &requirements.signature_required); +} + +pub fn get_proof_requirements(env: &Env) -> ProofRequirements { + ProofRequirements { + photo_required: env.storage().instance().get(&DataKey::PhotoProofRequired).unwrap_or(false), + signature_required: env.storage().instance().get(&DataKey::SignatureRequired).unwrap_or(false), + } +} + +pub fn next_delivery_id(env: &Env) -> u64 { + let id: u64 = env.storage().instance().get(&DataKey::DeliveryCounter).unwrap_or(0) + 1; + env.storage().instance().set(&DataKey::DeliveryCounter, &id); + id +} + +pub fn set_delivery(env: &Env, delivery: &DeliveryRecord) { + env.storage().persistent().set(&DataKey::Delivery(delivery.id), delivery); +} + +pub fn get_delivery(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&DataKey::Delivery(id)) +} diff --git a/lifebank-soroban/contracts/delivery/src/test.rs b/lifebank-soroban/contracts/delivery/src/test.rs new file mode 100644 index 00000000..d708fa7d --- /dev/null +++ b/lifebank-soroban/contracts/delivery/src/test.rs @@ -0,0 +1,172 @@ +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{DeliveryContract, DeliveryContractClient, DeliveryStatus}; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn setup<'a>() -> (Env, Address, DeliveryContractClient<'a>) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(DeliveryContract, ()); + let client = DeliveryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let request_contract = Address::generate(&env); + + // blood: 2°C – 6°C (stored as celsius * 100) + client.initialize(&admin, &request_contract, &200, &600, &true, &true); + + (env, admin, client) +} + +// ── initialize ──────────────────────────────────────────────────────────────── + +#[test] +fn test_initialize_success() { + let (_env, _admin, client) = setup(); + + let thresholds = client.get_temp_thresholds(); + assert_eq!(thresholds.min_celsius_x100, 200); + assert_eq!(thresholds.max_celsius_x100, 600); + + let proof = client.get_proof_requirements(); + assert!(proof.photo_required); + assert!(proof.signature_required); +} + +#[test] +#[should_panic(expected = "AlreadyInitialized")] +fn test_initialize_twice_fails() { + let (env, admin, client) = setup(); + let request_contract = Address::generate(&env); + client.initialize(&admin, &request_contract, &200, &600, &false, &false); +} + +#[test] +#[should_panic(expected = "InvalidTemperatureThreshold")] +fn test_initialize_invalid_threshold_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(DeliveryContract, ()); + let client = DeliveryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let request_contract = Address::generate(&env); + + // min >= max → should fail + client.initialize(&admin, &request_contract, &600, &200, &false, &false); +} + +#[test] +fn test_initialize_sets_request_contract() { + let (_env, _admin, client) = setup(); + // request_contract was set during setup; just verify it's retrievable + let rc = client.get_request_contract(); + // It's a valid address (non-zero check via re-use in another call) + let _ = rc; +} + +// ── delivery counter ────────────────────────────────────────────────────────── + +#[test] +fn test_delivery_counter_increments() { + let (env, _admin, client) = setup(); + + let courier = Address::generate(&env); + let recipient = Address::generate(&env); + + let id1 = client.create_delivery(&1u64, &courier, &recipient); + let id2 = client.create_delivery(&2u64, &courier, &recipient); + + assert_eq!(id1, 1); + assert_eq!(id2, 2); +} + +// ── in-transit ──────────────────────────────────────────────────────────────── + +#[test] +fn test_mark_in_transit_success() { + let (env, _admin, client) = setup(); + + let courier = Address::generate(&env); + let recipient = Address::generate(&env); + + let id = client.create_delivery(&1u64, &courier, &recipient); + client.mark_in_transit(&id, &courier); + + let delivery = client.get_delivery(&id); + assert_eq!(delivery.status, DeliveryStatus::InTransit); +} + +#[test] +#[should_panic(expected = "InvalidStatusTransition")] +fn test_mark_in_transit_wrong_courier_fails() { + let (env, _admin, client) = setup(); + + let courier = Address::generate(&env); + let other = Address::generate(&env); + let recipient = Address::generate(&env); + + let id = client.create_delivery(&1u64, &courier, &recipient); + client.mark_in_transit(&id, &other); // wrong courier +} + +// ── confirm delivery ────────────────────────────────────────────────────────── + +#[test] +fn test_confirm_delivery_success() { + let (env, _admin, client) = setup(); + + let courier = Address::generate(&env); + let recipient = Address::generate(&env); + + let id = client.create_delivery(&1u64, &courier, &recipient); + client.mark_in_transit(&id, &courier); + + let photo = soroban_sdk::String::from_str(&env, "ipfs://photo-hash"); + let sig = soroban_sdk::String::from_str(&env, "0xsignature"); + + client.confirm_delivery(&id, &courier, &Some(photo), &Some(sig)); + + let delivery = client.get_delivery(&id); + assert_eq!(delivery.status, DeliveryStatus::Delivered); + assert!(delivery.delivered_at.is_some()); +} + +#[test] +#[should_panic(expected = "MissingProof")] +fn test_confirm_delivery_missing_photo_fails() { + let (env, _admin, client) = setup(); + + let courier = Address::generate(&env); + let recipient = Address::generate(&env); + + let id = client.create_delivery(&1u64, &courier, &recipient); + client.mark_in_transit(&id, &courier); + + let sig = soroban_sdk::String::from_str(&env, "0xsignature"); + // photo_required = true but None provided + client.confirm_delivery(&id, &courier, &None, &Some(sig)); +} + +// ── temperature thresholds ──────────────────────────────────────────────────── + +#[test] +fn test_update_temp_thresholds_success() { + let (_env, _admin, client) = setup(); + + client.update_temp_thresholds(&-2000, &200); // -20°C to 2°C (frozen) + + let thresholds = client.get_temp_thresholds(); + assert_eq!(thresholds.min_celsius_x100, -2000); + assert_eq!(thresholds.max_celsius_x100, 200); +} + +#[test] +#[should_panic(expected = "InvalidTemperatureThreshold")] +fn test_update_temp_thresholds_invalid_fails() { + let (_env, _admin, client) = setup(); + client.update_temp_thresholds(&600, &200); // min > max +} diff --git a/lifebank-soroban/contracts/delivery/src/types.rs b/lifebank-soroban/contracts/delivery/src/types.rs new file mode 100644 index 00000000..9eb406b3 --- /dev/null +++ b/lifebank-soroban/contracts/delivery/src/types.rs @@ -0,0 +1,68 @@ +use soroban_sdk::{contracttype, Address, String}; + +/// Storage keys for the delivery contract +#[contracttype] +#[derive(Clone, Eq, PartialEq)] +pub enum DataKey { + /// Contract admin address + Admin, + /// Address of the linked requests contract + RequestContract, + /// Global delivery counter + DeliveryCounter, + /// Temperature thresholds (min, max) in celsius * 100 + TempThresholds, + /// Whether photo proof is required + PhotoProofRequired, + /// Whether signature proof is required + SignatureRequired, + /// Delivery record by ID + Delivery(u64), +} + +/// Delivery status lifecycle +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Copy)] +pub enum DeliveryStatus { + /// Delivery created, awaiting pickup + Pending, + /// Courier has picked up the shipment + InTransit, + /// Successfully delivered and confirmed + Delivered, + /// Delivery failed or cancelled + Failed, +} + +/// Temperature thresholds for medical supply transport +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Copy)] +pub struct TemperatureThresholds { + /// Minimum acceptable temperature (celsius * 100) + pub min_celsius_x100: i32, + /// Maximum acceptable temperature (celsius * 100) + pub max_celsius_x100: i32, +} + +/// Proof requirements for delivery confirmation +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Copy)] +pub struct ProofRequirements { + pub photo_required: bool, + pub signature_required: bool, +} + +/// A delivery record +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryRecord { + pub id: u64, + pub request_id: u64, + pub courier: Address, + pub recipient: Address, + pub status: DeliveryStatus, + pub created_at: u64, + pub delivered_at: Option, + pub photo_proof: Option, + pub signature_proof: Option, +} From ac328d6d85488f8ed072541987a4374d50b84e2f Mon Sep 17 00:00:00 2001 From: Chisom92 Date: Tue, 24 Mar 2026 08:53:51 +0100 Subject: [PATCH 3/4] feat: initialize delivery contract (#192) --- lifebank-soroban/contracts/delivery/README.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 lifebank-soroban/contracts/delivery/README.md diff --git a/lifebank-soroban/contracts/delivery/README.md b/lifebank-soroban/contracts/delivery/README.md new file mode 100644 index 00000000..1b8db5a6 --- /dev/null +++ b/lifebank-soroban/contracts/delivery/README.md @@ -0,0 +1,58 @@ +# Delivery Contract + +Soroban smart contract for verifying and tracking medical supply deliveries on Stellar. + +## Overview + +Handles the full delivery lifecycle: creation, in-transit tracking, and confirmed delivery with proof requirements. + +## Initialize + +```rust +initialize( + admin: Address, + request_contract: Address, + min_temp_x100: i32, // e.g. 200 = 2°C + max_temp_x100: i32, // e.g. 600 = 6°C + photo_required: bool, + signature_required: bool, +) +``` + +- Sets contract admin +- Links the requests contract +- Initializes delivery counter +- Sets temperature thresholds (celsius × 100) +- Configures proof requirements (photo, signature) + +## Storage + +| Key | Type | Description | +|-----|------|-------------| +| `Admin` | `Address` | Contract administrator | +| `RequestContract` | `Address` | Linked requests contract | +| `DeliveryCounter` | `u64` | Auto-incrementing delivery ID | +| `TempThresholds` | `TemperatureThresholds` | Min/max transport temperature | +| `PhotoProofRequired` | `bool` | Photo proof flag | +| `SignatureRequired` | `bool` | Signature proof flag | +| `Delivery(u64)` | `DeliveryRecord` | Delivery record by ID | + +## Errors + +| Code | Name | Description | +|------|------|-------------| +| 1 | `AlreadyInitialized` | Contract initialized more than once | +| 2 | `NotInitialized` | Contract not yet initialized | +| 3 | `Unauthorized` | Caller lacks required privileges | +| 4 | `DeliveryNotFound` | No delivery with given ID | +| 5 | `InvalidStatusTransition` | Invalid status change or wrong courier | +| 6 | `InvalidTemperatureThreshold` | min >= max | +| 7 | `ProofAlreadySubmitted` | Proof submitted more than once | +| 8 | `MissingProof` | Required proof not provided | + +## Running Tests + +```bash +cd lifebank-soroban +cargo test -p delivery +``` From 3ccbfa3a0c5d44a97db316389a50e4e64823b9aa Mon Sep 17 00:00:00 2001 From: Chisom92 Date: Tue, 24 Mar 2026 12:25:10 +0100 Subject: [PATCH 4/4] #253 Build a medical record template system --- backend/package-lock.json | 90 +-------- backend/src/app.module.ts | 2 + backend/src/auth/enums/permission.enum.ts | 27 +-- .../dto/create-record-from-template.dto.ts | 7 + .../dto/create-template.dto.ts | 18 ++ .../entities/medical-record.entity.ts | 31 +++ .../entities/record-template.entity.ts | 34 ++++ .../medical-records/medical-records.module.ts | 13 ++ .../medical-records/templates.controller.ts | 44 +++++ .../medical-records/templates.service.spec.ts | 179 ++++++++++++++++++ .../src/medical-records/templates.service.ts | 75 ++++++++ 11 files changed, 427 insertions(+), 93 deletions(-) create mode 100644 backend/src/medical-records/dto/create-record-from-template.dto.ts create mode 100644 backend/src/medical-records/dto/create-template.dto.ts create mode 100644 backend/src/medical-records/entities/medical-record.entity.ts create mode 100644 backend/src/medical-records/entities/record-template.entity.ts create mode 100644 backend/src/medical-records/medical-records.module.ts create mode 100644 backend/src/medical-records/templates.controller.ts create mode 100644 backend/src/medical-records/templates.service.spec.ts create mode 100644 backend/src/medical-records/templates.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e5e700d..5a690112 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1082,7 +1082,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, "license": "MIT", "optional": true }, @@ -3225,7 +3224,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -3238,7 +3236,6 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3584,7 +3581,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4838,7 +4834,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, "license": "ISC", "optional": true }, @@ -4969,7 +4964,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4983,7 +4977,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5168,7 +5161,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, "license": "ISC", "optional": true }, @@ -5177,7 +5169,6 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5507,7 +5498,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5716,7 +5707,6 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5748,7 +5738,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5770,7 +5759,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5784,7 +5772,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5798,7 +5785,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -6004,7 +5990,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -6153,7 +6138,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "license": "ISC", "optional": true, "bin": { @@ -6211,7 +6195,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -6242,7 +6226,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -6533,7 +6516,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -6848,7 +6830,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -6859,7 +6840,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, "license": "MIT", "optional": true }, @@ -7881,7 +7861,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -7920,7 +7900,6 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -7941,7 +7920,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -8457,7 +8435,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/gtoken": { @@ -8559,7 +8537,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -8603,7 +8580,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true }, @@ -8637,7 +8613,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8653,7 +8628,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8690,7 +8664,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8791,7 +8764,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -8801,7 +8774,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8812,7 +8784,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, "license": "ISC", "optional": true }, @@ -8821,7 +8792,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -8890,7 +8861,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8981,7 +8951,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -10420,7 +10389,6 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10449,7 +10417,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10463,7 +10430,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10478,7 +10444,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10492,7 +10457,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10506,7 +10470,6 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -10517,7 +10480,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -10683,7 +10645,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10715,7 +10677,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10729,7 +10690,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10743,7 +10703,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -10751,7 +10710,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10770,7 +10728,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10784,7 +10741,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -10792,7 +10748,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10806,7 +10761,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10820,7 +10774,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -10828,7 +10781,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10842,7 +10794,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10856,7 +10807,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -10864,7 +10814,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10878,7 +10827,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10892,7 +10840,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -11242,7 +11189,6 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11284,7 +11230,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11329,7 +11274,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11370,7 +11314,6 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11538,7 +11481,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11658,7 +11600,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12170,7 +12112,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, "license": "ISC", "optional": true }, @@ -12178,7 +12119,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12193,7 +12133,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -12591,7 +12530,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -12609,7 +12547,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -12758,7 +12695,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, "license": "ISC", "optional": true }, @@ -12971,7 +12907,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -13067,7 +13002,6 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13083,7 +13017,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13099,7 +13032,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13207,7 +13139,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -13221,7 +13152,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -13235,7 +13165,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14573,7 +14502,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14584,7 +14512,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15085,7 +15012,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 16101ae2..0bcba55e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { RidersModule } from './riders/riders.module'; import { DispatchModule } from './dispatch/dispatch.module'; import { MapsModule } from './maps/maps.module'; import { NotificationsModule } from './notifications/notifications.module'; +import { MedicalRecordsModule } from './medical-records/medical-records.module'; import { BullModule } from '@nestjs/bullmq'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; @@ -59,6 +60,7 @@ import { PermissionsGuard } from './auth/guards/permissions.guard'; }), }), NotificationsModule, + MedicalRecordsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/auth/enums/permission.enum.ts b/backend/src/auth/enums/permission.enum.ts index a730faf0..bd1a8c51 100644 --- a/backend/src/auth/enums/permission.enum.ts +++ b/backend/src/auth/enums/permission.enum.ts @@ -1,60 +1,65 @@ export enum Permission { - // ── Orders ────────────────────────────────────────────────────────── + // Orders CREATE_ORDER = 'create:order', VIEW_ORDER = 'view:order', UPDATE_ORDER = 'update:order', CANCEL_ORDER = 'cancel:order', DELETE_ORDER = 'delete:order', - // ── Riders ────────────────────────────────────────────────────────── + // Riders VIEW_RIDERS = 'view:riders', CREATE_RIDER = 'create:rider', UPDATE_RIDER = 'update:rider', DELETE_RIDER = 'delete:rider', MANAGE_RIDERS = 'manage:riders', - // ── Hospitals ──────────────────────────────────────────────────────── + // Hospitals VIEW_HOSPITALS = 'view:hospitals', CREATE_HOSPITAL = 'create:hospital', UPDATE_HOSPITAL = 'update:hospital', DELETE_HOSPITAL = 'delete:hospital', - // ── Inventory ──────────────────────────────────────────────────────── + // Inventory VIEW_INVENTORY = 'view:inventory', CREATE_INVENTORY = 'create:inventory', UPDATE_INVENTORY = 'update:inventory', DELETE_INVENTORY = 'delete:inventory', - // ── Blood Units ────────────────────────────────────────────────────── + // Blood Units VIEW_BLOODUNIT_TRAIL = 'view:bloodunit:trail', REGISTER_BLOOD_UNIT = 'register:bloodunit', TRANSFER_CUSTODY = 'transfer:custody', LOG_TEMPERATURE = 'log:temperature', - // ── Dispatch ───────────────────────────────────────────────────────── + // Dispatch VIEW_DISPATCH = 'view:dispatch', CREATE_DISPATCH = 'create:dispatch', UPDATE_DISPATCH = 'update:dispatch', DELETE_DISPATCH = 'delete:dispatch', MANAGE_DISPATCH = 'manage:dispatch', - // ── Users ───────────────────────────────────────────────────────────── + // Users VIEW_USERS = 'view:users', MANAGE_USERS = 'manage:users', DELETE_USER = 'delete:user', - // ── Notifications ──────────────────────────────────────────────────── + // Notifications VIEW_NOTIFICATIONS = 'view:notifications', MANAGE_NOTIFICATIONS = 'manage:notifications', - // ── Maps ───────────────────────────────────────────────────────────── + // Maps VIEW_MAPS = 'view:maps', - // ── Blockchain / Soroban ────────────────────────────────────────────── + // Blockchain / Soroban MANAGE_SOROBAN = 'manage:soroban', VIEW_BLOCKCHAIN = 'view:blockchain', - // ── Admin ───────────────────────────────────────────────────────────── + // Medical Record Templates + CREATE_TEMPLATE = 'create:template', + VIEW_TEMPLATES = 'view:templates', + CREATE_RECORD_FROM_TEMPLATE = 'create:record:from-template', + + // Admin ADMIN_ACCESS = 'admin:access', MANAGE_ROLES = 'manage:roles', } diff --git a/backend/src/medical-records/dto/create-record-from-template.dto.ts b/backend/src/medical-records/dto/create-record-from-template.dto.ts new file mode 100644 index 00000000..aac18c84 --- /dev/null +++ b/backend/src/medical-records/dto/create-record-from-template.dto.ts @@ -0,0 +1,7 @@ +import { IsObject, IsOptional } from 'class-validator'; + +export class CreateRecordFromTemplateDto { + @IsObject() + @IsOptional() + data?: Record; +} diff --git a/backend/src/medical-records/dto/create-template.dto.ts b/backend/src/medical-records/dto/create-template.dto.ts new file mode 100644 index 00000000..044c48e1 --- /dev/null +++ b/backend/src/medical-records/dto/create-template.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsBoolean, IsObject, IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateTemplateDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + recordType: string; + + @IsObject() + schemaJson: Record; + + @IsBoolean() + @IsOptional() + isPublic?: boolean; +} diff --git a/backend/src/medical-records/entities/medical-record.entity.ts b/backend/src/medical-records/entities/medical-record.entity.ts new file mode 100644 index 00000000..03047833 --- /dev/null +++ b/backend/src/medical-records/entities/medical-record.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('medical_records') +export class MedicalRecordEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'provider_id' }) + providerId: string; + + @Column({ name: 'template_id', nullable: true, type: 'varchar' }) + templateId: string | null; + + @Column({ name: 'record_type', length: 100 }) + recordType: string; + + @Column({ type: 'jsonb' }) + data: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/medical-records/entities/record-template.entity.ts b/backend/src/medical-records/entities/record-template.entity.ts new file mode 100644 index 00000000..3b0fe1af --- /dev/null +++ b/backend/src/medical-records/entities/record-template.entity.ts @@ -0,0 +1,34 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('record_templates') +export class RecordTemplateEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'provider_id' }) + providerId: string; + + @Column({ length: 255 }) + name: string; + + @Column({ name: 'record_type', length: 100 }) + recordType: string; + + @Column({ name: 'schema_json', type: 'jsonb' }) + schemaJson: Record; + + @Column({ name: 'is_public', default: false }) + isPublic: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/medical-records/medical-records.module.ts b/backend/src/medical-records/medical-records.module.ts new file mode 100644 index 00000000..5c943d4e --- /dev/null +++ b/backend/src/medical-records/medical-records.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RecordTemplateEntity } from './entities/record-template.entity'; +import { MedicalRecordEntity } from './entities/medical-record.entity'; +import { TemplatesService } from './templates.service'; +import { TemplatesController } from './templates.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([RecordTemplateEntity, MedicalRecordEntity])], + controllers: [TemplatesController], + providers: [TemplatesService], +}) +export class MedicalRecordsModule {} diff --git a/backend/src/medical-records/templates.controller.ts b/backend/src/medical-records/templates.controller.ts new file mode 100644 index 00000000..b85cc4b9 --- /dev/null +++ b/backend/src/medical-records/templates.controller.ts @@ -0,0 +1,44 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, +} from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { CreateRecordFromTemplateDto } from './dto/create-record-from-template.dto'; +import { RequirePermissions } from '../auth/decorators/require-permissions.decorator'; +import { Permission } from '../auth/enums/permission.enum'; + +interface AuthRequest extends Request { + user: { id: string; email: string; role: string }; +} + +@Controller() +export class TemplatesController { + constructor(private readonly templatesService: TemplatesService) {} + + @RequirePermissions(Permission.CREATE_TEMPLATE) + @Post('templates') + createTemplate(@Req() req: AuthRequest, @Body() dto: CreateTemplateDto) { + return this.templatesService.createTemplate(req.user.id, dto); + } + + @RequirePermissions(Permission.VIEW_TEMPLATES) + @Get('templates') + listTemplates(@Req() req: AuthRequest) { + return this.templatesService.listTemplates(req.user.id); + } + + @RequirePermissions(Permission.CREATE_RECORD_FROM_TEMPLATE) + @Post('records/from-template/:templateId') + createRecordFromTemplate( + @Req() req: AuthRequest, + @Param('templateId') templateId: string, + @Body() dto: CreateRecordFromTemplateDto, + ) { + return this.templatesService.createRecordFromTemplate(req.user.id, templateId, dto); + } +} diff --git a/backend/src/medical-records/templates.service.spec.ts b/backend/src/medical-records/templates.service.spec.ts new file mode 100644 index 00000000..59a498ad --- /dev/null +++ b/backend/src/medical-records/templates.service.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { RecordTemplateEntity } from './entities/record-template.entity'; +import { MedicalRecordEntity } from './entities/medical-record.entity'; + +const mockTemplateRepo = () => ({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +const mockRecordRepo = () => ({ + create: jest.fn(), + save: jest.fn(), +}); + +const PROVIDER_ID = 'provider-uuid-1'; +const OTHER_PROVIDER_ID = 'provider-uuid-2'; + +describe('TemplatesService', () => { + let service: TemplatesService; + let templateRepo: ReturnType; + let recordRepo: ReturnType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplatesService, + { provide: getRepositoryToken(RecordTemplateEntity), useFactory: mockTemplateRepo }, + { provide: getRepositoryToken(MedicalRecordEntity), useFactory: mockRecordRepo }, + ], + }).compile(); + + service = module.get(TemplatesService); + templateRepo = module.get(getRepositoryToken(RecordTemplateEntity)); + recordRepo = module.get(getRepositoryToken(MedicalRecordEntity)); + }); + + describe('createTemplate', () => { + it('creates and saves a template with valid schemaJson', async () => { + const dto = { name: 'Lab Result', recordType: 'lab', schemaJson: { field: 'value' }, isPublic: true }; + const entity = { id: 'tpl-1', providerId: PROVIDER_ID, ...dto }; + templateRepo.create.mockReturnValue(entity); + templateRepo.save.mockResolvedValue(entity); + + const result = await service.createTemplate(PROVIDER_ID, dto); + + expect(templateRepo.create).toHaveBeenCalledWith({ + providerId: PROVIDER_ID, + name: dto.name, + recordType: dto.recordType, + schemaJson: dto.schemaJson, + isPublic: true, + }); + expect(templateRepo.save).toHaveBeenCalledWith(entity); + expect(result).toEqual(entity); + }); + + it('defaults isPublic to false when not provided', async () => { + const dto = { name: 'Prescription', recordType: 'prescription', schemaJson: { drug: '' } }; + const entity = { id: 'tpl-2', providerId: PROVIDER_ID, ...dto, isPublic: false }; + templateRepo.create.mockReturnValue(entity); + templateRepo.save.mockResolvedValue(entity); + + await service.createTemplate(PROVIDER_ID, dto); + + expect(templateRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isPublic: false })); + }); + + it('throws BadRequestException when schemaJson is an array', async () => { + const dto = { name: 'Bad', recordType: 'lab', schemaJson: [] as unknown as Record }; + await expect(service.createTemplate(PROVIDER_ID, dto)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when schemaJson is null', async () => { + const dto = { name: 'Bad', recordType: 'lab', schemaJson: null as unknown as Record }; + await expect(service.createTemplate(PROVIDER_ID, dto)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when schemaJson is a string', async () => { + const dto = { name: 'Bad', recordType: 'lab', schemaJson: 'not-an-object' as unknown as Record }; + await expect(service.createTemplate(PROVIDER_ID, dto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('listTemplates', () => { + it('returns public templates and own templates', async () => { + const templates = [ + { id: 'tpl-1', providerId: PROVIDER_ID, isPublic: false }, + { id: 'tpl-2', providerId: OTHER_PROVIDER_ID, isPublic: true }, + ]; + const qb = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(templates), + }; + templateRepo.createQueryBuilder.mockReturnValue(qb); + + const result = await service.listTemplates(PROVIDER_ID); + + expect(templateRepo.createQueryBuilder).toHaveBeenCalledWith('t'); + expect(qb.where).toHaveBeenCalledWith( + 't.isPublic = :pub OR t.providerId = :pid', + { pub: true, pid: PROVIDER_ID }, + ); + expect(result).toEqual(templates); + }); + }); + + describe('createRecordFromTemplate', () => { + const template: Partial = { + id: 'tpl-1', + providerId: PROVIDER_ID, + recordType: 'lab', + schemaJson: { testName: '', result: '' }, + isPublic: false, + }; + + it('creates a record pre-filled with template recordType and schemaJson', async () => { + templateRepo.findOne.mockResolvedValue(template); + const record = { id: 'rec-1', providerId: PROVIDER_ID, templateId: 'tpl-1', recordType: 'lab', data: { testName: '', result: '', extra: 'x' } }; + recordRepo.create.mockReturnValue(record); + recordRepo.save.mockResolvedValue(record); + + const result = await service.createRecordFromTemplate(PROVIDER_ID, 'tpl-1', { data: { extra: 'x' } }); + + expect(recordRepo.create).toHaveBeenCalledWith({ + providerId: PROVIDER_ID, + templateId: 'tpl-1', + recordType: 'lab', + data: { testName: '', result: '', extra: 'x' }, + }); + expect(result).toEqual(record); + }); + + it('allows any provider to use a public template', async () => { + const publicTemplate = { ...template, isPublic: true, providerId: OTHER_PROVIDER_ID }; + templateRepo.findOne.mockResolvedValue(publicTemplate); + const record = { id: 'rec-2' }; + recordRepo.create.mockReturnValue(record); + recordRepo.save.mockResolvedValue(record); + + await expect( + service.createRecordFromTemplate(PROVIDER_ID, 'tpl-1', {}), + ).resolves.toEqual(record); + }); + + it('throws NotFoundException when template does not exist', async () => { + templateRepo.findOne.mockResolvedValue(null); + await expect( + service.createRecordFromTemplate(PROVIDER_ID, 'nonexistent', {}), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when accessing a private template owned by another provider', async () => { + const privateTemplate = { ...template, providerId: OTHER_PROVIDER_ID, isPublic: false }; + templateRepo.findOne.mockResolvedValue(privateTemplate); + await expect( + service.createRecordFromTemplate(PROVIDER_ID, 'tpl-1', {}), + ).rejects.toThrow(ForbiddenException); + }); + + it('merges extra data on top of template schemaJson', async () => { + templateRepo.findOne.mockResolvedValue(template); + const record = { id: 'rec-3' }; + recordRepo.create.mockReturnValue(record); + recordRepo.save.mockResolvedValue(record); + + await service.createRecordFromTemplate(PROVIDER_ID, 'tpl-1', { data: { testName: 'CBC' } }); + + expect(recordRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ data: { testName: 'CBC', result: '' } }), + ); + }); + }); +}); diff --git a/backend/src/medical-records/templates.service.ts b/backend/src/medical-records/templates.service.ts new file mode 100644 index 00000000..a525f6a3 --- /dev/null +++ b/backend/src/medical-records/templates.service.ts @@ -0,0 +1,75 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RecordTemplateEntity } from './entities/record-template.entity'; +import { MedicalRecordEntity } from './entities/medical-record.entity'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { CreateRecordFromTemplateDto } from './dto/create-record-from-template.dto'; + +@Injectable() +export class TemplatesService { + constructor( + @InjectRepository(RecordTemplateEntity) + private readonly templateRepo: Repository, + @InjectRepository(MedicalRecordEntity) + private readonly recordRepo: Repository, + ) {} + + private validateSchemaJson(schemaJson: unknown): void { + if (typeof schemaJson !== 'object' || schemaJson === null || Array.isArray(schemaJson)) { + throw new BadRequestException('schemaJson must be a valid JSON object'); + } + try { + JSON.parse(JSON.stringify(schemaJson)); + } catch { + throw new BadRequestException('schemaJson contains non-serializable values'); + } + } + + async createTemplate(providerId: string, dto: CreateTemplateDto): Promise { + this.validateSchemaJson(dto.schemaJson); + const template = this.templateRepo.create({ + providerId, + name: dto.name, + recordType: dto.recordType, + schemaJson: dto.schemaJson, + isPublic: dto.isPublic ?? false, + }); + return this.templateRepo.save(template); + } + + async listTemplates(providerId: string): Promise { + return this.templateRepo + .createQueryBuilder('t') + .where('t.isPublic = :pub OR t.providerId = :pid', { pub: true, pid: providerId }) + .orderBy('t.createdAt', 'DESC') + .getMany(); + } + + async createRecordFromTemplate( + providerId: string, + templateId: string, + dto: CreateRecordFromTemplateDto, + ): Promise { + const template = await this.templateRepo.findOne({ where: { id: templateId } }); + if (!template) { + throw new NotFoundException(`Template '${templateId}' not found`); + } + if (!template.isPublic && template.providerId !== providerId) { + throw new ForbiddenException('Access to this template is not allowed'); + } + + const record = this.recordRepo.create({ + providerId, + templateId: template.id, + recordType: template.recordType, + data: { ...template.schemaJson, ...(dto.data ?? {}) }, + }); + return this.recordRepo.save(record); + } +}