init projectmycelium
This commit is contained in:
172
src/services/auto_topup.rs
Normal file
172
src/services/auto_topup.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Auto top-up service for automatic credit purchasing
|
||||
//! Follows the established builder pattern for consistent API design
|
||||
|
||||
use crate::models::user::Transaction;
|
||||
use crate::services::currency::CurrencyService;
|
||||
use crate::services::user_persistence::{UserPersistence, AutoTopUpSettings};
|
||||
use actix_session::Session;
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AutoTopUpService {
|
||||
currency_service: CurrencyService,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AutoTopUpServiceBuilder {
|
||||
currency_service: Option<CurrencyService>,
|
||||
}
|
||||
|
||||
impl AutoTopUpServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn currency_service(mut self, service: CurrencyService) -> Self {
|
||||
self.currency_service = Some(service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<AutoTopUpService, String> {
|
||||
let currency_service = self.currency_service
|
||||
.unwrap_or_else(|| CurrencyService::new());
|
||||
|
||||
Ok(AutoTopUpService {
|
||||
currency_service,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoTopUpService {
|
||||
pub fn builder() -> AutoTopUpServiceBuilder {
|
||||
AutoTopUpServiceBuilder::new()
|
||||
}
|
||||
|
||||
pub async fn check_and_trigger_topup(
|
||||
&self,
|
||||
session: &Session,
|
||||
_required_amount: Decimal,
|
||||
) -> Result<bool, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
// IMPORTANT: Load or create data with the correct user_email set.
|
||||
// Using unwrap_or_default() would produce an empty user_email and save to user_data/.json
|
||||
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(&user_email);
|
||||
|
||||
// Check if auto top-up is enabled
|
||||
let auto_topup_settings = match &persistent_data.auto_topup_settings {
|
||||
Some(settings) if settings.enabled => settings.clone(),
|
||||
_ => return Ok(false), // Auto top-up not enabled
|
||||
};
|
||||
|
||||
// Check if balance is below threshold
|
||||
if persistent_data.wallet_balance_usd >= auto_topup_settings.threshold_amount_usd {
|
||||
return Ok(false); // Balance is sufficient
|
||||
}
|
||||
|
||||
// Execute auto top-up
|
||||
let transaction_id = Uuid::new_v4().to_string();
|
||||
persistent_data.wallet_balance_usd += auto_topup_settings.topup_amount_usd;
|
||||
|
||||
// Create transaction record
|
||||
let transaction = Transaction {
|
||||
id: transaction_id.clone(),
|
||||
user_id: user_email.clone(),
|
||||
transaction_type: crate::models::user::TransactionType::AutoTopUp {
|
||||
amount_usd: auto_topup_settings.topup_amount_usd,
|
||||
trigger_balance: auto_topup_settings.threshold_amount_usd,
|
||||
},
|
||||
amount: auto_topup_settings.topup_amount_usd,
|
||||
currency: Some("USD".to_string()),
|
||||
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
||||
amount_usd: Some(auto_topup_settings.topup_amount_usd),
|
||||
description: Some(format!("Auto top-up of {} USD", auto_topup_settings.topup_amount_usd)),
|
||||
reference_id: Some(format!("auto-topup-{}", uuid::Uuid::new_v4())),
|
||||
metadata: None,
|
||||
timestamp: Utc::now(),
|
||||
status: crate::models::user::TransactionStatus::Completed,
|
||||
};
|
||||
|
||||
persistent_data.transactions.push(transaction);
|
||||
|
||||
// Save updated data
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn configure_auto_topup(
|
||||
&self,
|
||||
session: &Session,
|
||||
settings: AutoTopUpSettings,
|
||||
) -> Result<(), String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
// Load or create data with the correct user_email set to avoid saving to user_data/.json
|
||||
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(&user_email);
|
||||
|
||||
persistent_data.auto_topup_settings = Some(settings);
|
||||
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get formatted auto top-up settings for display
|
||||
pub fn get_formatted_settings(&self, session: &Session) -> Result<Option<serde_json::Value>, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(settings) = &persistent_data.auto_topup_settings {
|
||||
let formatted_threshold = self.currency_service.format_price(settings.threshold_amount_usd, "USD")
|
||||
.unwrap_or_else(|_| format!("${:.2}", settings.threshold_amount_usd));
|
||||
let formatted_topup = self.currency_service.format_price(settings.topup_amount_usd, "USD")
|
||||
.unwrap_or_else(|_| format!("${:.2}", settings.topup_amount_usd));
|
||||
let formatted_daily_limit = if let Some(limit) = settings.daily_limit_usd {
|
||||
self.currency_service.format_price(limit, "USD")
|
||||
.unwrap_or_else(|_| format!("${:.2}", limit))
|
||||
} else {
|
||||
"No limit".to_string()
|
||||
};
|
||||
let formatted_monthly_limit = if let Some(limit) = settings.monthly_limit_usd {
|
||||
self.currency_service.format_price(limit, "USD")
|
||||
.unwrap_or_else(|_| format!("${:.2}", limit))
|
||||
} else {
|
||||
"No limit".to_string()
|
||||
};
|
||||
|
||||
Ok(Some(serde_json::json!({
|
||||
"enabled": settings.enabled,
|
||||
"threshold_amount": settings.threshold_amount_usd,
|
||||
"threshold_amount_formatted": formatted_threshold,
|
||||
"topup_amount": settings.topup_amount_usd,
|
||||
"topup_amount_formatted": formatted_topup,
|
||||
"daily_limit": settings.daily_limit_usd,
|
||||
"daily_limit_formatted": formatted_daily_limit,
|
||||
"monthly_limit": settings.monthly_limit_usd,
|
||||
"monthly_limit_formatted": formatted_monthly_limit,
|
||||
"payment_method_id": settings.payment_method_id
|
||||
})))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoTopUpService {
|
||||
fn default() -> Self {
|
||||
Self::builder().build().unwrap()
|
||||
}
|
||||
}
|
536
src/services/currency.rs
Normal file
536
src/services/currency.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
use crate::models::currency::{Currency, Price};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::collections::HashMap;
|
||||
use chrono::Utc;
|
||||
use actix_session::Session;
|
||||
|
||||
/// Service for handling currency operations and conversions
|
||||
#[derive(Clone)]
|
||||
pub struct CurrencyService {
|
||||
exchange_rates_cache: HashMap<String, Decimal>,
|
||||
last_update: chrono::DateTime<chrono::Utc>,
|
||||
default_display_currency: String,
|
||||
}
|
||||
|
||||
impl CurrencyService {
|
||||
pub fn new() -> Self {
|
||||
let mut service = Self {
|
||||
exchange_rates_cache: HashMap::default(),
|
||||
last_update: Utc::now(),
|
||||
default_display_currency: "USD".to_string(),
|
||||
};
|
||||
|
||||
// USD Credits is now the base currency - no conversion needed
|
||||
service.exchange_rates_cache.insert("USD".to_string(), dec!(1.0));
|
||||
service.update_exchange_rates();
|
||||
service
|
||||
}
|
||||
|
||||
pub fn new_with_config(
|
||||
_cache_duration_minutes: u64,
|
||||
_base_currency: String,
|
||||
auto_update: bool,
|
||||
fallback_rates: HashMap<String, Decimal>,
|
||||
) -> Self {
|
||||
let mut service = Self {
|
||||
exchange_rates_cache: fallback_rates,
|
||||
last_update: Utc::now(),
|
||||
default_display_currency: "USD".to_string(),
|
||||
};
|
||||
|
||||
if auto_update {
|
||||
service.update_exchange_rates();
|
||||
}
|
||||
|
||||
service
|
||||
}
|
||||
|
||||
pub fn new_with_display_config(
|
||||
_cache_duration_minutes: u64,
|
||||
_base_currency: String,
|
||||
display_currency: String,
|
||||
auto_update: bool,
|
||||
fallback_rates: HashMap<String, Decimal>,
|
||||
) -> Self {
|
||||
let mut service = Self {
|
||||
exchange_rates_cache: fallback_rates,
|
||||
last_update: Utc::now(),
|
||||
default_display_currency: display_currency,
|
||||
};
|
||||
|
||||
if auto_update {
|
||||
service.update_exchange_rates();
|
||||
}
|
||||
|
||||
service
|
||||
}
|
||||
|
||||
pub fn builder() -> crate::models::builders::CurrencyServiceBuilder {
|
||||
crate::models::builders::CurrencyServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Get all supported currencies
|
||||
pub fn get_supported_currencies(&self) -> Vec<Currency> {
|
||||
// Return standard supported currencies without mock data
|
||||
vec![
|
||||
Currency {
|
||||
code: "USD".to_string(),
|
||||
name: "US Dollar".to_string(),
|
||||
symbol: "$".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Fiat,
|
||||
exchange_rate_to_base: dec!(1.0),
|
||||
is_base_currency: true,
|
||||
decimal_places: 2,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
},
|
||||
Currency {
|
||||
code: "EUR".to_string(),
|
||||
name: "Euro".to_string(),
|
||||
symbol: "€".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Fiat,
|
||||
exchange_rate_to_base: dec!(0.85),
|
||||
is_base_currency: false,
|
||||
decimal_places: 2,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
},
|
||||
Currency {
|
||||
code: "TFC".to_string(),
|
||||
name: "ThreeFold Credits".to_string(),
|
||||
symbol: "TFC".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Custom("credits".to_string()),
|
||||
exchange_rate_to_base: dec!(1.0), // 1 TFC = 1 USD
|
||||
is_base_currency: false,
|
||||
decimal_places: 2,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
},
|
||||
Currency {
|
||||
code: "CAD".to_string(),
|
||||
name: "Canadian Dollar".to_string(),
|
||||
symbol: "C$".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Fiat,
|
||||
exchange_rate_to_base: dec!(1.35),
|
||||
is_base_currency: false,
|
||||
decimal_places: 2,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
},
|
||||
Currency {
|
||||
code: "TFT".to_string(),
|
||||
name: "ThreeFold Token".to_string(),
|
||||
symbol: "TFT".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Token,
|
||||
exchange_rate_to_base: dec!(0.05),
|
||||
is_base_currency: false,
|
||||
decimal_places: 3,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Get currency by code
|
||||
pub fn get_currency(&self, code: &str) -> Option<Currency> {
|
||||
self.get_supported_currencies()
|
||||
.into_iter()
|
||||
.find(|c| c.code == code)
|
||||
}
|
||||
|
||||
/// Get base currency
|
||||
pub fn get_base_currency(&self) -> Currency {
|
||||
Currency {
|
||||
code: "USD".to_string(),
|
||||
name: "US Dollar".to_string(),
|
||||
symbol: "$".to_string(),
|
||||
currency_type: crate::models::currency::CurrencyType::Fiat,
|
||||
exchange_rate_to_base: dec!(1.0),
|
||||
is_base_currency: true,
|
||||
decimal_places: 2,
|
||||
is_active: true,
|
||||
provider_config: None,
|
||||
last_updated: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert amount from one currency to another
|
||||
pub fn convert_amount(
|
||||
&self,
|
||||
amount: Decimal,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> Result<Decimal, String> {
|
||||
if from_currency == to_currency {
|
||||
return Ok(amount);
|
||||
}
|
||||
|
||||
let base_currency_code = "USD"; // Use USD as base currency
|
||||
|
||||
// Convert to base currency first if needed
|
||||
let base_amount = if from_currency == base_currency_code {
|
||||
amount
|
||||
} else {
|
||||
let from_rate = self.get_exchange_rate_to_base(from_currency)?;
|
||||
amount / from_rate
|
||||
};
|
||||
|
||||
// Convert from base currency to target currency
|
||||
if to_currency == base_currency_code {
|
||||
Ok(base_amount)
|
||||
} else {
|
||||
let to_rate = self.get_exchange_rate_to_base(to_currency)?;
|
||||
Ok(base_amount * to_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get exchange rate from base currency to target currency
|
||||
pub fn get_exchange_rate_to_base(&self, currency_code: &str) -> Result<Decimal, String> {
|
||||
let base_currency_code = "USD"; // Use USD as base currency
|
||||
|
||||
if currency_code == base_currency_code {
|
||||
return Ok(dec!(1.0));
|
||||
}
|
||||
|
||||
if let Some(rate) = self.exchange_rates_cache.get(currency_code) {
|
||||
Ok(*rate)
|
||||
} else if let Some(currency) = self.get_currency(currency_code) {
|
||||
Ok(currency.exchange_rate_to_base)
|
||||
} else {
|
||||
Err(format!("Currency {} not found", currency_code))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Price object with conversion
|
||||
pub fn create_price(
|
||||
&self,
|
||||
base_amount: Decimal,
|
||||
base_currency: &str,
|
||||
display_currency: &str,
|
||||
) -> Result<Price, String> {
|
||||
let conversion_rate = if base_currency == display_currency {
|
||||
dec!(1.0)
|
||||
} else {
|
||||
self.convert_amount(dec!(1.0), base_currency, display_currency)?
|
||||
};
|
||||
|
||||
let mut price = Price::new(
|
||||
base_amount,
|
||||
base_currency.to_string(),
|
||||
display_currency.to_string(),
|
||||
conversion_rate,
|
||||
);
|
||||
|
||||
// Update formatted display with proper currency symbol
|
||||
let formatted_display = self.format_price(price.display_amount, display_currency)?;
|
||||
price.update_formatted_display(formatted_display);
|
||||
|
||||
Ok(price)
|
||||
}
|
||||
|
||||
/// Format price with currency symbol
|
||||
pub fn format_price(
|
||||
&self,
|
||||
amount: Decimal,
|
||||
currency_code: &str,
|
||||
) -> Result<String, String> {
|
||||
if let Some(currency) = self.get_currency(currency_code) {
|
||||
Ok(currency.format_amount(amount))
|
||||
} else {
|
||||
Err(format!("Currency {} not found", currency_code))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update exchange rates with standard rates
|
||||
pub fn update_exchange_rates(&mut self) {
|
||||
// Use standard exchange rates without mock data
|
||||
let currencies = self.get_supported_currencies();
|
||||
|
||||
for currency in currencies {
|
||||
if !currency.is_base_currency {
|
||||
// Use the currency's exchange rate directly
|
||||
let new_rate = currency.exchange_rate_to_base;
|
||||
self.exchange_rates_cache.insert(currency.code.clone(), new_rate);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_update = Utc::now();
|
||||
}
|
||||
|
||||
/// Get the default display currency for this service instance
|
||||
pub fn get_default_display_currency(&self) -> &str {
|
||||
&self.default_display_currency
|
||||
}
|
||||
|
||||
/// Get user's preferred currency from session or persistent data
|
||||
pub fn get_user_preferred_currency(&self, session: &Session) -> String {
|
||||
// First check session for temporary preference
|
||||
if let Ok(Some(currency)) = session.get::<String>("preferred_currency") {
|
||||
return currency;
|
||||
}
|
||||
|
||||
// Then check persistent user data
|
||||
if let Ok(Some(user_email)) = session.get::<String>("user_email") {
|
||||
if let Some(persistent_data) = crate::services::user_persistence::UserPersistence::load_user_data(&user_email) {
|
||||
if let Some(display_currency) = persistent_data.display_currency {
|
||||
return display_currency;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service default, then system default
|
||||
if !self.default_display_currency.is_empty() {
|
||||
self.default_display_currency.clone()
|
||||
} else {
|
||||
"USD".to_string() // Default to USD when no preference is set
|
||||
}
|
||||
}
|
||||
|
||||
/// Set user's preferred currency in session and persistent data
|
||||
pub fn set_user_preferred_currency(&self, session: &Session, currency_code: String) -> Result<(), String> {
|
||||
if self.get_currency(¤cy_code).is_none() {
|
||||
return Err(format!("Currency {} is not supported", currency_code));
|
||||
}
|
||||
|
||||
// Set in session for immediate use
|
||||
session.insert("preferred_currency", currency_code.clone())
|
||||
.map_err(|e| format!("Failed to set currency preference in session: {}", e))?;
|
||||
|
||||
// Save to persistent data if user is logged in
|
||||
if let Ok(Some(user_email)) = session.get::<String>("user_email") {
|
||||
let mut persistent_data = crate::services::user_persistence::UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
||||
user_email: user_email.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
persistent_data.display_currency = Some(currency_code);
|
||||
|
||||
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data(&persistent_data) {
|
||||
// Don't fail the operation, session preference is still set
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all exchange rates relative to base currency
|
||||
pub fn get_all_exchange_rates(&self) -> HashMap<String, Decimal> {
|
||||
let mut rates = HashMap::default();
|
||||
let base_currency_code = "USD".to_string(); // Use USD as base currency
|
||||
|
||||
// Base currency always has rate 1.0
|
||||
rates.insert(base_currency_code.clone(), dec!(1.0));
|
||||
|
||||
// Add cached rates
|
||||
for (currency, rate) in &self.exchange_rates_cache {
|
||||
rates.insert(currency.clone(), *rate);
|
||||
}
|
||||
|
||||
// Add any missing currencies from static config
|
||||
for currency in self.get_supported_currencies() {
|
||||
if !rates.contains_key(¤cy.code) && !currency.is_base_currency {
|
||||
rates.insert(currency.code.clone(), currency.exchange_rate_to_base);
|
||||
}
|
||||
}
|
||||
|
||||
rates
|
||||
}
|
||||
|
||||
/// Check if exchange rates need updating
|
||||
pub fn should_update_rates(&self) -> bool {
|
||||
// Check if rates need updating based on time interval (15 minutes default)
|
||||
let update_interval = chrono::Duration::minutes(15);
|
||||
Utc::now().signed_duration_since(self.last_update) > update_interval
|
||||
}
|
||||
|
||||
/// Get currency statistics for admin/debug purposes
|
||||
pub fn get_currency_stats(&self) -> HashMap<String, serde_json::Value> {
|
||||
let mut stats = HashMap::default();
|
||||
|
||||
stats.insert("total_currencies".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(self.get_supported_currencies().len())));
|
||||
|
||||
stats.insert("base_currency".to_string(),
|
||||
serde_json::Value::String("USD".to_string()));
|
||||
|
||||
stats.insert("last_update".to_string(),
|
||||
serde_json::Value::String(self.last_update.to_rfc3339()));
|
||||
|
||||
stats.insert("cached_rates_count".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(self.exchange_rates_cache.len())));
|
||||
|
||||
let rate_values: Vec<serde_json::Value> = self.exchange_rates_cache.iter()
|
||||
.map(|(currency, rate)| {
|
||||
serde_json::json!({
|
||||
"currency": currency,
|
||||
"rate": rate.to_string()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
stats.insert("current_rates".to_string(), serde_json::Value::Array(rate_values));
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
/// Convert product prices for display in user's preferred currency
|
||||
pub fn convert_product_prices(
|
||||
&self,
|
||||
products: &[crate::models::product::Product],
|
||||
display_currency: &str,
|
||||
) -> Result<Vec<(String, Price)>, String> {
|
||||
let mut converted_prices = Vec::default();
|
||||
|
||||
for product in products {
|
||||
let price = self.create_price(
|
||||
product.base_price,
|
||||
&product.base_currency,
|
||||
display_currency,
|
||||
)?;
|
||||
converted_prices.push((product.id.clone(), price));
|
||||
}
|
||||
|
||||
Ok(converted_prices)
|
||||
}
|
||||
|
||||
/// Get currency display info for frontend
|
||||
pub fn get_currency_display_info(&self) -> Vec<serde_json::Value> {
|
||||
self.get_supported_currencies().iter()
|
||||
.filter(|c| c.is_active)
|
||||
.map(|currency| {
|
||||
serde_json::json!({
|
||||
"code": currency.code,
|
||||
"name": currency.name,
|
||||
"symbol": currency.symbol,
|
||||
"type": currency.currency_type,
|
||||
"decimal_places": currency.decimal_places,
|
||||
"is_base": currency.is_base_currency
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert USD amount to user's preferred display currency
|
||||
pub fn convert_usd_to_display_currency(
|
||||
&self,
|
||||
usd_amount: Decimal,
|
||||
session: &Session,
|
||||
) -> Result<(Decimal, String), String> {
|
||||
let display_currency = self.get_user_preferred_currency(session);
|
||||
|
||||
if display_currency == "USD" {
|
||||
Ok((usd_amount, "USD".to_string()))
|
||||
} else {
|
||||
let converted_amount = self.convert_amount(usd_amount, "USD", &display_currency)?;
|
||||
Ok((converted_amount, display_currency))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert user's display currency amount to USD
|
||||
pub fn convert_display_currency_to_usd(
|
||||
&self,
|
||||
amount: Decimal,
|
||||
session: &Session,
|
||||
) -> Result<Decimal, String> {
|
||||
let display_currency = self.get_user_preferred_currency(session);
|
||||
|
||||
if display_currency == "USD" {
|
||||
Ok(amount)
|
||||
} else {
|
||||
self.convert_amount(amount, &display_currency, "USD")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get formatted price in user's preferred currency
|
||||
pub fn get_formatted_price_for_user(
|
||||
&self,
|
||||
usd_amount: Decimal,
|
||||
session: &Session,
|
||||
) -> Result<String, String> {
|
||||
let (display_amount, display_currency) = self.convert_usd_to_display_currency(usd_amount, session)?;
|
||||
self.format_price(display_amount, &display_currency)
|
||||
}
|
||||
|
||||
/// Get quick top-up suggestions based on user's preferred currency
|
||||
pub fn get_suggested_topup_amounts(&self, session: &Session) -> Vec<Decimal> {
|
||||
let display_currency = self.get_user_preferred_currency(session);
|
||||
|
||||
match display_currency.as_str() {
|
||||
"USD" => vec![
|
||||
Decimal::from(10), // $10
|
||||
Decimal::from(25), // $25
|
||||
Decimal::from(50), // $50
|
||||
Decimal::from(100), // $100
|
||||
],
|
||||
"EUR" => vec![
|
||||
Decimal::from(10),
|
||||
Decimal::from(25),
|
||||
Decimal::from(50),
|
||||
Decimal::from(100),
|
||||
],
|
||||
"GBP" => vec![
|
||||
Decimal::from(10),
|
||||
Decimal::from(20),
|
||||
Decimal::from(50),
|
||||
Decimal::from(100),
|
||||
],
|
||||
_ => vec![ // Default USD amounts
|
||||
Decimal::from(10),
|
||||
Decimal::from(20),
|
||||
Decimal::from(50),
|
||||
Decimal::from(100),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CurrencyService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions for currency operations
|
||||
pub mod utils {
|
||||
use super::*;
|
||||
|
||||
/// Format amount with proper decimal places for currency
|
||||
pub fn format_amount_with_currency(
|
||||
amount: Decimal,
|
||||
currency: &Currency,
|
||||
) -> String {
|
||||
format!("{} {}",
|
||||
amount.round_dp(currency.decimal_places as u32),
|
||||
currency.symbol
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse currency amount from string
|
||||
pub fn parse_currency_amount(amount_str: &str) -> Result<Decimal, String> {
|
||||
amount_str.parse::<Decimal>()
|
||||
.map_err(|e| format!("Invalid amount format: {}", e))
|
||||
}
|
||||
|
||||
/// Validate currency code format
|
||||
pub fn is_valid_currency_code(code: &str) -> bool {
|
||||
code.len() == 3 && code.chars().all(|c| c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Get currency type display name
|
||||
pub fn get_currency_type_display(currency_type: &crate::models::currency::CurrencyType) -> &'static str {
|
||||
match currency_type {
|
||||
crate::models::currency::CurrencyType::Fiat => "Fiat Currency",
|
||||
crate::models::currency::CurrencyType::Cryptocurrency => "Cryptocurrency",
|
||||
crate::models::currency::CurrencyType::Token => "Token",
|
||||
crate::models::currency::CurrencyType::Points => "Loyalty Points",
|
||||
crate::models::currency::CurrencyType::Custom(_) => "Custom Currency",
|
||||
}
|
||||
}
|
||||
}
|
35
src/services/factory.rs
Normal file
35
src/services/factory.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
use crate::services::{
|
||||
currency::CurrencyService,
|
||||
user_persistence::UserPersistence,
|
||||
};
|
||||
|
||||
/// Service factory for single source of truth service instantiation
|
||||
///
|
||||
/// This factory consolidates repeated service instantiations throughout
|
||||
/// the Project Mycelium codebase, focusing on persistent data access
|
||||
/// and eliminating mock data usage in favor of user_data/ directory.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```rust,ignore
|
||||
/// let currency_service = ServiceFactory::currency_service();
|
||||
/// ```
|
||||
pub struct ServiceFactory;
|
||||
|
||||
impl ServiceFactory {
|
||||
/// Creates a new CurrencyService instance using persistent data
|
||||
///
|
||||
/// This replaces scattered CurrencyService instantiations throughout the codebase
|
||||
/// with a single source of truth, using persistent data instead of mock data.
|
||||
pub fn currency_service() -> Arc<CurrencyService> {
|
||||
Arc::new(CurrencyService::builder().build().unwrap())
|
||||
}
|
||||
|
||||
/// Provides access to UserPersistence for persistent data operations
|
||||
///
|
||||
/// This provides centralized access to persistent user data from user_data/
|
||||
/// directory, eliminating the need for mock data services.
|
||||
pub fn user_persistence() -> UserPersistence {
|
||||
UserPersistence
|
||||
}
|
||||
}
|
2999
src/services/farmer.rs
Normal file
2999
src/services/farmer.rs
Normal file
File diff suppressed because it is too large
Load Diff
362
src/services/grid.rs
Normal file
362
src/services/grid.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
//! Grid service for ThreeFold Grid integration
|
||||
//! Handles fetching node data from gridproxy API
|
||||
|
||||
use crate::models::user::{GridNodeData, NodeCapacity};
|
||||
use serde::Deserialize;
|
||||
use chrono::Utc;
|
||||
|
||||
/// GridProxy API response structures
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GridProxyNode {
|
||||
#[serde(rename = "nodeId")]
|
||||
pub node_id: u32,
|
||||
#[serde(rename = "farmId")]
|
||||
pub farm_id: u32,
|
||||
#[serde(rename = "farmName")]
|
||||
pub farm_name: String,
|
||||
pub country: String,
|
||||
pub city: String,
|
||||
pub location: GridProxyLocation,
|
||||
pub total_resources: GridProxyResources,
|
||||
pub used_resources: GridProxyResources,
|
||||
#[serde(rename = "certificationType")]
|
||||
pub certification_type: String,
|
||||
#[serde(rename = "farmingPolicyId")]
|
||||
pub farming_policy_id: u32,
|
||||
pub status: String,
|
||||
#[serde(rename = "farm_free_ips")]
|
||||
pub farm_free_ips: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GridProxyLocation {
|
||||
pub country: String,
|
||||
pub city: String,
|
||||
pub longitude: f64,
|
||||
pub latitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GridProxyResources {
|
||||
pub cru: u32, // CPU cores
|
||||
pub mru: u64, // Memory in bytes
|
||||
pub sru: u64, // SSD storage in bytes
|
||||
pub hru: u64, // HDD storage in bytes
|
||||
}
|
||||
|
||||
/// Service for ThreeFold Grid operations
|
||||
#[derive(Clone)]
|
||||
pub struct GridService {
|
||||
gridproxy_url: String,
|
||||
timeout_seconds: u64,
|
||||
}
|
||||
|
||||
/// Builder for GridService
|
||||
#[derive(Default)]
|
||||
pub struct GridServiceBuilder {
|
||||
gridproxy_url: Option<String>,
|
||||
timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl GridServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn gridproxy_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.gridproxy_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn timeout_seconds(mut self, timeout: u64) -> Self {
|
||||
self.timeout_seconds = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<GridService, String> {
|
||||
Ok(GridService {
|
||||
gridproxy_url: self.gridproxy_url.unwrap_or_else(|| "https://gridproxy.grid.tf".to_string()),
|
||||
timeout_seconds: self.timeout_seconds.unwrap_or(30),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GridService {
|
||||
pub fn builder() -> GridServiceBuilder {
|
||||
GridServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Fetch node data from ThreeFold Grid
|
||||
pub async fn fetch_node_data(&self, node_id: u32) -> Result<GridNodeData, String> {
|
||||
// Try to fetch from real API first, fall back to mock data
|
||||
match self.fetch_real_node_data(node_id).await {
|
||||
Ok(data) => {
|
||||
Ok(data)
|
||||
},
|
||||
Err(e) => {
|
||||
self.create_mock_grid_data(node_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a node exists on the grid
|
||||
pub async fn validate_node_exists(&self, node_id: u32) -> Result<bool, String> {
|
||||
// Try real API first, fall back to basic validation
|
||||
match self.fetch_real_node_data(node_id).await {
|
||||
Ok(_) => {
|
||||
Ok(true)
|
||||
},
|
||||
Err(_) => {
|
||||
// Fall back to basic validation for mock data
|
||||
let is_valid = node_id > 0 && node_id < 10000;
|
||||
if is_valid {
|
||||
} else {
|
||||
}
|
||||
Ok(is_valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch real node data from gridproxy API
|
||||
async fn fetch_real_node_data(&self, node_id: u32) -> Result<GridNodeData, String> {
|
||||
let url = format!("{}/nodes?node_id={}", self.gridproxy_url, node_id);
|
||||
|
||||
// For now, we'll use reqwest to make the HTTP call
|
||||
// In a production environment, you might want to use a more robust HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(self.timeout_seconds))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch node data: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("API returned status: {}", response.status()));
|
||||
}
|
||||
|
||||
let nodes: Vec<GridProxyNode> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if nodes.is_empty() {
|
||||
return Err(format!("Node {} not found", node_id));
|
||||
}
|
||||
|
||||
let node = &nodes[0];
|
||||
self.convert_gridproxy_to_grid_data(node)
|
||||
}
|
||||
|
||||
/// Convert GridProxy API response to our internal GridNodeData format
|
||||
fn convert_gridproxy_to_grid_data(&self, node: &GridProxyNode) -> Result<GridNodeData, String> {
|
||||
// Convert bytes to GB for memory and storage
|
||||
let memory_gb = (node.total_resources.mru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let ssd_storage_gb = (node.total_resources.sru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let hdd_storage_gb = (node.total_resources.hru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let total_storage_gb = ssd_storage_gb + hdd_storage_gb; // For backward compatibility
|
||||
|
||||
let used_memory_gb = (node.used_resources.mru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let used_ssd_storage_gb = (node.used_resources.sru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let used_hdd_storage_gb = (node.used_resources.hru as f64 / (1024.0 * 1024.0 * 1024.0)) as i32;
|
||||
let used_total_storage_gb = used_ssd_storage_gb + used_hdd_storage_gb;
|
||||
|
||||
crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(node.node_id)
|
||||
.city(node.location.city.clone())
|
||||
.country(node.location.country.clone())
|
||||
.farm_name(node.farm_name.clone())
|
||||
.farm_id(node.farm_id)
|
||||
.public_ips(node.farm_free_ips.unwrap_or(0))
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: node.total_resources.cru as i32,
|
||||
memory_gb,
|
||||
storage_gb: total_storage_gb, // Backward compatibility
|
||||
bandwidth_mbps: 1000, // Default bandwidth, not provided by API
|
||||
ssd_storage_gb,
|
||||
hdd_storage_gb,
|
||||
ram_gb: memory_gb,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: node.used_resources.cru as i32,
|
||||
memory_gb: used_memory_gb,
|
||||
storage_gb: used_total_storage_gb, // Backward compatibility
|
||||
bandwidth_mbps: 0, // Default used bandwidth
|
||||
ssd_storage_gb: used_ssd_storage_gb,
|
||||
hdd_storage_gb: used_hdd_storage_gb,
|
||||
ram_gb: used_memory_gb,
|
||||
})
|
||||
.certification_type(node.certification_type.clone())
|
||||
.farming_policy_id(node.farming_policy_id)
|
||||
.last_updated(Utc::now())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create mock grid data for testing
|
||||
fn create_mock_grid_data(&self, node_id: u32) -> Result<GridNodeData, String> {
|
||||
let grid_data = match node_id {
|
||||
1 => crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(1)
|
||||
.city("Ghent".to_string())
|
||||
.country("Belgium".to_string())
|
||||
.farm_name("Freefarm".to_string())
|
||||
.farm_id(1)
|
||||
.public_ips(1)
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: 56,
|
||||
memory_gb: 189,
|
||||
storage_gb: 1863 + 134000, // Total for backward compatibility
|
||||
bandwidth_mbps: 1000,
|
||||
ssd_storage_gb: 1863, // ~2TB SSD
|
||||
hdd_storage_gb: 134000, // ~134TB HDD
|
||||
ram_gb: 189,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: 16,
|
||||
memory_gb: 63,
|
||||
storage_gb: 753,
|
||||
bandwidth_mbps: 100,
|
||||
ssd_storage_gb: 753,
|
||||
hdd_storage_gb: 0,
|
||||
ram_gb: 63,
|
||||
})
|
||||
.certification_type("Diy".to_string())
|
||||
.farming_policy_id(1)
|
||||
.last_updated(Utc::now())
|
||||
.build()?,
|
||||
8 => crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(8)
|
||||
.city("Vienna".to_string())
|
||||
.country("Austria".to_string())
|
||||
.farm_name("TF Tech".to_string())
|
||||
.farm_id(2)
|
||||
.public_ips(2)
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: 16,
|
||||
memory_gb: 64,
|
||||
storage_gb: 2000 + 8000, // Total for backward compatibility
|
||||
bandwidth_mbps: 1000,
|
||||
ssd_storage_gb: 2000, // 2TB SSD
|
||||
hdd_storage_gb: 8000, // 8TB HDD
|
||||
ram_gb: 64,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: 4,
|
||||
memory_gb: 16,
|
||||
storage_gb: 400,
|
||||
bandwidth_mbps: 200,
|
||||
ssd_storage_gb: 400,
|
||||
hdd_storage_gb: 0,
|
||||
ram_gb: 16,
|
||||
})
|
||||
.certification_type("Certified".to_string())
|
||||
.farming_policy_id(1)
|
||||
.last_updated(Utc::now())
|
||||
.build()?,
|
||||
42 => crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(42)
|
||||
.city("Dubai".to_string())
|
||||
.country("UAE".to_string())
|
||||
.farm_name("Desert Farm".to_string())
|
||||
.farm_id(5)
|
||||
.public_ips(4)
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: 32,
|
||||
memory_gb: 128,
|
||||
storage_gb: 4000 + 16000, // Total for backward compatibility
|
||||
bandwidth_mbps: 2000,
|
||||
ssd_storage_gb: 4000, // 4TB SSD
|
||||
hdd_storage_gb: 16000, // 16TB HDD
|
||||
ram_gb: 128,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: 8,
|
||||
memory_gb: 32,
|
||||
storage_gb: 800,
|
||||
bandwidth_mbps: 400,
|
||||
ssd_storage_gb: 800,
|
||||
hdd_storage_gb: 0,
|
||||
ram_gb: 32,
|
||||
})
|
||||
.certification_type("Certified".to_string())
|
||||
.farming_policy_id(2)
|
||||
.last_updated(Utc::now())
|
||||
.build()?,
|
||||
1337 => crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(1337)
|
||||
.city("San Francisco".to_string())
|
||||
.country("USA".to_string())
|
||||
.farm_name("Silicon Valley Farm".to_string())
|
||||
.farm_id(10)
|
||||
.public_ips(8)
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: 64,
|
||||
memory_gb: 256,
|
||||
storage_gb: 8000 + 32000, // Total for backward compatibility
|
||||
bandwidth_mbps: 10000,
|
||||
ssd_storage_gb: 8000, // 8TB SSD
|
||||
hdd_storage_gb: 32000, // 32TB HDD
|
||||
ram_gb: 256,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: 16,
|
||||
memory_gb: 64,
|
||||
storage_gb: 1600,
|
||||
bandwidth_mbps: 2000,
|
||||
ssd_storage_gb: 1600,
|
||||
hdd_storage_gb: 0,
|
||||
ram_gb: 64,
|
||||
})
|
||||
.certification_type("Certified".to_string())
|
||||
.farming_policy_id(3)
|
||||
.last_updated(Utc::now())
|
||||
.build()?,
|
||||
_ => {
|
||||
// Generate dynamic mock data for other node IDs
|
||||
let cities = vec![
|
||||
("London", "UK"), ("Paris", "France"), ("Berlin", "Germany"),
|
||||
("Tokyo", "Japan"), ("Sydney", "Australia"), ("Toronto", "Canada"),
|
||||
("Amsterdam", "Netherlands"), ("Stockholm", "Sweden")
|
||||
];
|
||||
let (city, country) = cities[node_id as usize % cities.len()];
|
||||
|
||||
let ssd_gb = (((node_id % 4) + 1) * 500) as i32;
|
||||
let hdd_gb = (((node_id % 4) + 1) * 2000) as i32;
|
||||
let used_ssd_gb = (((node_id % 4) + 1) * 100) as i32;
|
||||
|
||||
crate::models::builders::GridNodeDataBuilder::new()
|
||||
.grid_node_id(node_id)
|
||||
.city(city.to_string())
|
||||
.country(country.to_string())
|
||||
.farm_name(format!("Farm {}", node_id))
|
||||
.farm_id(node_id % 20 + 1)
|
||||
.public_ips((node_id % 5) + 1)
|
||||
.total_resources(NodeCapacity {
|
||||
cpu_cores: (((node_id % 4) + 1) * 4) as i32,
|
||||
memory_gb: (((node_id % 4) + 1) * 16) as i32,
|
||||
storage_gb: ssd_gb + hdd_gb, // Total for backward compatibility
|
||||
bandwidth_mbps: 1000,
|
||||
ssd_storage_gb: ssd_gb,
|
||||
hdd_storage_gb: hdd_gb,
|
||||
ram_gb: (((node_id % 4) + 1) * 16) as i32,
|
||||
})
|
||||
.used_resources(NodeCapacity {
|
||||
cpu_cores: ((node_id % 4) + 1) as i32,
|
||||
memory_gb: (((node_id % 4) + 1) * 4) as i32,
|
||||
storage_gb: used_ssd_gb, // Only SSD used for simplicity
|
||||
bandwidth_mbps: 200,
|
||||
ssd_storage_gb: used_ssd_gb,
|
||||
hdd_storage_gb: 0,
|
||||
ram_gb: (((node_id % 4) + 1) * 4) as i32,
|
||||
})
|
||||
.certification_type(if node_id % 3 == 0 { "Certified" } else { "DIY" }.to_string())
|
||||
.farming_policy_id((node_id % 3) + 1)
|
||||
.last_updated(Utc::now())
|
||||
.build()?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(grid_data)
|
||||
}
|
||||
}
|
438
src/services/instant_purchase.rs
Normal file
438
src/services/instant_purchase.rs
Normal file
@@ -0,0 +1,438 @@
|
||||
use actix_session::Session;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{
|
||||
user::{Transaction, TransactionType, TransactionStatus},
|
||||
order::{Order, OrderItem, OrderStatus, PaymentMethod, PaymentDetails, PurchaseType},
|
||||
};
|
||||
use crate::services::{
|
||||
currency::CurrencyService,
|
||||
user_persistence::UserPersistence,
|
||||
order::OrderService,
|
||||
};
|
||||
|
||||
/// Service for handling instant purchases (buy-now functionality)
|
||||
#[derive(Clone)]
|
||||
pub struct InstantPurchaseService {
|
||||
currency_service: CurrencyService,
|
||||
}
|
||||
|
||||
/// Request for instant purchase
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InstantPurchaseRequest {
|
||||
pub product_id: String,
|
||||
pub product_name: String,
|
||||
pub product_category: String,
|
||||
pub quantity: u32,
|
||||
pub unit_price_usd: Decimal, // Price in USD (base currency)
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub specifications: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Response for instant purchase
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InstantPurchaseResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub order_id: Option<String>,
|
||||
pub transaction_id: Option<String>,
|
||||
pub remaining_balance: Option<Decimal>,
|
||||
pub insufficient_balance: Option<InsufficientBalanceInfo>,
|
||||
}
|
||||
|
||||
/// Information about insufficient balance
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InsufficientBalanceInfo {
|
||||
pub required_amount: Decimal,
|
||||
pub current_balance: Decimal,
|
||||
pub shortfall: Decimal,
|
||||
pub topup_url: String,
|
||||
}
|
||||
|
||||
/// Request for quick wallet top-up
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QuickTopupRequest {
|
||||
pub amount: Decimal, // Amount in user's preferred display currency
|
||||
pub payment_method: String,
|
||||
}
|
||||
|
||||
/// Response for quick top-up
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct QuickTopupResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub transaction_id: Option<String>,
|
||||
pub usd_amount: Option<Decimal>, // Amount of USD added
|
||||
pub new_balance: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// Builder for InstantPurchaseService
|
||||
#[derive(Default)]
|
||||
pub struct InstantPurchaseServiceBuilder {
|
||||
currency_service: Option<CurrencyService>,
|
||||
}
|
||||
|
||||
impl InstantPurchaseServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_currency_service(mut self, service: CurrencyService) -> Self {
|
||||
self.currency_service = Some(service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<InstantPurchaseService, String> {
|
||||
let currency_service = self.currency_service
|
||||
.unwrap_or_else(|| CurrencyService::builder().build().unwrap());
|
||||
|
||||
Ok(InstantPurchaseService {
|
||||
currency_service,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InstantPurchaseService {
|
||||
pub fn builder() -> InstantPurchaseServiceBuilder {
|
||||
InstantPurchaseServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Execute an instant purchase
|
||||
pub async fn execute_instant_purchase(
|
||||
&self,
|
||||
session: &Session,
|
||||
request: InstantPurchaseRequest,
|
||||
) -> Result<InstantPurchaseResponse, String> {
|
||||
log::info!(
|
||||
target: "instant_purchase",
|
||||
"execute_instant_purchase:start product_id={} product_name={} qty={} unit_price_usd={}",
|
||||
request.product_id,
|
||||
request.product_name,
|
||||
request.quantity,
|
||||
request.unit_price_usd
|
||||
);
|
||||
// Get user email from session
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
// Load user data
|
||||
let mut persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
||||
user_email: user_email.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Calculate total cost in USD
|
||||
let total_cost_usd = request.unit_price_usd * Decimal::from(request.quantity);
|
||||
|
||||
// Check if user has sufficient balance
|
||||
if persistent_data.wallet_balance_usd < total_cost_usd {
|
||||
let shortfall = total_cost_usd - persistent_data.wallet_balance_usd;
|
||||
|
||||
return Ok(InstantPurchaseResponse {
|
||||
success: false,
|
||||
message: "Insufficient balance for instant purchase".to_string(),
|
||||
order_id: None,
|
||||
transaction_id: None,
|
||||
remaining_balance: Some(persistent_data.wallet_balance_usd),
|
||||
insufficient_balance: Some(InsufficientBalanceInfo {
|
||||
required_amount: total_cost_usd,
|
||||
current_balance: persistent_data.wallet_balance_usd,
|
||||
shortfall,
|
||||
topup_url: "/wallet?action=topup".to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Generate IDs
|
||||
let order_id = Uuid::new_v4().to_string();
|
||||
let transaction_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Deduct balance
|
||||
persistent_data.wallet_balance_usd -= total_cost_usd;
|
||||
|
||||
// Create transaction record
|
||||
let transaction = Transaction {
|
||||
id: transaction_id.clone(),
|
||||
user_id: user_email.clone(),
|
||||
transaction_type: TransactionType::InstantPurchase {
|
||||
product_id: request.product_id.clone(),
|
||||
quantity: Some(request.quantity),
|
||||
},
|
||||
amount: total_cost_usd,
|
||||
currency: Some("USD".to_string()),
|
||||
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
||||
amount_usd: Some(total_cost_usd),
|
||||
description: Some(format!("Instant purchase of product {}", request.product_id)),
|
||||
reference_id: Some(format!("instant-{}", uuid::Uuid::new_v4())),
|
||||
metadata: None,
|
||||
timestamp: Utc::now(),
|
||||
status: TransactionStatus::Completed,
|
||||
};
|
||||
|
||||
// Add transaction to history
|
||||
persistent_data.transactions.push(transaction);
|
||||
|
||||
// Normalize category (e.g., "services" -> "service")
|
||||
fn canonical_category_id(category_id: &str) -> String {
|
||||
match category_id.to_lowercase().as_str() {
|
||||
// Applications
|
||||
"applications" | "application" | "app" | "apps" => "application".to_string(),
|
||||
// Gateways
|
||||
"gateways" | "gateway" => "gateway".to_string(),
|
||||
// Services and professional service subcategories
|
||||
"services" | "service"
|
||||
| "consulting" | "deployment" | "support" | "training"
|
||||
| "development" | "maintenance"
|
||||
| "professional_services" | "professional_service"
|
||||
| "professional services" | "professional service"
|
||||
| "system administration" | "system_administration" | "sysadmin"
|
||||
=> "service".to_string(),
|
||||
// Compute
|
||||
"computes" | "compute" => "compute".to_string(),
|
||||
// Storage (modeled as service in current UI)
|
||||
"storage" | "storages" => "service".to_string(),
|
||||
// keep others as-is
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create order record
|
||||
let order_item = OrderItem::new(
|
||||
request.product_id.clone(),
|
||||
request.product_name.clone(),
|
||||
canonical_category_id(&request.product_category),
|
||||
request.quantity,
|
||||
request.unit_price_usd,
|
||||
request.provider_id.clone(),
|
||||
request.provider_name.clone(),
|
||||
);
|
||||
|
||||
let mut order = Order::new(
|
||||
order_id.clone(),
|
||||
user_email.clone(),
|
||||
"USD".to_string(), // Base currency
|
||||
"USD".to_string(), // Currency used (instant purchases use USD directly)
|
||||
Decimal::from(1), // Conversion rate (1:1 for USD)
|
||||
);
|
||||
|
||||
order.add_item(order_item);
|
||||
// Mark as instant purchase
|
||||
order.purchase_type = PurchaseType::Instant;
|
||||
order.update_status(OrderStatus::Completed); // Instant purchases are immediately completed
|
||||
|
||||
// Set payment details
|
||||
let payment_details = PaymentDetails::new(
|
||||
transaction_id.clone(),
|
||||
PaymentMethod::Token {
|
||||
token_type: "USD".to_string(),
|
||||
wallet_address: "internal_wallet".to_string(),
|
||||
},
|
||||
);
|
||||
order.set_payment_details(payment_details);
|
||||
|
||||
// Create service bookings and provider service requests for any service items
|
||||
// This mirrors the post-payment behavior in OrderService for standard checkout
|
||||
let order_service = OrderService::new();
|
||||
if let Err(e) = order_service.create_service_bookings_from_order(&order) {
|
||||
log::error!(
|
||||
target: "instant_purchase",
|
||||
"create_service_bookings_from_order:failed order_id={} customer_email={} err={}",
|
||||
order_id,
|
||||
user_email,
|
||||
e
|
||||
);
|
||||
return Err(format!(
|
||||
"Failed to create service bookings for order {}: {}",
|
||||
order_id, e
|
||||
));
|
||||
} else {
|
||||
log::info!(
|
||||
target: "instant_purchase",
|
||||
"create_service_bookings_from_order:succeeded order_id={} customer_email={}",
|
||||
order_id,
|
||||
user_email
|
||||
);
|
||||
}
|
||||
|
||||
// Important: create_service_bookings_from_order persists bookings by loading/saving
|
||||
// user data internally. Our in-memory `persistent_data` is now stale and would
|
||||
// overwrite those bookings if we saved it as-is. Merge latest bookings back in.
|
||||
if let Some(latest) = UserPersistence::load_user_data(&user_email) {
|
||||
persistent_data.service_bookings = latest.service_bookings;
|
||||
log::debug!(
|
||||
target: "instant_purchase",
|
||||
"merged_latest_bookings order_id={} merged_bookings_count={}",
|
||||
order_id,
|
||||
persistent_data.service_bookings.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Add order to user's persistent data (following industry standard user-centric storage)
|
||||
persistent_data.orders.push(order);
|
||||
|
||||
// Save updated user data (includes the new order)
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| {
|
||||
log::error!(
|
||||
target: "instant_purchase",
|
||||
"save_user_data:failed email={} order_id={} err={}",
|
||||
user_email,
|
||||
order_id,
|
||||
e
|
||||
);
|
||||
format!("Failed to save user data: {}", e)
|
||||
})?;
|
||||
|
||||
// TODO: Trigger deployment/fulfillment process here
|
||||
// This would depend on the product type and provider integration
|
||||
|
||||
log::info!(
|
||||
target: "instant_purchase",
|
||||
"execute_instant_purchase:success order_id={} tx_id={} remaining_balance={}",
|
||||
order_id,
|
||||
transaction_id,
|
||||
persistent_data.wallet_balance_usd
|
||||
);
|
||||
Ok(InstantPurchaseResponse {
|
||||
success: true,
|
||||
message: format!("Successfully purchased {} x{}", request.product_name, request.quantity),
|
||||
order_id: Some(order_id),
|
||||
transaction_id: Some(transaction_id),
|
||||
remaining_balance: Some(persistent_data.wallet_balance_usd),
|
||||
insufficient_balance: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute quick wallet top-up
|
||||
pub async fn execute_quick_topup(
|
||||
&self,
|
||||
session: &Session,
|
||||
request: QuickTopupRequest,
|
||||
) -> Result<QuickTopupResponse, String> {
|
||||
// Get user email from session
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
// Load user data
|
||||
let mut persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
||||
user_email: user_email.clone(),
|
||||
display_currency: Some("USD".to_string()),
|
||||
quick_topup_amounts: Some(vec![dec!(10), dec!(25), dec!(50), dec!(100)]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Get user's preferred display currency
|
||||
let display_currency = self.currency_service.get_user_preferred_currency(session);
|
||||
|
||||
// Convert amount to USD if needed
|
||||
let usd_amount = if display_currency == "USD" {
|
||||
request.amount
|
||||
} else {
|
||||
self.currency_service.convert_amount(
|
||||
request.amount,
|
||||
&display_currency,
|
||||
"USD",
|
||||
).map_err(|e| format!("Currency conversion failed: {}", e))?
|
||||
};
|
||||
|
||||
// Generate transaction ID
|
||||
let transaction_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Add USD to wallet
|
||||
persistent_data.wallet_balance_usd += usd_amount;
|
||||
|
||||
// Create transaction record
|
||||
let transaction = Transaction {
|
||||
id: transaction_id.clone(),
|
||||
user_id: user_email.clone(),
|
||||
transaction_type: TransactionType::CreditsPurchase {
|
||||
amount_usd: usd_amount,
|
||||
payment_method: request.payment_method.clone(),
|
||||
},
|
||||
amount: usd_amount,
|
||||
currency: Some("USD".to_string()),
|
||||
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
||||
amount_usd: Some(usd_amount),
|
||||
description: Some(format!("Credits purchase via {}", request.payment_method)),
|
||||
reference_id: Some(format!("credits-{}", uuid::Uuid::new_v4())),
|
||||
metadata: None,
|
||||
timestamp: Utc::now(),
|
||||
status: TransactionStatus::Completed,
|
||||
};
|
||||
|
||||
// Add transaction to history
|
||||
persistent_data.transactions.push(transaction);
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
// TODO: Process actual payment here
|
||||
// This would integrate with payment processors (Stripe, PayPal, etc.)
|
||||
|
||||
Ok(QuickTopupResponse {
|
||||
success: true,
|
||||
message: format!("Successfully added ${} to your wallet", usd_amount),
|
||||
transaction_id: Some(transaction_id),
|
||||
usd_amount: Some(usd_amount),
|
||||
new_balance: Some(persistent_data.wallet_balance_usd),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if user can afford a purchase
|
||||
pub fn check_affordability(
|
||||
&self,
|
||||
session: &Session,
|
||||
total_cost_usd: Decimal,
|
||||
) -> Result<bool, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(persistent_data.wallet_balance_usd >= total_cost_usd)
|
||||
}
|
||||
|
||||
/// Get balance shortfall information
|
||||
pub fn get_balance_shortfall(
|
||||
&self,
|
||||
session: &Session,
|
||||
required_amount_usd: Decimal,
|
||||
) -> Result<Option<InsufficientBalanceInfo>, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_default();
|
||||
|
||||
if persistent_data.wallet_balance_usd >= required_amount_usd {
|
||||
Ok(None)
|
||||
} else {
|
||||
let shortfall = required_amount_usd - persistent_data.wallet_balance_usd;
|
||||
Ok(Some(InsufficientBalanceInfo {
|
||||
required_amount: required_amount_usd,
|
||||
current_balance: persistent_data.wallet_balance_usd,
|
||||
shortfall,
|
||||
topup_url: "/wallet?action=topup".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstantPurchaseService {
|
||||
fn default() -> Self {
|
||||
Self::builder().build().unwrap()
|
||||
}
|
||||
}
|
22
src/services/mod.rs
Normal file
22
src/services/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Export services
|
||||
pub mod auto_topup;
|
||||
pub mod currency;
|
||||
pub mod factory;
|
||||
pub mod farmer;
|
||||
pub mod grid;
|
||||
pub mod instant_purchase;
|
||||
pub mod navbar;
|
||||
pub mod node_marketplace;
|
||||
pub mod node_rental;
|
||||
pub mod order;
|
||||
pub mod pool_service;
|
||||
pub mod product;
|
||||
pub mod session_manager;
|
||||
pub mod slice_assignment;
|
||||
pub mod slice_calculator;
|
||||
pub mod slice_rental;
|
||||
pub mod ssh_key_service;
|
||||
pub mod user_persistence;
|
||||
pub mod user_service;
|
||||
|
||||
// Re-export ServiceFactory for easy access
|
229
src/services/navbar.rs
Normal file
229
src/services/navbar.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use actix_session::Session;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::services::{currency::CurrencyService, user_persistence::UserPersistence};
|
||||
|
||||
/// Service for handling navbar dropdown data
|
||||
#[derive(Clone)]
|
||||
pub struct NavbarService {
|
||||
currency_service: CurrencyService,
|
||||
}
|
||||
|
||||
/// Data structure for navbar dropdown menu
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NavbarDropdownData {
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: String,
|
||||
pub wallet_balance: Decimal,
|
||||
pub wallet_balance_formatted: String,
|
||||
pub display_currency: String,
|
||||
pub currency_symbol: String,
|
||||
pub quick_actions: Vec<QuickAction>,
|
||||
pub show_topup_button: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct QuickAction {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub url: String,
|
||||
pub icon: String,
|
||||
pub badge: Option<String>,
|
||||
}
|
||||
|
||||
/// Builder for NavbarService
|
||||
#[derive(Default)]
|
||||
pub struct NavbarServiceBuilder {
|
||||
currency_service: Option<CurrencyService>,
|
||||
}
|
||||
|
||||
impl NavbarServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_currency_service(mut self, service: CurrencyService) -> Self {
|
||||
self.currency_service = Some(service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<NavbarService, String> {
|
||||
let currency_service = self.currency_service
|
||||
.unwrap_or_else(|| CurrencyService::builder().build().unwrap());
|
||||
|
||||
Ok(NavbarService {
|
||||
currency_service,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NavbarService {
|
||||
pub fn builder() -> NavbarServiceBuilder {
|
||||
NavbarServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Get navbar dropdown data for authenticated user
|
||||
pub fn get_dropdown_data(&self, session: &Session) -> Result<NavbarDropdownData, String> {
|
||||
// Get user email from session
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email from session: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
// Load user persistent data
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
||||
user_email: user_email.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Get user's preferred display currency
|
||||
let mut display_currency = self.currency_service.get_user_preferred_currency(session);
|
||||
|
||||
// Get currency info for formatting; fall back to USD if invalid
|
||||
let (currency, effective_currency) = match self.currency_service.get_currency(&display_currency) {
|
||||
Some(c) => (c, display_currency.clone()),
|
||||
None => {
|
||||
let usd = self
|
||||
.currency_service
|
||||
.get_currency("USD")
|
||||
.expect("USD currency must be available");
|
||||
display_currency = "USD".to_string();
|
||||
(usd, "USD".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
// Convert wallet balance to display currency
|
||||
let wallet_balance_display = if effective_currency == "USD" {
|
||||
persistent_data.wallet_balance_usd
|
||||
} else {
|
||||
self.currency_service.convert_amount(
|
||||
persistent_data.wallet_balance_usd,
|
||||
"USD",
|
||||
&effective_currency,
|
||||
).unwrap_or(Decimal::ZERO)
|
||||
};
|
||||
|
||||
// Format the balance
|
||||
let wallet_balance_formatted = currency.format_amount(wallet_balance_display);
|
||||
|
||||
// Create quick actions
|
||||
let quick_actions = vec![
|
||||
QuickAction {
|
||||
id: "topup".to_string(),
|
||||
label: "Top Up Wallet".to_string(),
|
||||
url: "/wallet?action=topup".to_string(),
|
||||
icon: "bi-plus-circle".to_string(),
|
||||
badge: None,
|
||||
},
|
||||
QuickAction {
|
||||
id: "wallet".to_string(),
|
||||
label: "Wallet".to_string(),
|
||||
url: "/wallet".to_string(),
|
||||
icon: "bi-wallet2".to_string(),
|
||||
badge: None,
|
||||
},
|
||||
QuickAction {
|
||||
id: "settings".to_string(),
|
||||
label: "Settings".to_string(),
|
||||
url: "/dashboard/settings".to_string(),
|
||||
icon: "bi-gear".to_string(),
|
||||
badge: None,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(NavbarDropdownData {
|
||||
user_name: persistent_data.name,
|
||||
user_email,
|
||||
wallet_balance: wallet_balance_display,
|
||||
wallet_balance_formatted,
|
||||
display_currency: effective_currency.clone(),
|
||||
currency_symbol: currency.symbol.clone(),
|
||||
quick_actions,
|
||||
show_topup_button: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get simplified navbar data for guest users
|
||||
pub fn get_guest_data() -> NavbarDropdownData {
|
||||
NavbarDropdownData {
|
||||
user_name: None,
|
||||
user_email: String::new(),
|
||||
wallet_balance: Decimal::ZERO,
|
||||
wallet_balance_formatted: "Not logged in".to_string(),
|
||||
display_currency: "USD".to_string(),
|
||||
currency_symbol: "$".to_string(),
|
||||
quick_actions: vec![
|
||||
QuickAction {
|
||||
id: "login".to_string(),
|
||||
label: "Login".to_string(),
|
||||
url: "/login".to_string(),
|
||||
icon: "bi-box-arrow-in-right".to_string(),
|
||||
badge: None,
|
||||
},
|
||||
QuickAction {
|
||||
id: "register".to_string(),
|
||||
label: "Register".to_string(),
|
||||
url: "/register".to_string(),
|
||||
icon: "bi-person-plus".to_string(),
|
||||
badge: None,
|
||||
},
|
||||
],
|
||||
show_topup_button: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user has sufficient balance for a purchase
|
||||
pub fn check_sufficient_balance(&self, session: &Session, required_amount_usd: Decimal) -> Result<bool, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(persistent_data.wallet_balance_usd >= required_amount_usd)
|
||||
}
|
||||
|
||||
/// Get quick top-up amounts for user's preferred currency
|
||||
pub fn get_quick_topup_amounts(&self, session: &Session) -> Result<Vec<Decimal>, String> {
|
||||
let user_email = session.get::<String>("user_email")
|
||||
.map_err(|e| format!("Failed to get user email: {}", e))?
|
||||
.ok_or("User not authenticated")?;
|
||||
|
||||
let persistent_data = UserPersistence::load_user_data(&user_email)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Use custom amounts if set, otherwise use defaults
|
||||
if let Some(custom_amounts) = persistent_data.quick_topup_amounts {
|
||||
Ok(custom_amounts)
|
||||
} else {
|
||||
// Default amounts in user's preferred currency
|
||||
let display_currency = self.currency_service.get_user_preferred_currency(session);
|
||||
|
||||
let default_amounts = if display_currency == "USD" {
|
||||
vec![
|
||||
Decimal::from(10), // $10
|
||||
Decimal::from(25), // $25
|
||||
Decimal::from(50), // $50
|
||||
Decimal::from(100), // $100
|
||||
]
|
||||
} else {
|
||||
// Default fiat amounts (will be converted to USD internally)
|
||||
vec![
|
||||
Decimal::from(10), // $10, €10, etc.
|
||||
Decimal::from(20), // $20, €20, etc.
|
||||
Decimal::from(50), // $50, €50, etc.
|
||||
Decimal::from(100), // $100, €100, etc.
|
||||
]
|
||||
};
|
||||
|
||||
Ok(default_amounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NavbarService {
|
||||
fn default() -> Self {
|
||||
Self::builder().build().unwrap()
|
||||
}
|
||||
}
|
754
src/services/node_marketplace.rs
Normal file
754
src/services/node_marketplace.rs
Normal file
@@ -0,0 +1,754 @@
|
||||
//! Node marketplace service for aggregating farmer nodes into marketplace products
|
||||
//! Follows the established builder pattern for consistent API design
|
||||
|
||||
use crate::models::user::FarmNode;
|
||||
use crate::models::product::Product;
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use crate::services::currency::CurrencyService;
|
||||
use crate::services::slice_calculator::SliceCombination;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Service for converting farmer nodes to marketplace products
|
||||
#[derive(Clone)]
|
||||
pub struct NodeMarketplaceService {
|
||||
currency_service: CurrencyService,
|
||||
include_offline_nodes: bool,
|
||||
price_calculation_method: String,
|
||||
cache_enabled: bool,
|
||||
}
|
||||
|
||||
/// Builder for NodeMarketplaceService following established pattern
|
||||
#[derive(Default)]
|
||||
pub struct NodeMarketplaceServiceBuilder {
|
||||
currency_service: Option<CurrencyService>,
|
||||
include_offline_nodes: Option<bool>,
|
||||
price_calculation_method: Option<String>,
|
||||
cache_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl NodeMarketplaceServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn currency_service(mut self, service: CurrencyService) -> Self {
|
||||
self.currency_service = Some(service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn include_offline_nodes(mut self, include: bool) -> Self {
|
||||
self.include_offline_nodes = Some(include);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn price_calculation_method(mut self, method: impl Into<String>) -> Self {
|
||||
self.price_calculation_method = Some(method.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache_enabled(mut self, enabled: bool) -> Self {
|
||||
self.cache_enabled = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<NodeMarketplaceService, String> {
|
||||
let currency_service = self.currency_service.unwrap_or_else(|| {
|
||||
crate::models::builders::CurrencyServiceBuilder::new()
|
||||
.build()
|
||||
.expect("Failed to create default currency service")
|
||||
});
|
||||
|
||||
Ok(NodeMarketplaceService {
|
||||
currency_service,
|
||||
include_offline_nodes: self.include_offline_nodes.unwrap_or(true),
|
||||
price_calculation_method: self.price_calculation_method.unwrap_or_else(|| "capacity_based".to_string()),
|
||||
cache_enabled: self.cache_enabled.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeMarketplaceService {
|
||||
pub fn builder() -> NodeMarketplaceServiceBuilder {
|
||||
NodeMarketplaceServiceBuilder::new()
|
||||
}
|
||||
|
||||
pub fn new_with_config(
|
||||
currency_service: CurrencyService,
|
||||
include_offline_nodes: bool,
|
||||
price_calculation_method: String,
|
||||
cache_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
currency_service,
|
||||
include_offline_nodes,
|
||||
price_calculation_method,
|
||||
cache_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all farmer nodes as marketplace products
|
||||
pub fn get_all_marketplace_nodes(&self) -> Vec<Product> {
|
||||
let mut all_products = Vec::new();
|
||||
|
||||
// Get all user files from ./user_data/
|
||||
if let Ok(entries) = std::fs::read_dir("./user_data/") {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.ends_with(".json")
|
||||
&& filename.contains("_at_")
|
||||
&& !filename.contains("_cart")
|
||||
&& filename != "session_data.json"
|
||||
{
|
||||
let user_email = filename
|
||||
.trim_end_matches(".json")
|
||||
.replace("_at_", "@")
|
||||
.replace("_", ".");
|
||||
let nodes = UserPersistence::get_user_nodes(&user_email);
|
||||
|
||||
for node in nodes {
|
||||
// Filter by node type and status
|
||||
if node.node_type == "3Node" && (self.include_offline_nodes || self.is_node_online(&node)) {
|
||||
if let Ok(product) = self.convert_node_to_product(&node, &user_email) {
|
||||
all_products.push(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_products
|
||||
}
|
||||
|
||||
/// Convert FarmNode to Product using builder pattern
|
||||
pub fn convert_node_to_product(&self, node: &FarmNode, farmer_email: &str) -> Result<Product, String> {
|
||||
// Calculate price based on node capacity
|
||||
let hourly_price = self.calculate_node_price(node)?;
|
||||
|
||||
// Create product attributes with node specifications
|
||||
let mut attributes = HashMap::new();
|
||||
|
||||
attributes.insert("farmer_email".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "farmer_email".to_string(),
|
||||
value: serde_json::Value::String(farmer_email.to_string()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(1),
|
||||
});
|
||||
|
||||
attributes.insert("node_specs".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "node_specs".to_string(),
|
||||
value: serde_json::json!({
|
||||
"cpu_cores": node.capacity.cpu_cores,
|
||||
"memory_gb": node.capacity.memory_gb,
|
||||
"storage_gb": node.capacity.storage_gb,
|
||||
"bandwidth_mbps": node.capacity.bandwidth_mbps
|
||||
}),
|
||||
attribute_type: crate::models::product::AttributeType::Custom("node_specifications".to_string()),
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(2),
|
||||
});
|
||||
|
||||
attributes.insert("utilization".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "utilization".to_string(),
|
||||
value: serde_json::json!({
|
||||
"cpu_used": node.used_capacity.cpu_cores,
|
||||
"memory_used": node.used_capacity.memory_gb,
|
||||
"storage_used": node.used_capacity.storage_gb,
|
||||
"bandwidth_used": node.used_capacity.bandwidth_mbps
|
||||
}),
|
||||
attribute_type: crate::models::product::AttributeType::Custom("utilization_metrics".to_string()),
|
||||
is_searchable: false,
|
||||
is_filterable: false,
|
||||
display_order: Some(3),
|
||||
});
|
||||
|
||||
attributes.insert("performance".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "performance".to_string(),
|
||||
value: serde_json::json!({
|
||||
"uptime_percentage": node.uptime_percentage,
|
||||
"health_score": node.health_score,
|
||||
"earnings_today": node.earnings_today_usd
|
||||
}),
|
||||
attribute_type: crate::models::product::AttributeType::Custom("performance_metrics".to_string()),
|
||||
is_searchable: false,
|
||||
is_filterable: true,
|
||||
display_order: Some(4),
|
||||
});
|
||||
|
||||
attributes.insert("availability_status".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "availability_status".to_string(),
|
||||
value: serde_json::Value::String(format!("{:?}", node.status)),
|
||||
attribute_type: crate::models::product::AttributeType::Select(vec!["Online".to_string(), "Offline".to_string(), "Maintenance".to_string()]),
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(5),
|
||||
});
|
||||
|
||||
attributes.insert("region".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "region".to_string(),
|
||||
value: serde_json::Value::String(node.region.clone()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(6),
|
||||
});
|
||||
|
||||
// Get farmer display name
|
||||
let farmer_display_name = self.get_farmer_display_name(farmer_email);
|
||||
|
||||
// Create metadata with location
|
||||
let metadata = crate::models::product::ProductMetadata {
|
||||
tags: vec!["3node".to_string(), "hardware".to_string(), node.region.clone()],
|
||||
location: Some(node.location.clone()),
|
||||
rating: Some(node.health_score / 20.0), // Convert health score to 5-star rating
|
||||
review_count: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Use Product builder pattern with add_attribute for each attribute
|
||||
let mut builder = crate::models::product::Product::builder()
|
||||
.id(format!("node_{}", node.id))
|
||||
.name(format!("{} - {}", node.name, farmer_display_name))
|
||||
.description(format!("3Node with {} CPU cores, {} GB RAM, {} GB storage in {}. Uptime: {:.1}%, Health Score: {:.1}",
|
||||
node.capacity.cpu_cores,
|
||||
node.capacity.memory_gb,
|
||||
node.capacity.storage_gb,
|
||||
node.location,
|
||||
node.uptime_percentage,
|
||||
node.health_score))
|
||||
.base_price(hourly_price)
|
||||
.base_currency("USD".to_string())
|
||||
.category_id("hardware".to_string())
|
||||
.provider_id(farmer_email.to_string())
|
||||
.provider_name(farmer_display_name)
|
||||
.metadata(metadata)
|
||||
.availability(if self.is_node_online(node) {
|
||||
crate::models::product::ProductAvailability::Available
|
||||
} else {
|
||||
crate::models::product::ProductAvailability::Unavailable
|
||||
});
|
||||
|
||||
// Add each attribute individually
|
||||
for (key, attribute) in attributes {
|
||||
builder = builder.add_attribute(key, attribute);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Calculate pricing based on node capacity
|
||||
fn calculate_node_price(&self, node: &FarmNode) -> Result<Decimal, String> {
|
||||
match self.price_calculation_method.as_str() {
|
||||
"capacity_based" => {
|
||||
// Price based on total capacity: $0.10 per CPU core + $0.05 per GB RAM + $0.01 per GB storage
|
||||
let cpu_price = Decimal::from(node.capacity.cpu_cores) * Decimal::from_str("0.10").unwrap();
|
||||
let ram_price = Decimal::from(node.capacity.memory_gb) * Decimal::from_str("0.05").unwrap();
|
||||
let storage_price = Decimal::from(node.capacity.storage_gb) * Decimal::from_str("0.01").unwrap();
|
||||
Ok(cpu_price + ram_price + storage_price)
|
||||
},
|
||||
"performance_based" => {
|
||||
// Price based on performance metrics
|
||||
let base_price = Decimal::from_str("5.00").unwrap();
|
||||
let performance_multiplier = Decimal::from_str(&format!("{:.2}", node.health_score / 100.0)).unwrap();
|
||||
Ok(base_price * performance_multiplier)
|
||||
},
|
||||
"utilization_based" => {
|
||||
// Price based on available capacity
|
||||
let available_cpu = node.capacity.cpu_cores - node.used_capacity.cpu_cores;
|
||||
let available_ram = node.capacity.memory_gb - node.used_capacity.memory_gb;
|
||||
let cpu_price = Decimal::from(available_cpu) * Decimal::from_str("0.15").unwrap();
|
||||
let ram_price = Decimal::from(available_ram) * Decimal::from_str("0.08").unwrap();
|
||||
Ok(cpu_price + ram_price)
|
||||
},
|
||||
_ => Ok(Decimal::from_str("1.00").unwrap()) // Default price
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if node is online
|
||||
fn is_node_online(&self, node: &FarmNode) -> bool {
|
||||
format!("{}", node.status) == "Online"
|
||||
}
|
||||
|
||||
/// Get farmer display name from email
|
||||
fn get_farmer_display_name(&self, farmer_email: &str) -> String {
|
||||
// Try to get actual name from persistent data
|
||||
if let Some(user_data) = UserPersistence::load_user_data(farmer_email) {
|
||||
if let Some(name) = user_data.name {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to email username
|
||||
farmer_email.split('@').next().unwrap_or("Farmer").to_string()
|
||||
}
|
||||
|
||||
/// Apply marketplace filters to node products
|
||||
pub fn apply_marketplace_filters(&self, products: &[Product], filters: &HashMap<String, String>) -> Vec<Product> {
|
||||
let mut filtered = products.to_vec();
|
||||
|
||||
// Filter by location
|
||||
if let Some(location) = filters.get("location") {
|
||||
if !location.is_empty() {
|
||||
filtered.retain(|p| p.metadata.location.as_ref().map_or(false, |l| l.to_lowercase().contains(&location.to_lowercase())));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by price range
|
||||
if let Some(min_price) = filters.get("min_price").and_then(|p| p.parse::<f64>().ok()) {
|
||||
filtered.retain(|p| p.base_price.to_f64().unwrap_or(0.0) >= min_price);
|
||||
}
|
||||
|
||||
if let Some(max_price) = filters.get("max_price").and_then(|p| p.parse::<f64>().ok()) {
|
||||
filtered.retain(|p| p.base_price.to_f64().unwrap_or(0.0) <= max_price);
|
||||
}
|
||||
|
||||
// Filter by CPU cores
|
||||
if let Some(min_cpu) = filters.get("min_cpu").and_then(|c| c.parse::<i32>().ok()) {
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("node_specs")
|
||||
.and_then(|specs| specs.value.get("cpu_cores"))
|
||||
.and_then(|cpu| cpu.as_i64())
|
||||
.map_or(false, |cpu| cpu >= min_cpu as i64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by RAM
|
||||
if let Some(min_ram) = filters.get("min_ram").and_then(|r| r.parse::<i32>().ok()) {
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("node_specs")
|
||||
.and_then(|specs| specs.value.get("memory_gb"))
|
||||
.and_then(|ram| ram.as_i64())
|
||||
.map_or(false, |ram| ram >= min_ram as i64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by storage
|
||||
if let Some(min_storage) = filters.get("min_storage").and_then(|s| s.parse::<i32>().ok()) {
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("node_specs")
|
||||
.and_then(|specs| specs.value.get("storage_gb"))
|
||||
.and_then(|storage| storage.as_i64())
|
||||
.map_or(false, |storage| storage >= min_storage as i64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by region
|
||||
if let Some(region) = filters.get("region") {
|
||||
if !region.is_empty() {
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("region")
|
||||
.and_then(|r| r.value.as_str())
|
||||
.map_or(false, |r| r.to_lowercase().contains(®ion.to_lowercase()))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Apply slice-specific filters to slice products
|
||||
pub fn apply_slice_filters(&self, products: &[Product], filters: &std::collections::HashMap<String, String>) -> Vec<Product> {
|
||||
let mut filtered = products.to_vec();
|
||||
|
||||
// Filter by location
|
||||
if let Some(location) = filters.get("location") {
|
||||
if !location.is_empty() {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.metadata.location.as_ref().map_or(false, |l| l.to_lowercase().contains(&location.to_lowercase()))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by price range
|
||||
if let Some(min_price) = filters.get("min_price").and_then(|p| p.parse::<f64>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| p.base_price.to_f64().unwrap_or(0.0) >= min_price);
|
||||
}
|
||||
|
||||
if let Some(max_price) = filters.get("max_price").and_then(|p| p.parse::<f64>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| p.base_price.to_f64().unwrap_or(0.0) <= max_price);
|
||||
}
|
||||
|
||||
// Filter by CPU cores (support both min_cpu and min_cores)
|
||||
if let Some(min_cpu) = filters.get("min_cpu").or_else(|| filters.get("min_cores")).and_then(|c| c.parse::<u32>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("cpu_cores")
|
||||
.and_then(|cpu| cpu.value.as_u64())
|
||||
.map_or(false, |cpu| cpu >= min_cpu as u64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by RAM (support both min_ram and min_memory)
|
||||
if let Some(min_ram) = filters.get("min_ram").or_else(|| filters.get("min_memory")).and_then(|r| r.parse::<u32>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("memory_gb")
|
||||
.and_then(|ram| ram.value.as_u64())
|
||||
.map_or(false, |ram| ram >= min_ram as u64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by storage
|
||||
if let Some(min_storage) = filters.get("min_storage").and_then(|s| s.parse::<u32>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("storage_gb")
|
||||
.and_then(|storage| storage.value.as_u64())
|
||||
.map_or(false, |storage| storage >= min_storage as u64)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by uptime percentage
|
||||
if let Some(min_uptime) = filters.get("min_uptime").and_then(|u| u.parse::<f64>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("uptime_percentage")
|
||||
.and_then(|uptime| uptime.value.as_f64())
|
||||
.map_or(false, |uptime| uptime >= min_uptime)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by bandwidth
|
||||
if let Some(min_bandwidth) = filters.get("min_bandwidth").and_then(|b| b.parse::<u32>().ok()) {
|
||||
let before_count = filtered.len();
|
||||
filtered.retain(|p| {
|
||||
p.attributes.get("bandwidth_mbps")
|
||||
.and_then(|bandwidth| bandwidth.value.as_u64())
|
||||
.map_or(true, |bandwidth| bandwidth >= min_bandwidth as u64) // Default to true if no bandwidth info
|
||||
});
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Get slice marketplace statistics
|
||||
pub fn get_slice_marketplace_statistics(&self) -> serde_json::Value {
|
||||
let all_slices = self.get_all_slice_combinations();
|
||||
|
||||
let mut total_slices = 0u32;
|
||||
let mut total_cpu_cores = 0u32;
|
||||
let mut total_memory_gb = 0u32;
|
||||
let mut total_storage_gb = 0u32;
|
||||
let mut unique_farmers = std::collections::HashSet::new();
|
||||
let mut unique_locations = std::collections::HashSet::new();
|
||||
|
||||
for product in &all_slices {
|
||||
if let (Some(cpu), Some(memory), Some(storage), Some(quantity)) = (
|
||||
product.attributes.get("cpu_cores").and_then(|c| c.value.as_u64()),
|
||||
product.attributes.get("memory_gb").and_then(|m| m.value.as_u64()),
|
||||
product.attributes.get("storage_gb").and_then(|s| s.value.as_u64()),
|
||||
product.attributes.get("slice_specs")
|
||||
.and_then(|specs| specs.value.get("quantity_available"))
|
||||
.and_then(|q| q.as_u64())
|
||||
) {
|
||||
total_slices += quantity as u32;
|
||||
total_cpu_cores += (cpu * quantity) as u32;
|
||||
total_memory_gb += (memory * quantity) as u32;
|
||||
total_storage_gb += (storage * quantity) as u32;
|
||||
}
|
||||
|
||||
if let Some(farmer) = product.attributes.get("farmer_email").and_then(|f| f.value.as_str()) {
|
||||
unique_farmers.insert(farmer.to_string());
|
||||
}
|
||||
|
||||
if let Some(location) = &product.metadata.location {
|
||||
unique_locations.insert(location.clone());
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"total_slice_products": all_slices.len(),
|
||||
"total_available_slices": total_slices,
|
||||
"total_cpu_cores": total_cpu_cores,
|
||||
"total_memory_gb": total_memory_gb,
|
||||
"total_storage_gb": total_storage_gb,
|
||||
"unique_farmers": unique_farmers.len(),
|
||||
"unique_locations": unique_locations.len(),
|
||||
"farmers": unique_farmers.into_iter().collect::<Vec<_>>(),
|
||||
"locations": unique_locations.into_iter().collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get available regions from all nodes
|
||||
pub fn get_available_regions(&self) -> Vec<String> {
|
||||
let products = self.get_all_marketplace_nodes();
|
||||
let mut regions: Vec<String> = products.iter()
|
||||
.filter_map(|p| p.attributes.get("region"))
|
||||
.filter_map(|r| r.value.as_str())
|
||||
.map(|r| r.to_string())
|
||||
.collect();
|
||||
|
||||
regions.sort();
|
||||
regions.dedup();
|
||||
regions
|
||||
}
|
||||
|
||||
/// Get node capacity statistics
|
||||
pub fn get_capacity_statistics(&self) -> serde_json::Value {
|
||||
let products = self.get_all_marketplace_nodes();
|
||||
|
||||
let mut total_cpu = 0i64;
|
||||
let mut total_ram = 0i64;
|
||||
let mut total_storage = 0i64;
|
||||
let mut node_count = 0;
|
||||
|
||||
for product in &products {
|
||||
if let Some(specs) = product.attributes.get("node_specs") {
|
||||
if let (Some(cpu), Some(ram), Some(storage)) = (
|
||||
specs.value.get("cpu_cores").and_then(|c| c.as_i64()),
|
||||
specs.value.get("memory_gb").and_then(|r| r.as_i64()),
|
||||
specs.value.get("storage_gb").and_then(|s| s.as_i64())
|
||||
) {
|
||||
total_cpu += cpu;
|
||||
total_ram += ram;
|
||||
total_storage += storage;
|
||||
node_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"total_nodes": node_count,
|
||||
"total_cpu_cores": total_cpu,
|
||||
"total_ram_gb": total_ram,
|
||||
"total_storage_gb": total_storage,
|
||||
"average_cpu_per_node": if node_count > 0 { total_cpu / node_count } else { 0 },
|
||||
"average_ram_per_node": if node_count > 0 { total_ram / node_count } else { 0 },
|
||||
"average_storage_per_node": if node_count > 0 { total_storage / node_count } else { 0 }
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all available slice combinations as marketplace products
|
||||
pub fn get_all_slice_combinations(&self) -> Vec<Product> {
|
||||
let mut all_slice_products = Vec::new();
|
||||
|
||||
// Read all user data files to find farmers with nodes
|
||||
if let Ok(entries) = std::fs::read_dir("./user_data") {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.ends_with(".json")
|
||||
&& filename.contains("_at_")
|
||||
&& !filename.contains("_cart")
|
||||
&& filename != "session_data.json"
|
||||
{
|
||||
let user_email = filename
|
||||
.trim_end_matches(".json")
|
||||
.replace("_at_", "@")
|
||||
.replace("_", ".");
|
||||
|
||||
// Load user data directly to avoid infinite loops
|
||||
if let Some(user_data) = UserPersistence::load_user_data(&user_email) {
|
||||
|
||||
for node in &user_data.nodes {
|
||||
|
||||
// Only include online nodes with available slices
|
||||
if self.is_node_online(&node) && !node.available_combinations.is_empty() {
|
||||
|
||||
for combination in &node.available_combinations {
|
||||
if let Some(qty) = combination.get("quantity_available").and_then(|v| v.as_u64()) {
|
||||
if qty > 0 {
|
||||
if let Ok(slice_combination) = serde_json::from_value::<crate::services::slice_calculator::SliceCombination>(combination.clone()) {
|
||||
match self.convert_slice_combination_to_product(&slice_combination, &node, &user_email) {
|
||||
Ok(product) => {
|
||||
all_slice_products.push(product);
|
||||
},
|
||||
Err(e) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all_slice_products
|
||||
}
|
||||
|
||||
/// Convert slice combination to marketplace product
|
||||
pub fn convert_slice_combination_to_product(
|
||||
&self,
|
||||
combination: &SliceCombination,
|
||||
_node: &FarmNode,
|
||||
farmer_email: &str
|
||||
) -> Result<Product, String> {
|
||||
let mut attributes = HashMap::new();
|
||||
|
||||
// Farmer information
|
||||
attributes.insert("farmer_email".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "farmer_email".to_string(),
|
||||
value: serde_json::Value::String(farmer_email.to_string()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(1),
|
||||
});
|
||||
|
||||
// Node ID for Deploy button
|
||||
attributes.insert("node_id".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "node_id".to_string(),
|
||||
value: serde_json::Value::String(combination.node_id.clone()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(2),
|
||||
});
|
||||
|
||||
// Combination ID for Deploy button
|
||||
attributes.insert("combination_id".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "combination_id".to_string(),
|
||||
value: serde_json::Value::String(combination.id.clone()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(3),
|
||||
});
|
||||
|
||||
// Individual CPU/Memory/Storage attributes for template compatibility
|
||||
attributes.insert("cpu_cores".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "cpu_cores".to_string(),
|
||||
value: serde_json::Value::Number(serde_json::Number::from(combination.cpu_cores)),
|
||||
attribute_type: crate::models::product::AttributeType::Number,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(4),
|
||||
});
|
||||
|
||||
attributes.insert("memory_gb".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "memory_gb".to_string(),
|
||||
value: serde_json::Value::Number(serde_json::Number::from(combination.memory_gb)),
|
||||
attribute_type: crate::models::product::AttributeType::Number,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(5),
|
||||
});
|
||||
|
||||
attributes.insert("storage_gb".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "storage_gb".to_string(),
|
||||
value: serde_json::Value::Number(serde_json::Number::from(combination.storage_gb)),
|
||||
attribute_type: crate::models::product::AttributeType::Number,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(6),
|
||||
});
|
||||
|
||||
// Uptime percentage for template display
|
||||
attributes.insert("uptime_percentage".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "uptime_percentage".to_string(),
|
||||
value: serde_json::Value::Number(serde_json::Number::from_f64(combination.node_uptime_percentage as f64).unwrap_or(serde_json::Number::from(99))),
|
||||
attribute_type: crate::models::product::AttributeType::Number,
|
||||
is_searchable: false,
|
||||
is_filterable: true,
|
||||
display_order: Some(7),
|
||||
});
|
||||
|
||||
// Location for filtering and template compatibility
|
||||
attributes.insert("location".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "location".to_string(),
|
||||
value: serde_json::Value::String(combination.node_location.clone()),
|
||||
attribute_type: crate::models::product::AttributeType::Text,
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(8),
|
||||
});
|
||||
|
||||
// Slice specifications (detailed)
|
||||
attributes.insert("slice_specs".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "slice_specs".to_string(),
|
||||
value: serde_json::json!({
|
||||
"cpu_cores": combination.cpu_cores,
|
||||
"memory_gb": combination.memory_gb,
|
||||
"storage_gb": combination.storage_gb,
|
||||
"multiplier": combination.multiplier,
|
||||
"base_slices_required": combination.base_slices_required,
|
||||
"quantity_available": combination.quantity_available,
|
||||
"price_per_hour": combination.price_per_hour
|
||||
}),
|
||||
attribute_type: crate::models::product::AttributeType::Custom("slice_specifications".to_string()),
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(9),
|
||||
});
|
||||
|
||||
// Inherited node characteristics
|
||||
attributes.insert("node_characteristics".to_string(), crate::models::product::ProductAttribute {
|
||||
key: "node_characteristics".to_string(),
|
||||
value: serde_json::json!({
|
||||
"uptime_percentage": combination.node_uptime_percentage,
|
||||
"bandwidth_mbps": combination.node_bandwidth_mbps,
|
||||
"location": combination.node_location,
|
||||
"certification_type": combination.node_certification_type,
|
||||
"node_id": combination.node_id
|
||||
}),
|
||||
attribute_type: crate::models::product::AttributeType::Custom("node_inheritance".to_string()),
|
||||
is_searchable: true,
|
||||
is_filterable: true,
|
||||
display_order: Some(10),
|
||||
});
|
||||
|
||||
// Get farmer display name
|
||||
let farmer_display_name = self.get_farmer_display_name(farmer_email);
|
||||
|
||||
// Create metadata
|
||||
let metadata = crate::models::product::ProductMetadata {
|
||||
location: Some(combination.node_location.clone()),
|
||||
custom_fields: {
|
||||
let mut fields = std::collections::HashMap::new();
|
||||
fields.insert("provider".to_string(), serde_json::Value::String(farmer_display_name.clone()));
|
||||
fields.insert("certification".to_string(), serde_json::Value::String(combination.node_certification_type.clone()));
|
||||
fields.insert("created_at".to_string(), serde_json::Value::String(chrono::Utc::now().to_rfc3339()));
|
||||
fields.insert("updated_at".to_string(), serde_json::Value::String(chrono::Utc::now().to_rfc3339()));
|
||||
fields.insert("tags".to_string(), serde_json::json!(vec![
|
||||
"slice".to_string(),
|
||||
"compute".to_string(),
|
||||
combination.node_location.clone(),
|
||||
format!("{}x", combination.multiplier)
|
||||
]));
|
||||
fields
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Build product using the builder pattern
|
||||
let mut product = crate::models::product::Product::builder()
|
||||
.id(format!("slice_{}_{}", combination.node_id, combination.id))
|
||||
.name(format!("{} Slice ({}x Base Unit)", farmer_display_name, combination.multiplier))
|
||||
.description(format!(
|
||||
"Compute slice with {} vCPU, {}GB RAM, {}GB storage from {} ({}% uptime)",
|
||||
combination.cpu_cores,
|
||||
combination.memory_gb,
|
||||
combination.storage_gb,
|
||||
combination.node_location,
|
||||
combination.node_uptime_percentage
|
||||
))
|
||||
.category_id("compute_slices".to_string())
|
||||
.base_price(combination.price_per_hour)
|
||||
.base_currency("USD".to_string())
|
||||
.provider_id(farmer_email.to_string())
|
||||
.provider_name(farmer_display_name)
|
||||
.metadata(metadata)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build slice product: {}", e))?;
|
||||
|
||||
// Add the attributes to the product
|
||||
product.attributes = attributes;
|
||||
|
||||
Ok(product)
|
||||
}
|
||||
|
||||
}
|
323
src/services/node_rental.rs
Normal file
323
src/services/node_rental.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
//! Node rental service for managing node rentals and farmer earnings
|
||||
//! Follows the established builder pattern for consistent API design
|
||||
|
||||
use crate::models::user::{NodeRental, NodeRentalType, NodeRentalStatus, FarmerRentalEarning, PaymentStatus, NodeAvailabilityStatus};
|
||||
use crate::services::user_persistence::{UserPersistence, ProductRental};
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::{Utc, Duration};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Service for node rental operations
|
||||
#[derive(Clone)]
|
||||
pub struct NodeRentalService {
|
||||
auto_billing_enabled: bool,
|
||||
notification_enabled: bool,
|
||||
conflict_prevention: bool,
|
||||
}
|
||||
|
||||
/// Builder for NodeRentalService
|
||||
#[derive(Default)]
|
||||
pub struct NodeRentalServiceBuilder {
|
||||
auto_billing_enabled: Option<bool>,
|
||||
notification_enabled: Option<bool>,
|
||||
conflict_prevention: Option<bool>,
|
||||
}
|
||||
|
||||
impl NodeRentalServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn auto_billing_enabled(mut self, enabled: bool) -> Self {
|
||||
self.auto_billing_enabled = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn notification_enabled(mut self, enabled: bool) -> Self {
|
||||
self.notification_enabled = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn conflict_prevention(mut self, enabled: bool) -> Self {
|
||||
self.conflict_prevention = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<NodeRentalService, String> {
|
||||
Ok(NodeRentalService {
|
||||
auto_billing_enabled: self.auto_billing_enabled.unwrap_or(true),
|
||||
notification_enabled: self.notification_enabled.unwrap_or(true),
|
||||
conflict_prevention: self.conflict_prevention.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeRentalService {
|
||||
pub fn builder() -> NodeRentalServiceBuilder {
|
||||
NodeRentalServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Rent a node product (slice or full node)
|
||||
pub fn rent_node_product(
|
||||
&self,
|
||||
product_id: &str,
|
||||
renter_email: &str,
|
||||
duration_months: u32,
|
||||
rental_type: NodeRentalType,
|
||||
monthly_cost: Decimal,
|
||||
) -> Result<(NodeRental, FarmerRentalEarning), String> {
|
||||
// Extract node ID from product ID
|
||||
let node_id = if product_id.starts_with("fullnode_") {
|
||||
product_id.strip_prefix("fullnode_").unwrap_or(product_id)
|
||||
} else if product_id.starts_with("slice_") {
|
||||
// For slice products, we need to find the associated node
|
||||
// This would typically come from the product metadata
|
||||
product_id.strip_prefix("slice_").unwrap_or(product_id)
|
||||
} else {
|
||||
product_id
|
||||
};
|
||||
|
||||
// Check for conflicts if enabled
|
||||
if self.conflict_prevention {
|
||||
self.check_rental_conflicts(node_id, &rental_type)?;
|
||||
}
|
||||
|
||||
// Calculate rental period
|
||||
let start_date = Utc::now();
|
||||
let end_date = start_date + Duration::days((duration_months * 30) as i64);
|
||||
|
||||
// Create rental record
|
||||
let rental = crate::models::builders::NodeRentalBuilder::new()
|
||||
.node_id(node_id.to_string())
|
||||
.renter_email(renter_email.to_string())
|
||||
.rental_type(rental_type.clone())
|
||||
.monthly_cost(monthly_cost)
|
||||
.start_date(start_date)
|
||||
.end_date(end_date)
|
||||
.status(NodeRentalStatus::Active)
|
||||
.auto_renewal(false)
|
||||
.payment_method("USD".to_string())
|
||||
.build()?;
|
||||
|
||||
// Create farmer earning record
|
||||
let farmer_earning = crate::models::builders::FarmerRentalEarningBuilder::new()
|
||||
.node_id(node_id.to_string())
|
||||
.rental_id(rental.id.clone())
|
||||
.renter_email(renter_email.to_string())
|
||||
.amount(monthly_cost)
|
||||
.currency("USD".to_string())
|
||||
.earning_date(start_date)
|
||||
.rental_type(rental_type)
|
||||
.payment_status(PaymentStatus::Completed)
|
||||
.build()?;
|
||||
|
||||
// Find the farmer who owns this node
|
||||
let farmer_email = self.find_node_owner(node_id)?;
|
||||
|
||||
// Save rental to renter's data
|
||||
self.save_rental_to_user(&rental, renter_email, product_id)?;
|
||||
|
||||
// Save earning to farmer's data
|
||||
self.save_earning_to_farmer(&farmer_earning, &farmer_email)?;
|
||||
|
||||
// Update node availability status
|
||||
self.update_node_availability(node_id, &farmer_email)?;
|
||||
|
||||
Ok((rental, farmer_earning))
|
||||
}
|
||||
|
||||
/// Check for rental conflicts
|
||||
fn check_rental_conflicts(&self, node_id: &str, rental_type: &NodeRentalType) -> Result<(), String> {
|
||||
// Find the farmer who owns this node
|
||||
let farmer_email = self.find_node_owner(node_id)?;
|
||||
|
||||
if let Some(farmer_data) = UserPersistence::load_user_data(&farmer_email) {
|
||||
// Check existing rentals for this node
|
||||
let existing_rentals: Vec<_> = farmer_data.node_rentals.iter()
|
||||
.filter(|r| r.node_id == node_id && r.is_active())
|
||||
.collect();
|
||||
|
||||
for existing_rental in existing_rentals {
|
||||
match (&existing_rental.rental_type, rental_type) {
|
||||
(NodeRentalType::FullNode, _) => {
|
||||
return Err("Cannot rent: full node is currently rented".to_string());
|
||||
}
|
||||
(_, NodeRentalType::FullNode) => {
|
||||
return Err("Cannot rent full node: slices are currently rented".to_string());
|
||||
}
|
||||
(NodeRentalType::Slice, NodeRentalType::Slice) => {
|
||||
// Check if there's enough capacity for additional slices
|
||||
// This would require more complex capacity tracking
|
||||
// For now, we'll allow multiple slice rentals
|
||||
}
|
||||
(NodeRentalType::SliceRental, NodeRentalType::SliceRental) => {
|
||||
// Allow multiple slice rentals
|
||||
}
|
||||
(NodeRentalType::SliceRental, NodeRentalType::Slice) => {
|
||||
// Allow slice rental when slice rental exists
|
||||
}
|
||||
(NodeRentalType::Slice, NodeRentalType::SliceRental) => {
|
||||
// Allow slice rental when slice exists
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the farmer who owns a specific node
|
||||
fn find_node_owner(&self, node_id: &str) -> Result<String, String> {
|
||||
// Scan all user files to find the node owner
|
||||
if let Ok(entries) = std::fs::read_dir("./user_data/") {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.ends_with(".json")
|
||||
&& filename.contains("_at_")
|
||||
&& !filename.contains("_cart")
|
||||
&& filename != "session_data.json"
|
||||
{
|
||||
let user_email = filename
|
||||
.trim_end_matches(".json")
|
||||
.replace("_at_", "@")
|
||||
.replace("_", ".");
|
||||
|
||||
if let Some(user_data) = UserPersistence::load_user_data(&user_email) {
|
||||
if user_data.nodes.iter().any(|node| node.id == node_id) {
|
||||
return Ok(user_email);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Node owner not found for node: {}", node_id))
|
||||
}
|
||||
|
||||
/// Save rental record to user's persistent data
|
||||
fn save_rental_to_user(&self, rental: &NodeRental, user_email: &str, product_id: &str) -> Result<(), String> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.unwrap_or_else(|| self.create_default_user_data(user_email));
|
||||
|
||||
// Add to node rentals
|
||||
user_data.node_rentals.push(rental.clone());
|
||||
|
||||
// Add to product rentals for dashboard display
|
||||
let product_rental = ProductRental {
|
||||
id: rental.id.clone(),
|
||||
product_id: product_id.to_string(),
|
||||
product_name: format!("Node Rental {}", product_id),
|
||||
rental_type: "node".to_string(),
|
||||
customer_email: user_email.to_string(),
|
||||
provider_email: "unknown@provider.com".to_string(), // TODO: Get from actual provider
|
||||
monthly_cost: rental.monthly_cost,
|
||||
status: "Active".to_string(),
|
||||
rental_id: rental.id.clone(),
|
||||
start_date: rental.start_date.to_rfc3339(),
|
||||
end_date: rental.end_date.to_rfc3339(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
};
|
||||
user_data.active_product_rentals.push(product_rental);
|
||||
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save earning record to farmer's persistent data
|
||||
fn save_earning_to_farmer(&self, earning: &FarmerRentalEarning, farmer_email: &str) -> Result<(), String> {
|
||||
let mut farmer_data = UserPersistence::load_user_data(farmer_email)
|
||||
.unwrap_or_else(|| self.create_default_user_data(farmer_email));
|
||||
|
||||
// Add to farmer rental earnings
|
||||
farmer_data.farmer_rental_earnings.push(earning.clone());
|
||||
|
||||
// Update wallet balance
|
||||
farmer_data.wallet_balance_usd += earning.amount;
|
||||
|
||||
UserPersistence::save_user_data(&farmer_data)
|
||||
.map_err(|e| format!("Failed to save farmer data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update node availability status based on current rentals
|
||||
fn update_node_availability(&self, node_id: &str, farmer_email: &str) -> Result<(), String> {
|
||||
let mut farmer_data = UserPersistence::load_user_data(farmer_email)
|
||||
.ok_or("Farmer data not found")?;
|
||||
|
||||
if let Some(node) = farmer_data.nodes.iter_mut().find(|n| n.id == node_id) {
|
||||
// Count active rentals for this node
|
||||
let active_rentals: Vec<_> = farmer_data.node_rentals.iter()
|
||||
.filter(|r| r.node_id == node_id && r.is_active())
|
||||
.collect();
|
||||
|
||||
node.availability_status = if active_rentals.is_empty() {
|
||||
NodeAvailabilityStatus::Available
|
||||
} else if active_rentals.iter().any(|r| matches!(r.rental_type, NodeRentalType::FullNode)) {
|
||||
NodeAvailabilityStatus::FullyRented
|
||||
} else {
|
||||
NodeAvailabilityStatus::PartiallyRented
|
||||
};
|
||||
|
||||
UserPersistence::save_user_data(&farmer_data)
|
||||
.map_err(|e| format!("Failed to update node availability: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create default user data structure using centralized builder
|
||||
fn create_default_user_data(&self, user_email: &str) -> crate::services::user_persistence::UserPersistentData {
|
||||
crate::models::builders::SessionDataBuilder::new_user(user_email)
|
||||
}
|
||||
|
||||
/// Get active rentals for a user
|
||||
pub fn get_user_rentals(&self, user_email: &str) -> Vec<NodeRental> {
|
||||
if let Some(user_data) = UserPersistence::load_user_data(user_email) {
|
||||
user_data.node_rentals.into_iter()
|
||||
.filter(|r| r.is_active())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get farmer earnings from rentals
|
||||
pub fn get_farmer_rental_earnings(&self, farmer_email: &str) -> Vec<FarmerRentalEarning> {
|
||||
if let Some(farmer_data) = UserPersistence::load_user_data(farmer_email) {
|
||||
farmer_data.farmer_rental_earnings
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a rental
|
||||
pub fn cancel_rental(&self, rental_id: &str, user_email: &str) -> Result<(), String> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or("User data not found")?;
|
||||
|
||||
// Find and update the rental
|
||||
if let Some(rental) = user_data.node_rentals.iter_mut().find(|r| r.id == rental_id) {
|
||||
rental.status = NodeRentalStatus::Cancelled;
|
||||
|
||||
// Update product rental status
|
||||
if let Some(product_rental) = user_data.active_product_rentals.iter_mut().find(|pr| pr.id == rental_id) {
|
||||
product_rental.status = "Cancelled".to_string();
|
||||
}
|
||||
|
||||
// Update node availability
|
||||
let farmer_email = self.find_node_owner(&rental.node_id)?;
|
||||
self.update_node_availability(&rental.node_id, &farmer_email)?;
|
||||
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Rental not found".to_string())
|
||||
}
|
||||
}
|
||||
}
|
1242
src/services/order.rs
Normal file
1242
src/services/order.rs
Normal file
File diff suppressed because it is too large
Load Diff
225
src/services/pool_service.rs
Normal file
225
src/services/pool_service.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use crate::models::pool::*;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct PoolService {
|
||||
pools: Arc<Mutex<HashMap<String, LiquidityPool>>>,
|
||||
analytics: Arc<Mutex<PoolAnalytics>>,
|
||||
}
|
||||
|
||||
impl PoolService {
|
||||
pub fn new() -> Self {
|
||||
let mut pools = HashMap::new();
|
||||
|
||||
// Initialize Credits-Fiat Pool
|
||||
pools.insert("credits-fiat".to_string(), LiquidityPool {
|
||||
id: "credits-fiat".to_string(),
|
||||
name: "Credits-Fiat Pool".to_string(),
|
||||
token_a: "USD".to_string(),
|
||||
token_b: "EUR".to_string(),
|
||||
reserve_a: dec!(125000), // 125K USD
|
||||
reserve_b: dec!(106250), // 106.25K EUR (0.85 rate)
|
||||
exchange_rate: dec!(0.85), // 1 USD = 0.85 EUR
|
||||
liquidity: dec!(125000),
|
||||
volume_24h: dec!(50000),
|
||||
fee_percentage: dec!(0.003), // 0.3%
|
||||
status: PoolStatus::Active,
|
||||
});
|
||||
|
||||
// Initialize Credits-TFT Pool
|
||||
pools.insert("credits-tft".to_string(), LiquidityPool {
|
||||
id: "credits-tft".to_string(),
|
||||
name: "Credits-TFT Pool".to_string(),
|
||||
token_a: "USD".to_string(),
|
||||
token_b: "TFT".to_string(),
|
||||
reserve_a: dec!(25000), // 25K USD
|
||||
reserve_b: dec!(125000), // 125K TFT
|
||||
exchange_rate: dec!(5.0), // 1 USD = 5 TFT
|
||||
liquidity: dec!(25000),
|
||||
volume_24h: dec!(25000),
|
||||
fee_percentage: dec!(0.005), // 0.5%
|
||||
status: PoolStatus::Active,
|
||||
});
|
||||
|
||||
// Initialize Credits-PEAQ Pool
|
||||
pools.insert("credits-peaq".to_string(), LiquidityPool {
|
||||
id: "credits-peaq".to_string(),
|
||||
name: "Credits-PEAQ Pool".to_string(),
|
||||
token_a: "USD".to_string(),
|
||||
token_b: "PEAQ".to_string(),
|
||||
reserve_a: dec!(10000), // 10K USD
|
||||
reserve_b: dec!(200000), // 200K PEAQ
|
||||
exchange_rate: dec!(20.0), // 1 USD = 20 PEAQ
|
||||
liquidity: dec!(10000),
|
||||
volume_24h: dec!(15000),
|
||||
fee_percentage: dec!(0.007), // 0.7%
|
||||
status: PoolStatus::Active,
|
||||
});
|
||||
|
||||
Self {
|
||||
pools: Arc::new(Mutex::new(pools)),
|
||||
analytics: Arc::new(Mutex::new(Self::generate_mock_analytics())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_config(
|
||||
initial_pools: HashMap<String, crate::models::pool::LiquidityPool>,
|
||||
_analytics_enabled: bool,
|
||||
) -> Self {
|
||||
let _pools: HashMap<String, crate::models::pool::LiquidityPool> = HashMap::new();
|
||||
|
||||
// If no initial pools provided, use defaults
|
||||
if initial_pools.is_empty() {
|
||||
return Self::new();
|
||||
}
|
||||
|
||||
// Convert Pool to LiquidityPool (assuming they're compatible)
|
||||
// For now, use default pools since the types might be different
|
||||
return Self::new();
|
||||
}
|
||||
|
||||
pub fn builder() -> crate::models::builders::PoolServiceBuilder {
|
||||
crate::models::builders::PoolServiceBuilder::new()
|
||||
}
|
||||
|
||||
pub fn get_pool(&self, pool_id: &str) -> Option<LiquidityPool> {
|
||||
self.pools.lock().unwrap().get(pool_id).cloned()
|
||||
}
|
||||
|
||||
pub fn get_all_pools(&self) -> Vec<LiquidityPool> {
|
||||
self.pools.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn calculate_exchange(&self, pool_id: &str, from_token: &str, amount: Decimal) -> Option<(Decimal, Decimal)> {
|
||||
let pools = self.pools.lock().unwrap();
|
||||
let pool = pools.get(pool_id)?;
|
||||
|
||||
let (receive_amount, fee) = match (from_token, pool.token_a.as_str(), pool.token_b.as_str()) {
|
||||
("USD", "USD", _) => {
|
||||
// USD to other token
|
||||
let gross_amount = amount * pool.exchange_rate;
|
||||
let fee = gross_amount * pool.fee_percentage;
|
||||
(gross_amount - fee, fee)
|
||||
},
|
||||
(_, "USD", token_b) if *from_token == *token_b => {
|
||||
// Other token to USD
|
||||
let gross_amount = amount / pool.exchange_rate;
|
||||
let fee = gross_amount * pool.fee_percentage;
|
||||
(gross_amount - fee, fee)
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((receive_amount, fee))
|
||||
}
|
||||
|
||||
pub fn execute_exchange(&self, request: &ExchangeRequest) -> ExchangeResponse {
|
||||
let mut pools = self.pools.lock().unwrap();
|
||||
let pool = match pools.get_mut(&request.pool_id) {
|
||||
Some(p) => p,
|
||||
None => return ExchangeResponse {
|
||||
success: false,
|
||||
message: "Pool not found".to_string(),
|
||||
transaction_id: None,
|
||||
from_amount: None,
|
||||
to_amount: None,
|
||||
exchange_rate: None,
|
||||
fee: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Calculate exchange amounts
|
||||
let (receive_amount, fee) = match self.calculate_exchange(&request.pool_id, &request.from_token, request.amount) {
|
||||
Some(amounts) => amounts,
|
||||
None => return ExchangeResponse {
|
||||
success: false,
|
||||
message: "Invalid exchange pair".to_string(),
|
||||
transaction_id: None,
|
||||
from_amount: None,
|
||||
to_amount: None,
|
||||
exchange_rate: None,
|
||||
fee: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Check minimum receive amount
|
||||
if let Some(min_receive) = request.min_receive {
|
||||
if receive_amount < min_receive {
|
||||
return ExchangeResponse {
|
||||
success: false,
|
||||
message: "Slippage tolerance exceeded".to_string(),
|
||||
transaction_id: None,
|
||||
from_amount: None,
|
||||
to_amount: None,
|
||||
exchange_rate: None,
|
||||
fee: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update pool reserves (simplified for mock)
|
||||
pool.volume_24h += request.amount;
|
||||
|
||||
ExchangeResponse {
|
||||
success: true,
|
||||
message: format!("Successfully exchanged {} {} for {} {}",
|
||||
request.amount, request.from_token, receive_amount, request.to_token),
|
||||
transaction_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
from_amount: Some(request.amount),
|
||||
to_amount: Some(receive_amount),
|
||||
exchange_rate: Some(pool.exchange_rate),
|
||||
fee: Some(fee),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_analytics(&self) -> PoolAnalytics {
|
||||
self.analytics.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn generate_mock_analytics() -> PoolAnalytics {
|
||||
// Generate realistic mock data for charts
|
||||
let mut price_history = Vec::new();
|
||||
let mut volume_history = Vec::new();
|
||||
|
||||
// Generate 30 days of price history
|
||||
for i in 0..30 {
|
||||
let date = Utc::now() - chrono::Duration::days(29 - i);
|
||||
price_history.push(PricePoint {
|
||||
timestamp: date,
|
||||
price: dec!(0.1) + (dec!(0.005) * Decimal::from((i as f64 * 0.1).sin() as i64)),
|
||||
volume: dec!(1000) + (dec!(500) * Decimal::from((i as f64 * 0.2).cos() as i64)),
|
||||
});
|
||||
|
||||
volume_history.push(VolumePoint {
|
||||
date: date.format("%Y-%m-%d").to_string(),
|
||||
volume: dec!(1000) + (dec!(500) * Decimal::from((i as f64 * 0.2).cos() as i64)),
|
||||
});
|
||||
}
|
||||
|
||||
let mut liquidity_distribution = HashMap::new();
|
||||
liquidity_distribution.insert("Credits-Fiat".to_string(), dec!(125000));
|
||||
liquidity_distribution.insert("Credits-TFT".to_string(), dec!(25000));
|
||||
liquidity_distribution.insert("Credits-PEAQ".to_string(), dec!(10000));
|
||||
|
||||
let mut staking_distribution = HashMap::new();
|
||||
staking_distribution.insert("$10-50".to_string(), 45);
|
||||
staking_distribution.insert("$51-100".to_string(), 30);
|
||||
staking_distribution.insert("$101-500".to_string(), 20);
|
||||
staking_distribution.insert("$501+".to_string(), 5);
|
||||
|
||||
PoolAnalytics {
|
||||
price_history,
|
||||
volume_history,
|
||||
liquidity_distribution,
|
||||
staking_distribution,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref POOL_SERVICE: PoolService = PoolService::new();
|
||||
}
|
772
src/services/product.rs
Normal file
772
src/services/product.rs
Normal file
@@ -0,0 +1,772 @@
|
||||
use crate::models::product::{Product, ProductCategory, ProductAvailability};
|
||||
use crate::services::node_marketplace::NodeMarketplaceService;
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use crate::services::currency::CurrencyService;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Service for handling product operations
|
||||
#[derive(Clone)]
|
||||
pub struct ProductService {
|
||||
currency_service: CurrencyService,
|
||||
node_marketplace_service: NodeMarketplaceService,
|
||||
include_slice_products: bool,
|
||||
}
|
||||
|
||||
// Simple in-memory cache for aggregated catalog, keyed by include_slice_products flag
|
||||
struct CacheEntry {
|
||||
products: Vec<Product>,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
struct CatalogCache {
|
||||
with_slice: Option<CacheEntry>,
|
||||
without_slice: Option<CacheEntry>,
|
||||
}
|
||||
|
||||
impl Default for CatalogCache {
|
||||
fn default() -> Self {
|
||||
Self { with_slice: None, without_slice: None }
|
||||
}
|
||||
}
|
||||
|
||||
static CATALOG_CACHE: OnceLock<Mutex<CatalogCache>> = OnceLock::new();
|
||||
|
||||
/// Product search and filter criteria
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProductSearchCriteria {
|
||||
pub query: Option<String>,
|
||||
pub category_id: Option<String>,
|
||||
pub min_price: Option<Decimal>,
|
||||
pub max_price: Option<Decimal>,
|
||||
pub provider_id: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub availability: Option<ProductAvailability>,
|
||||
pub featured_only: bool,
|
||||
pub attributes: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Product search results with metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProductSearchResult {
|
||||
pub products: Vec<Product>,
|
||||
pub total_count: usize,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub total_pages: usize,
|
||||
pub filters_applied: ProductSearchCriteria,
|
||||
}
|
||||
|
||||
impl ProductService {
|
||||
pub fn new() -> Self {
|
||||
let node_marketplace_service = NodeMarketplaceService::builder()
|
||||
.build()
|
||||
.expect("Failed to create NodeMarketplaceService");
|
||||
|
||||
Self {
|
||||
currency_service: CurrencyService::new(),
|
||||
node_marketplace_service,
|
||||
include_slice_products: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_config(
|
||||
currency_service: CurrencyService,
|
||||
_cache_enabled: bool,
|
||||
_default_category: Option<String>,
|
||||
) -> Self {
|
||||
let node_marketplace_service = NodeMarketplaceService::builder()
|
||||
.currency_service(currency_service.clone())
|
||||
.build()
|
||||
.expect("Failed to create NodeMarketplaceService");
|
||||
|
||||
Self {
|
||||
currency_service,
|
||||
node_marketplace_service,
|
||||
include_slice_products: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_slice_support(
|
||||
currency_service: CurrencyService,
|
||||
include_slice_products: bool,
|
||||
) -> Self {
|
||||
let node_marketplace_service = NodeMarketplaceService::builder()
|
||||
.currency_service(currency_service.clone())
|
||||
.build()
|
||||
.expect("Failed to create NodeMarketplaceService");
|
||||
|
||||
Self {
|
||||
currency_service,
|
||||
node_marketplace_service,
|
||||
include_slice_products,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builder() -> crate::models::builders::ProductServiceBuilder {
|
||||
crate::models::builders::ProductServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Get all products (includes fixtures/mock, user-created services, and optionally slice products)
|
||||
pub fn get_all_products(&self) -> Vec<Product> {
|
||||
let config = crate::config::get_app_config();
|
||||
|
||||
if config.is_catalog_cache_enabled() {
|
||||
let ttl = Duration::from_secs(config.catalog_cache_ttl_secs());
|
||||
let now = Instant::now();
|
||||
let cache = CATALOG_CACHE.get_or_init(|| Mutex::new(CatalogCache::default()));
|
||||
let mut guard = cache.lock().unwrap();
|
||||
|
||||
let entry_opt = if self.include_slice_products { &mut guard.with_slice } else { &mut guard.without_slice };
|
||||
if let Some(entry) = entry_opt {
|
||||
if now.duration_since(entry.fetched_at) < ttl {
|
||||
return entry.products.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired
|
||||
let products = self.aggregate_all_products_uncached();
|
||||
let new_entry = CacheEntry { products: products.clone(), fetched_at: now };
|
||||
if self.include_slice_products {
|
||||
guard.with_slice = Some(new_entry);
|
||||
} else {
|
||||
guard.without_slice = Some(new_entry);
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
// Cache disabled
|
||||
self.aggregate_all_products_uncached()
|
||||
}
|
||||
|
||||
/// Compute the full aggregated catalog without using the cache
|
||||
fn aggregate_all_products_uncached(&self) -> Vec<Product> {
|
||||
let mut all_products = Vec::new();
|
||||
let config = crate::config::get_app_config();
|
||||
|
||||
// Prefer fixtures when configured
|
||||
if config.is_fixtures() {
|
||||
let fixture_products = self.load_fixture_products();
|
||||
all_products.extend(fixture_products);
|
||||
}
|
||||
// Mock data support removed - using only fixtures and user persistent data
|
||||
|
||||
// Get user-created products (applications/services created via Service Provider dashboard)
|
||||
// Note: System has migrated from services to products - only use product-based approach
|
||||
let user_products = UserPersistence::get_all_users_products();
|
||||
println!("🔍 PRODUCT SERVICE: Found {} user products", user_products.len());
|
||||
for product in &user_products {
|
||||
println!("🔍 PRODUCT SERVICE: User product: {} (category: {}, provider: {})",
|
||||
product.name, product.category_id, product.provider_id);
|
||||
}
|
||||
all_products.extend(user_products);
|
||||
println!("🔍 PRODUCT SERVICE: Total products after adding user products: {}", all_products.len());
|
||||
|
||||
// Get slice products if enabled
|
||||
if self.include_slice_products {
|
||||
let slice_products = self.node_marketplace_service.get_all_slice_combinations();
|
||||
all_products.extend(slice_products);
|
||||
}
|
||||
|
||||
// Normalize categories across all sources to canonical forms
|
||||
for p in all_products.iter_mut() {
|
||||
let normalized = Self::canonical_category_id(&p.category_id);
|
||||
p.category_id = normalized;
|
||||
}
|
||||
|
||||
// Deduplicate by product ID, preferring later sources (user-owned) over earlier seeds/mocks
|
||||
// Strategy: reverse iterate so last occurrence wins, then reverse back to preserve overall order
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
let mut unique_rev = Vec::with_capacity(all_products.len());
|
||||
for p in all_products.into_iter().rev() {
|
||||
if seen_ids.insert(p.id.clone()) {
|
||||
unique_rev.push(p);
|
||||
}
|
||||
}
|
||||
unique_rev.reverse();
|
||||
unique_rev
|
||||
}
|
||||
|
||||
/// Get product by ID using the aggregated, de-duplicated catalog
|
||||
pub fn get_product_by_id(&self, id: &str) -> Option<Product> {
|
||||
self.get_all_products()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
}
|
||||
/// Get slice products only
|
||||
pub fn get_slice_products(&self) -> Vec<Product> {
|
||||
if self.include_slice_products {
|
||||
self.node_marketplace_service.get_all_slice_combinations()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a product is a slice product
|
||||
pub fn is_slice_product(&self, product_id: &str) -> bool {
|
||||
if !self.include_slice_products {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Slice products have IDs that start with "slice_" or contain slice-specific patterns
|
||||
product_id.starts_with("slice_") ||
|
||||
(product_id.contains("x") && product_id.chars().any(|c| c.is_numeric()))
|
||||
}
|
||||
|
||||
/// Get slice product details with deployment information
|
||||
pub fn get_slice_product_details(&self, product_id: &str) -> Option<serde_json::Value> {
|
||||
if let Some(product) = self.get_product_by_id(product_id) {
|
||||
if self.is_slice_product(product_id) {
|
||||
// Extract slice-specific information for deployment
|
||||
let mut details = serde_json::json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"price": product.base_price,
|
||||
"currency": product.base_currency,
|
||||
"provider": product.provider_name,
|
||||
"category": "compute_slice",
|
||||
"is_slice_product": true
|
||||
});
|
||||
|
||||
// Add slice-specific attributes
|
||||
if let Some(node_id) = product.attributes.get("node_id") {
|
||||
details["node_id"] = node_id.value.clone();
|
||||
}
|
||||
if let Some(combination_id) = product.attributes.get("combination_id") {
|
||||
details["combination_id"] = combination_id.value.clone();
|
||||
}
|
||||
if let Some(farmer_email) = product.attributes.get("farmer_email") {
|
||||
details["farmer_email"] = farmer_email.value.clone();
|
||||
}
|
||||
if let Some(cpu_cores) = product.attributes.get("cpu_cores") {
|
||||
details["cpu_cores"] = cpu_cores.value.clone();
|
||||
}
|
||||
if let Some(memory_gb) = product.attributes.get("memory_gb") {
|
||||
details["memory_gb"] = memory_gb.value.clone();
|
||||
}
|
||||
if let Some(storage_gb) = product.attributes.get("storage_gb") {
|
||||
details["storage_gb"] = storage_gb.value.clone();
|
||||
}
|
||||
if let Some(location) = product.attributes.get("location") {
|
||||
details["location"] = location.value.clone();
|
||||
}
|
||||
|
||||
return Some(details);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply filters to slice products
|
||||
pub fn get_filtered_slice_products(&self, filters: &HashMap<String, String>) -> Vec<Product> {
|
||||
if !self.include_slice_products {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let slice_products = self.node_marketplace_service.get_all_slice_combinations();
|
||||
self.node_marketplace_service.apply_slice_filters(&slice_products, filters)
|
||||
}
|
||||
|
||||
|
||||
/// Get products by category
|
||||
pub fn get_products_by_category(&self, category_id: &str) -> Vec<Product> {
|
||||
self.get_all_products()
|
||||
.into_iter()
|
||||
.filter(|p| p.category_id == category_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get featured products
|
||||
pub fn get_featured_products(&self) -> Vec<Product> {
|
||||
self.get_all_products()
|
||||
.into_iter()
|
||||
.filter(|p| p.metadata.featured)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get products by provider
|
||||
pub fn get_products_by_provider(&self, provider_id: &str) -> Vec<Product> {
|
||||
self.get_all_products()
|
||||
.into_iter()
|
||||
.filter(|p| p.provider_id == provider_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Search products with basic text query
|
||||
pub fn search_products(&self, query: &str) -> Vec<Product> {
|
||||
let query_lower = query.to_lowercase();
|
||||
self.get_all_products()
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.name.to_lowercase().contains(&query_lower) ||
|
||||
p.description.to_lowercase().contains(&query_lower) ||
|
||||
p.metadata.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower)) ||
|
||||
p.provider_name.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Advanced product search with multiple criteria
|
||||
pub fn search_products_advanced(
|
||||
&self,
|
||||
criteria: &ProductSearchCriteria,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
) -> ProductSearchResult {
|
||||
let all = self.get_all_products();
|
||||
let mut products: Vec<&Product> = all.iter().collect();
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref query) = criteria.query {
|
||||
products = self.filter_by_text_query(products, query);
|
||||
}
|
||||
|
||||
if let Some(ref category_id) = criteria.category_id {
|
||||
products = self.filter_by_category(products, category_id);
|
||||
}
|
||||
|
||||
if let Some(min_price) = criteria.min_price {
|
||||
products = self.filter_by_min_price(products, min_price);
|
||||
}
|
||||
|
||||
if let Some(max_price) = criteria.max_price {
|
||||
products = self.filter_by_max_price(products, max_price);
|
||||
}
|
||||
|
||||
if let Some(ref provider_id) = criteria.provider_id {
|
||||
products = self.filter_by_provider(products, provider_id);
|
||||
}
|
||||
|
||||
if let Some(ref location) = criteria.location {
|
||||
products = self.filter_by_location(products, location);
|
||||
}
|
||||
|
||||
if !criteria.tags.is_empty() {
|
||||
products = self.filter_by_tags(products, &criteria.tags);
|
||||
}
|
||||
|
||||
if let Some(ref availability) = criteria.availability {
|
||||
products = self.filter_by_availability(products, availability);
|
||||
}
|
||||
|
||||
if criteria.featured_only {
|
||||
products = self.filter_featured_only(products);
|
||||
}
|
||||
|
||||
if !criteria.attributes.is_empty() {
|
||||
products = self.filter_by_attributes(products, &criteria.attributes);
|
||||
}
|
||||
|
||||
let total_count = products.len();
|
||||
let total_pages = (total_count + page_size - 1) / page_size;
|
||||
|
||||
// Apply pagination
|
||||
let start_idx = page * page_size;
|
||||
let end_idx = std::cmp::min(start_idx + page_size, total_count);
|
||||
let paginated_products: Vec<Product> = products[start_idx..end_idx]
|
||||
.iter()
|
||||
.map(|&p| p.clone())
|
||||
.collect();
|
||||
|
||||
ProductSearchResult {
|
||||
products: paginated_products,
|
||||
total_count,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
filters_applied: criteria.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all product categories
|
||||
pub fn get_categories(&self) -> Vec<ProductCategory> {
|
||||
let config = crate::config::get_app_config();
|
||||
if config.is_fixtures() {
|
||||
let products = self.get_all_products();
|
||||
self.derive_categories(&products)
|
||||
} else {
|
||||
// Mock data support removed - using only fixtures and user persistent data
|
||||
let products = self.get_all_products();
|
||||
self.derive_categories(&products)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get category by ID
|
||||
pub fn get_category_by_id(&self, id: &str) -> Option<ProductCategory> {
|
||||
self.get_categories().into_iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Get products with prices converted to specified currency
|
||||
pub fn get_products_with_converted_prices(
|
||||
&self,
|
||||
products: &[Product],
|
||||
display_currency: &str,
|
||||
) -> Result<Vec<(Product, crate::models::currency::Price)>, String> {
|
||||
let mut result = Vec::default();
|
||||
|
||||
for product in products {
|
||||
let price = self.currency_service.create_price(
|
||||
product.base_price,
|
||||
&product.base_currency,
|
||||
display_currency,
|
||||
)?;
|
||||
result.push((product.clone(), price));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get product recommendations based on a product
|
||||
pub fn get_product_recommendations(&self, product_id: &str, limit: usize) -> Vec<Product> {
|
||||
if let Some(product) = self.get_product_by_id(product_id) {
|
||||
// Simple recommendation logic: same category, different products
|
||||
let mut recommendations: Vec<Product> = self.get_products_by_category(&product.category_id)
|
||||
.into_iter()
|
||||
.filter(|p| p.id != product_id)
|
||||
.collect();
|
||||
|
||||
// Sort by rating and featured status
|
||||
recommendations.sort_by(|a, b| {
|
||||
let a_score = self.calculate_recommendation_score(a);
|
||||
let b_score = self.calculate_recommendation_score(b);
|
||||
b_score.partial_cmp(&a_score).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
recommendations.into_iter().take(limit).collect()
|
||||
} else {
|
||||
Vec::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get product statistics
|
||||
pub fn get_product_statistics(&self) -> HashMap<String, serde_json::Value> {
|
||||
let products = self.get_all_products();
|
||||
let categories = self.get_categories();
|
||||
|
||||
let mut stats = HashMap::default();
|
||||
|
||||
// Basic counts
|
||||
stats.insert("total_products".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(products.len())));
|
||||
stats.insert("total_categories".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(categories.len())));
|
||||
|
||||
// Featured products count
|
||||
let featured_count = products.iter().filter(|p| p.metadata.featured).count();
|
||||
stats.insert("featured_products".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(featured_count)));
|
||||
|
||||
// Products by category
|
||||
let mut category_counts: HashMap<String, i32> = HashMap::default();
|
||||
for product in &products {
|
||||
*category_counts.entry(product.category_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let category_stats: Vec<serde_json::Value> = category_counts.iter()
|
||||
.map(|(category_id, count)| {
|
||||
let category_name = self.get_category_by_id(category_id)
|
||||
.map(|c| c.display_name.clone())
|
||||
.unwrap_or_else(|| category_id.to_string());
|
||||
serde_json::json!({
|
||||
"category_id": category_id,
|
||||
"category_name": category_name,
|
||||
"product_count": count
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
stats.insert("products_by_category".to_string(), serde_json::Value::Array(category_stats));
|
||||
|
||||
// Price statistics
|
||||
if !products.is_empty() {
|
||||
let prices: Vec<Decimal> = products.iter().map(|p| p.base_price).collect();
|
||||
let min_price = prices.iter().min().unwrap();
|
||||
let max_price = prices.iter().max().unwrap();
|
||||
let avg_price = prices.iter().sum::<Decimal>() / Decimal::from(prices.len());
|
||||
|
||||
let currency = self.currency_service.get_base_currency().code.clone();
|
||||
stats.insert("price_range".to_string(), serde_json::json!({
|
||||
"min": min_price.to_string(),
|
||||
"max": max_price.to_string(),
|
||||
"average": avg_price.to_string(),
|
||||
"currency": currency
|
||||
}));
|
||||
}
|
||||
|
||||
// Provider statistics
|
||||
let mut provider_counts: HashMap<String, i32> = HashMap::default();
|
||||
for product in &products {
|
||||
*provider_counts.entry(product.provider_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
stats.insert("total_providers".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(provider_counts.len())));
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
// Private helper methods for filtering
|
||||
|
||||
fn filter_by_text_query<'a>(&self, products: Vec<&'a Product>, query: &str) -> Vec<&'a Product> {
|
||||
let query_lower = query.to_lowercase();
|
||||
products.into_iter()
|
||||
.filter(|p| {
|
||||
p.name.to_lowercase().contains(&query_lower) ||
|
||||
p.description.to_lowercase().contains(&query_lower) ||
|
||||
p.metadata.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower)) ||
|
||||
p.provider_name.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_category<'a>(&self, products: Vec<&'a Product>, category_id: &str) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| p.category_id == category_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_min_price<'a>(&self, products: Vec<&'a Product>, min_price: Decimal) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| p.base_price >= min_price)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_max_price<'a>(&self, products: Vec<&'a Product>, max_price: Decimal) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| p.base_price <= max_price)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_provider<'a>(&self, products: Vec<&'a Product>, provider_id: &str) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| p.provider_id == provider_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_location<'a>(&self, products: Vec<&'a Product>, location: &str) -> Vec<&'a Product> {
|
||||
let location_lower = location.to_lowercase();
|
||||
products.into_iter()
|
||||
.filter(|p| {
|
||||
p.metadata.location.as_ref()
|
||||
.map(|loc| loc.to_lowercase().contains(&location_lower))
|
||||
.unwrap_or(false) ||
|
||||
p.attributes.get("location")
|
||||
.and_then(|v| v.value.as_str())
|
||||
.map(|loc| loc.to_lowercase().contains(&location_lower))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_tags<'a>(&self, products: Vec<&'a Product>, tags: &[String]) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| {
|
||||
tags.iter().any(|tag| p.metadata.tags.contains(tag))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_availability<'a>(&self, products: Vec<&'a Product>, availability: &ProductAvailability) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| std::mem::discriminant(&p.availability) == std::mem::discriminant(availability))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_featured_only<'a>(&self, products: Vec<&'a Product>) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| p.metadata.featured)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_by_attributes<'a>(
|
||||
&self,
|
||||
products: Vec<&'a Product>,
|
||||
attributes: &HashMap<String, serde_json::Value>,
|
||||
) -> Vec<&'a Product> {
|
||||
products.into_iter()
|
||||
.filter(|p| {
|
||||
attributes.iter().all(|(key, value)| {
|
||||
p.attributes.get(key)
|
||||
.map(|attr| &attr.value == value)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn calculate_recommendation_score(&self, product: &Product) -> f32 {
|
||||
let mut score = 0.0;
|
||||
|
||||
// Featured products get higher score
|
||||
if product.metadata.featured {
|
||||
score += 10.0;
|
||||
}
|
||||
|
||||
// Products with ratings get score based on rating
|
||||
if let Some(rating) = product.metadata.rating {
|
||||
score += rating * 2.0;
|
||||
}
|
||||
|
||||
// Products with more reviews get slight boost
|
||||
score += (product.metadata.review_count as f32).ln().max(0.0);
|
||||
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
impl ProductService {
|
||||
/// Load products from fixtures directory (products.json). Returns empty vec on error.
|
||||
fn load_fixture_products(&self) -> Vec<Product> {
|
||||
let config = crate::config::get_app_config();
|
||||
let mut path = PathBuf::from(config.fixtures_path());
|
||||
path.push("products.json");
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<Vec<Product>>(&content) {
|
||||
Ok(mut products) => {
|
||||
// Normalize category IDs from fixtures to canonical singular forms
|
||||
for p in products.iter_mut() {
|
||||
let normalized = Self::canonical_category_id(&p.category_id);
|
||||
p.category_id = normalized;
|
||||
}
|
||||
products
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("WARN: Failed to parse fixtures file {}: {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("INFO: Fixtures file not found or unreadable ({}): {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map various plural/alias category IDs to canonical singular IDs used across the app
|
||||
fn canonical_category_id(category_id: &str) -> String {
|
||||
match category_id.to_lowercase().as_str() {
|
||||
// Applications
|
||||
"applications" | "application" | "app" | "apps" => "application".to_string(),
|
||||
// Gateways
|
||||
"gateways" | "gateway" => "gateway".to_string(),
|
||||
// Services
|
||||
"services" | "service" => "service".to_string(),
|
||||
// Professional service subcategories should map to the generic "service"
|
||||
"consulting" | "deployment" | "support" | "training" | "development" | "maintenance"
|
||||
| "professional_services" | "professional_service" | "professional services" | "professional service"
|
||||
| "system administration" | "system_administration" | "sysadmin" => "service".to_string(),
|
||||
// Compute
|
||||
"computes" | "compute" => "compute".to_string(),
|
||||
// Storage often modeled as a service in current UI
|
||||
"storage" | "storages" => "service".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive minimal categories from available products
|
||||
fn derive_categories(&self, products: &[Product]) -> Vec<ProductCategory> {
|
||||
use std::collections::HashSet;
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut categories = Vec::new();
|
||||
|
||||
for p in products {
|
||||
if seen.insert(p.category_id.clone()) {
|
||||
categories.push(ProductCategory {
|
||||
id: p.category_id.clone(),
|
||||
name: p.category_id.clone(),
|
||||
display_name: p.category_id.clone(),
|
||||
description: String::new(),
|
||||
attribute_schema: Vec::new(),
|
||||
parent_category: None,
|
||||
is_active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
categories
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProductService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProductSearchCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: None,
|
||||
category_id: None,
|
||||
min_price: None,
|
||||
max_price: None,
|
||||
provider_id: None,
|
||||
location: None,
|
||||
tags: Vec::default(),
|
||||
availability: None,
|
||||
featured_only: false,
|
||||
attributes: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProductSearchCriteria {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_query(mut self, query: String) -> Self {
|
||||
self.query = Some(query);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category_id: String) -> Self {
|
||||
self.category_id = Some(category_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_price_range(mut self, min_price: Option<Decimal>, max_price: Option<Decimal>) -> Self {
|
||||
self.min_price = min_price;
|
||||
self.max_price = max_price;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_provider(mut self, provider_id: String) -> Self {
|
||||
self.provider_id = Some(provider_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_location(mut self, location: String) -> Self {
|
||||
self.location = Some(location);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
||||
self.tags = tags;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_availability(mut self, availability: ProductAvailability) -> Self {
|
||||
self.availability = Some(availability);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn featured_only(mut self) -> Self {
|
||||
self.featured_only = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
|
||||
self.attributes.insert(key, value);
|
||||
self
|
||||
}
|
||||
}
|
347
src/services/session_manager.rs
Normal file
347
src/services/session_manager.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
use actix_session::Session;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::models::user::{Transaction, User};
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserSessionData {
|
||||
pub user_email: String,
|
||||
pub wallet_balance: Decimal,
|
||||
pub transactions: Vec<Transaction>,
|
||||
pub staked_amount: Decimal,
|
||||
pub pool_positions: HashMap<String, PoolPosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PoolPosition {
|
||||
pub pool_id: String,
|
||||
pub amount: Decimal,
|
||||
pub entry_rate: Decimal,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub struct SessionManager;
|
||||
|
||||
impl SessionManager {
|
||||
pub fn save_user_session_data(session: &Session, data: &UserSessionData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Store each component separately to avoid size limits
|
||||
session.insert("user_email", &data.user_email)?;
|
||||
session.insert("wallet_balance", &data.wallet_balance)?;
|
||||
|
||||
// Accumulate transactions instead of replacing them
|
||||
let mut existing_transactions: Vec<Transaction> = session.get("user_transactions")?.unwrap_or_default();
|
||||
for transaction in &data.transactions {
|
||||
if !existing_transactions.iter().any(|t| t.id == transaction.id) {
|
||||
existing_transactions.push(transaction.clone());
|
||||
}
|
||||
}
|
||||
session.insert("user_transactions", &existing_transactions)?;
|
||||
|
||||
session.insert("staked_amount", &data.staked_amount)?;
|
||||
session.insert("pool_positions", &data.pool_positions)?;
|
||||
|
||||
// Also save to persistent storage - MERGE with existing data instead of overwriting
|
||||
let mut persistent_data = match UserPersistence::load_user_data(&data.user_email) {
|
||||
Some(existing_data) => {
|
||||
existing_data
|
||||
},
|
||||
None => {
|
||||
// Create new data structure using centralized builder
|
||||
crate::models::builders::SessionDataBuilder::new_user(&data.user_email)
|
||||
}
|
||||
};
|
||||
|
||||
// Update only wallet and transaction data, preserve everything else
|
||||
persistent_data.wallet_balance_usd = data.wallet_balance;
|
||||
persistent_data.staked_amount_usd = data.staked_amount;
|
||||
|
||||
// Merge transactions - avoid duplicates by checking transaction IDs
|
||||
for new_transaction in &existing_transactions {
|
||||
if !persistent_data.transactions.iter().any(|t| t.id == new_transaction.id) {
|
||||
persistent_data.transactions.push(new_transaction.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update pool positions
|
||||
for (pool_id, session_position) in &data.pool_positions {
|
||||
persistent_data.pool_positions.insert(
|
||||
pool_id.clone(),
|
||||
crate::services::user_persistence::PoolPosition {
|
||||
pool_id: session_position.pool_id.clone(),
|
||||
amount: session_position.amount,
|
||||
entry_rate: session_position.entry_rate,
|
||||
timestamp: session_position.timestamp,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = UserPersistence::save_user_data(&persistent_data) {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async variant that persists via per-user locked wrappers and propagates req_id for logging
|
||||
pub async fn save_user_session_data_async(
|
||||
session: &Session,
|
||||
data: &UserSessionData,
|
||||
req_id: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Store each component separately to avoid size limits
|
||||
session.insert("user_email", &data.user_email)?;
|
||||
session.insert("wallet_balance", &data.wallet_balance)?;
|
||||
|
||||
// Accumulate transactions instead of replacing them
|
||||
let mut existing_transactions: Vec<Transaction> = session.get("user_transactions")?.unwrap_or_default();
|
||||
for transaction in &data.transactions {
|
||||
if !existing_transactions.iter().any(|t| t.id == transaction.id) {
|
||||
existing_transactions.push(transaction.clone());
|
||||
}
|
||||
}
|
||||
session.insert("user_transactions", &existing_transactions)?;
|
||||
|
||||
session.insert("staked_amount", &data.staked_amount)?;
|
||||
session.insert("pool_positions", &data.pool_positions)?;
|
||||
|
||||
// Merge into persistent data using locked load/save
|
||||
let mut persistent_data = match UserPersistence::load_user_data_locked(&data.user_email, req_id).await {
|
||||
Some(existing_data) => existing_data,
|
||||
None => {
|
||||
// Create new data structure using centralized builder
|
||||
crate::models::builders::SessionDataBuilder::new_user(&data.user_email)
|
||||
}
|
||||
};
|
||||
|
||||
// Update only wallet and transaction data, preserve everything else
|
||||
persistent_data.wallet_balance_usd = data.wallet_balance;
|
||||
persistent_data.staked_amount_usd = data.staked_amount;
|
||||
|
||||
// Merge transactions - avoid duplicates by checking transaction IDs
|
||||
for new_transaction in &existing_transactions {
|
||||
if !persistent_data.transactions.iter().any(|t| t.id == new_transaction.id) {
|
||||
persistent_data.transactions.push(new_transaction.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update pool positions
|
||||
for (pool_id, session_position) in &data.pool_positions {
|
||||
persistent_data.pool_positions.insert(
|
||||
pool_id.clone(),
|
||||
crate::services::user_persistence::PoolPosition {
|
||||
pool_id: session_position.pool_id.clone(),
|
||||
amount: session_position.amount,
|
||||
entry_rate: session_position.entry_rate,
|
||||
timestamp: session_position.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Persist with lock
|
||||
let _ = UserPersistence::save_user_data_locked(&persistent_data, req_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_user_session_data(session: &Session) -> Option<UserSessionData> {
|
||||
let user_email = session.get::<String>("user_email").ok()??;
|
||||
|
||||
// First try to load from session
|
||||
let session_balance = session.get::<Decimal>("wallet_balance").ok()?.unwrap_or_default();
|
||||
let session_transactions = session.get::<Vec<Transaction>>("user_transactions").ok()?.unwrap_or_default();
|
||||
let session_staked = session.get::<Decimal>("staked_amount").ok()?.unwrap_or_default();
|
||||
let session_positions = session.get::<HashMap<String, PoolPosition>>("pool_positions").ok()?.unwrap_or_default();
|
||||
|
||||
// Try to load from persistent storage
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(&user_email) {
|
||||
// Use persistent data if session is empty or persistent data is newer
|
||||
let wallet_balance = if session_balance > rust_decimal::Decimal::ZERO {
|
||||
session_balance
|
||||
} else {
|
||||
persistent_data.wallet_balance_usd
|
||||
};
|
||||
|
||||
// Merge transactions (session + persistent, avoiding duplicates)
|
||||
let mut all_transactions = session_transactions;
|
||||
for transaction in persistent_data.transactions {
|
||||
if !all_transactions.iter().any(|t| t.id == transaction.id) {
|
||||
all_transactions.push(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert pool positions back to session format
|
||||
let pool_positions = persistent_data.pool_positions.iter().map(|(k, v)| {
|
||||
(k.clone(), PoolPosition {
|
||||
pool_id: v.pool_id.clone(),
|
||||
amount: v.amount,
|
||||
entry_rate: v.entry_rate,
|
||||
timestamp: v.timestamp,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Some(UserSessionData {
|
||||
user_email,
|
||||
wallet_balance,
|
||||
transactions: all_transactions,
|
||||
staked_amount: persistent_data.staked_amount_usd,
|
||||
pool_positions,
|
||||
})
|
||||
} else {
|
||||
// Fall back to session data only
|
||||
Some(UserSessionData {
|
||||
user_email,
|
||||
wallet_balance: session_balance,
|
||||
transactions: session_transactions,
|
||||
staked_amount: session_staked,
|
||||
pool_positions: session_positions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Async variant that uses locked persistent load and propagates req_id
|
||||
pub async fn load_user_session_data_async(
|
||||
session: &Session,
|
||||
req_id: Option<&str>,
|
||||
) -> Option<UserSessionData> {
|
||||
let user_email = session.get::<String>("user_email").ok()??;
|
||||
|
||||
// First try to load from session
|
||||
let session_balance = session.get::<Decimal>("wallet_balance").ok()?.unwrap_or_default();
|
||||
let session_transactions = session.get::<Vec<Transaction>>("user_transactions").ok()?.unwrap_or_default();
|
||||
let session_staked = session.get::<Decimal>("staked_amount").ok()?.unwrap_or_default();
|
||||
let session_positions = session.get::<HashMap<String, PoolPosition>>("pool_positions").ok()?.unwrap_or_default();
|
||||
|
||||
// Try to load from persistent storage using locked load
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data_locked(&user_email, req_id).await {
|
||||
let wallet_balance = if session_balance > rust_decimal::Decimal::ZERO {
|
||||
session_balance
|
||||
} else {
|
||||
persistent_data.wallet_balance_usd
|
||||
};
|
||||
|
||||
// Merge transactions (session + persistent, avoiding duplicates)
|
||||
let mut all_transactions = session_transactions;
|
||||
for transaction in persistent_data.transactions {
|
||||
if !all_transactions.iter().any(|t| t.id == transaction.id) {
|
||||
all_transactions.push(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert pool positions back to session format
|
||||
let pool_positions = persistent_data.pool_positions.iter().map(|(k, v)| {
|
||||
(k.clone(), PoolPosition {
|
||||
pool_id: v.pool_id.clone(),
|
||||
amount: v.amount,
|
||||
entry_rate: v.entry_rate,
|
||||
timestamp: v.timestamp,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Some(UserSessionData {
|
||||
user_email,
|
||||
wallet_balance,
|
||||
transactions: all_transactions,
|
||||
staked_amount: persistent_data.staked_amount_usd,
|
||||
pool_positions,
|
||||
})
|
||||
} else {
|
||||
// Fall back to session data only
|
||||
Some(UserSessionData {
|
||||
user_email,
|
||||
wallet_balance: session_balance,
|
||||
transactions: session_transactions,
|
||||
staked_amount: session_staked,
|
||||
pool_positions: session_positions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_session_to_user(_user: &mut User, session_data: &UserSessionData) {
|
||||
// Persist session-derived fields to durable storage instead of mutating mock_data
|
||||
let user_email = &session_data.user_email;
|
||||
|
||||
// Load or create persistent record
|
||||
let mut persistent_data = match UserPersistence::load_user_data(user_email) {
|
||||
Some(data) => data,
|
||||
None => crate::models::builders::SessionDataBuilder::new_user(user_email),
|
||||
};
|
||||
|
||||
// Update wallet balance if provided (> 0 preserves backward compatibility for "unset")
|
||||
if session_data.wallet_balance > rust_decimal::Decimal::ZERO {
|
||||
persistent_data.wallet_balance_usd = session_data.wallet_balance;
|
||||
}
|
||||
|
||||
// Update staked amount
|
||||
persistent_data.staked_amount_usd = session_data.staked_amount;
|
||||
|
||||
// Merge transactions by unique id
|
||||
for tx in &session_data.transactions {
|
||||
if !persistent_data.transactions.iter().any(|t| t.id == tx.id) {
|
||||
persistent_data.transactions.push(tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Sync pool positions
|
||||
for (pool_id, pos) in &session_data.pool_positions {
|
||||
persistent_data.pool_positions.insert(
|
||||
pool_id.clone(),
|
||||
crate::services::user_persistence::PoolPosition {
|
||||
pool_id: pos.pool_id.clone(),
|
||||
amount: pos.amount,
|
||||
entry_rate: pos.entry_rate,
|
||||
timestamp: pos.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Save updates; ignore errors here (controller can log if needed)
|
||||
let _ = UserPersistence::save_user_data(&persistent_data);
|
||||
}
|
||||
|
||||
/// Async variant that persists via locked save and propagates req_id
|
||||
pub async fn apply_session_to_user_async(
|
||||
_user: &mut User,
|
||||
session_data: &UserSessionData,
|
||||
req_id: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_email = &session_data.user_email;
|
||||
|
||||
// Load or create persistent record
|
||||
let mut persistent_data = match UserPersistence::load_user_data_locked(user_email, req_id).await {
|
||||
Some(data) => data,
|
||||
None => crate::models::builders::SessionDataBuilder::new_user(user_email),
|
||||
};
|
||||
|
||||
if session_data.wallet_balance > rust_decimal::Decimal::ZERO {
|
||||
persistent_data.wallet_balance_usd = session_data.wallet_balance;
|
||||
}
|
||||
|
||||
// Update staked amount
|
||||
persistent_data.staked_amount_usd = session_data.staked_amount;
|
||||
|
||||
// Merge transactions by unique id
|
||||
for tx in &session_data.transactions {
|
||||
if !persistent_data.transactions.iter().any(|t| t.id == tx.id) {
|
||||
persistent_data.transactions.push(tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Sync pool positions
|
||||
for (pool_id, pos) in &session_data.pool_positions {
|
||||
persistent_data.pool_positions.insert(
|
||||
pool_id.clone(),
|
||||
crate::services::user_persistence::PoolPosition {
|
||||
pool_id: pos.pool_id.clone(),
|
||||
amount: pos.amount,
|
||||
entry_rate: pos.entry_rate,
|
||||
timestamp: pos.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Save updates with lock
|
||||
UserPersistence::save_user_data_locked(&persistent_data, req_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
459
src/services/slice_assignment.rs
Normal file
459
src/services/slice_assignment.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
//! Slice assignment service for managing slice deployments and assignments
|
||||
//! Handles the specialized checkout flow for slice products with VM/Kubernetes deployment options
|
||||
|
||||
use crate::services::slice_calculator::{SliceAllocation, AllocationStatus};
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Service for managing slice assignments and deployments
|
||||
#[derive(Clone)]
|
||||
pub struct SliceAssignmentService {
|
||||
auto_save: bool,
|
||||
}
|
||||
|
||||
/// Assignment request for slice deployment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceAssignmentRequest {
|
||||
pub user_email: String,
|
||||
pub farmer_email: String,
|
||||
pub node_id: String,
|
||||
pub combination_id: String,
|
||||
pub quantity: u32,
|
||||
pub deployment_config: DeploymentConfiguration,
|
||||
pub rental_duration_hours: u32,
|
||||
pub total_cost: Decimal,
|
||||
}
|
||||
|
||||
/// Deployment configuration for slice assignments
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeploymentConfiguration {
|
||||
pub deployment_type: DeploymentType,
|
||||
pub assignment_mode: AssignmentMode,
|
||||
pub network_config: NetworkConfiguration,
|
||||
pub security_config: SecurityConfiguration,
|
||||
pub monitoring_enabled: bool,
|
||||
pub backup_enabled: bool,
|
||||
}
|
||||
|
||||
/// Type of deployment for the slice
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeploymentType {
|
||||
IndividualVM {
|
||||
vm_configs: Vec<VMConfiguration>,
|
||||
},
|
||||
KubernetesCluster {
|
||||
cluster_config: KubernetesConfiguration,
|
||||
},
|
||||
}
|
||||
|
||||
/// Assignment mode for slice allocation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AssignmentMode {
|
||||
/// Assign all slices to individual VMs
|
||||
IndividualVMs,
|
||||
/// Assign slices to Kubernetes cluster with role distribution
|
||||
KubernetesCluster {
|
||||
master_slices: u32,
|
||||
worker_slices: u32,
|
||||
},
|
||||
/// Mixed assignment (some VMs, some K8s)
|
||||
Mixed {
|
||||
vm_assignments: Vec<VMAssignment>,
|
||||
k8s_assignments: Vec<KubernetesAssignment>,
|
||||
},
|
||||
}
|
||||
|
||||
/// VM configuration for individual slice assignment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VMConfiguration {
|
||||
pub vm_name: String,
|
||||
pub os_image: String, // "ubuntu-22.04", "debian-11", "centos-8", "alpine-3.18"
|
||||
pub ssh_key: Option<String>,
|
||||
pub slice_count: u32, // How many slices this VM uses
|
||||
pub auto_scaling: bool,
|
||||
pub custom_startup_script: Option<String>,
|
||||
}
|
||||
|
||||
/// Kubernetes configuration for cluster deployment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KubernetesConfiguration {
|
||||
pub cluster_name: String,
|
||||
pub k8s_version: String, // "1.28", "1.29", "1.30"
|
||||
pub network_plugin: String, // "flannel", "calico", "weave"
|
||||
pub master_nodes: u32,
|
||||
pub worker_nodes: u32,
|
||||
pub ingress_controller: Option<String>, // "nginx", "traefik", "istio"
|
||||
pub storage_class: Option<String>, // "local-path", "nfs", "ceph"
|
||||
}
|
||||
|
||||
/// VM assignment for mixed mode
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VMAssignment {
|
||||
pub vm_config: VMConfiguration,
|
||||
pub slice_allocation: u32,
|
||||
}
|
||||
|
||||
/// Kubernetes assignment for mixed mode
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KubernetesAssignment {
|
||||
pub cluster_config: KubernetesConfiguration,
|
||||
pub slice_allocation: u32,
|
||||
}
|
||||
|
||||
/// Network configuration for deployments
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkConfiguration {
|
||||
pub public_ip_required: bool,
|
||||
pub private_network_cidr: Option<String>,
|
||||
pub exposed_ports: Vec<u16>,
|
||||
pub load_balancer_enabled: bool,
|
||||
}
|
||||
|
||||
/// Security configuration for deployments
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecurityConfiguration {
|
||||
pub firewall_enabled: bool,
|
||||
pub ssh_access_enabled: bool,
|
||||
pub vpn_access_enabled: bool,
|
||||
pub encryption_at_rest: bool,
|
||||
pub encryption_in_transit: bool,
|
||||
}
|
||||
|
||||
/// Completed slice assignment with deployment details
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceAssignment {
|
||||
pub assignment_id: String,
|
||||
pub user_email: String,
|
||||
pub farmer_email: String,
|
||||
pub node_id: String,
|
||||
pub combination_id: String,
|
||||
pub slice_allocations: Vec<SliceAllocation>,
|
||||
pub deployment_config: DeploymentConfiguration,
|
||||
pub deployment_status: DeploymentStatus,
|
||||
pub deployment_endpoints: Vec<DeploymentEndpoint>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub total_cost: Decimal,
|
||||
pub payment_status: PaymentStatus,
|
||||
}
|
||||
|
||||
/// Status of slice deployment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeploymentStatus {
|
||||
Pending,
|
||||
Provisioning,
|
||||
Deploying,
|
||||
Running,
|
||||
Scaling,
|
||||
Updating,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Failed,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
/// Deployment endpoint information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeploymentEndpoint {
|
||||
pub endpoint_type: EndpointType,
|
||||
pub url: String,
|
||||
pub port: u16,
|
||||
pub protocol: String, // "http", "https", "ssh", "tcp", "udp"
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Type of deployment endpoint
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EndpointType {
|
||||
SSH,
|
||||
HTTP,
|
||||
HTTPS,
|
||||
KubernetesAPI,
|
||||
Application,
|
||||
Database,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// Payment status for slice assignment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PaymentStatus {
|
||||
Pending,
|
||||
Paid,
|
||||
Failed,
|
||||
Refunded,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Builder for SliceAssignmentService
|
||||
#[derive(Default)]
|
||||
pub struct SliceAssignmentServiceBuilder {
|
||||
auto_save: Option<bool>,
|
||||
}
|
||||
|
||||
impl SliceAssignmentServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn auto_save(mut self, enabled: bool) -> Self {
|
||||
self.auto_save = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SliceAssignmentService, String> {
|
||||
Ok(SliceAssignmentService {
|
||||
auto_save: self.auto_save.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SliceAssignmentService {
|
||||
pub fn builder() -> SliceAssignmentServiceBuilder {
|
||||
SliceAssignmentServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Create a new slice assignment from request
|
||||
pub fn create_assignment(&self, request: SliceAssignmentRequest) -> Result<SliceAssignment, String> {
|
||||
let assignment_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now();
|
||||
let expires_at = now + chrono::Duration::hours(request.rental_duration_hours as i64);
|
||||
|
||||
// Create slice allocations
|
||||
let mut slice_allocations = Vec::new();
|
||||
for _i in 0..request.quantity {
|
||||
let allocation = SliceAllocation {
|
||||
allocation_id: Uuid::new_v4().to_string(),
|
||||
slice_combination_id: request.combination_id.clone(),
|
||||
renter_email: request.user_email.clone(),
|
||||
base_slices_used: 1, // Each allocation uses 1 base slice by default
|
||||
rental_start: now,
|
||||
rental_end: Some(expires_at),
|
||||
status: AllocationStatus::Active,
|
||||
monthly_cost: request.total_cost / Decimal::from(request.quantity),
|
||||
};
|
||||
slice_allocations.push(allocation);
|
||||
}
|
||||
|
||||
let assignment = SliceAssignment {
|
||||
assignment_id,
|
||||
user_email: request.user_email,
|
||||
farmer_email: request.farmer_email,
|
||||
node_id: request.node_id,
|
||||
combination_id: request.combination_id,
|
||||
slice_allocations,
|
||||
deployment_config: request.deployment_config,
|
||||
deployment_status: DeploymentStatus::Pending,
|
||||
deployment_endpoints: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
expires_at,
|
||||
total_cost: request.total_cost,
|
||||
payment_status: PaymentStatus::Pending,
|
||||
};
|
||||
|
||||
if self.auto_save {
|
||||
self.save_assignment(&assignment)?;
|
||||
}
|
||||
|
||||
Ok(assignment)
|
||||
}
|
||||
|
||||
/// Save assignment to persistent storage
|
||||
pub fn save_assignment(&self, assignment: &SliceAssignment) -> Result<(), String> {
|
||||
// Save to user data directory
|
||||
let filename = format!("./user_data/slice_assignments_{}.json", assignment.user_email.replace("@", "_at_").replace(".", "_"));
|
||||
|
||||
// Load existing assignments or create new list
|
||||
let mut assignments = self.load_user_assignments(&assignment.user_email).unwrap_or_default();
|
||||
|
||||
// Update or add assignment
|
||||
if let Some(existing) = assignments.iter_mut().find(|a| a.assignment_id == assignment.assignment_id) {
|
||||
*existing = assignment.clone();
|
||||
} else {
|
||||
assignments.push(assignment.clone());
|
||||
}
|
||||
|
||||
// Save to file
|
||||
let json_data = serde_json::to_string_pretty(&assignments)
|
||||
.map_err(|e| format!("Failed to serialize assignments: {}", e))?;
|
||||
|
||||
std::fs::write(&filename, json_data)
|
||||
.map_err(|e| format!("Failed to write assignments file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load assignments for a user
|
||||
pub fn load_user_assignments(&self, user_email: &str) -> Result<Vec<SliceAssignment>, String> {
|
||||
let filename = format!("./user_data/slice_assignments_{}.json", user_email.replace("@", "_at_").replace(".", "_"));
|
||||
|
||||
if !std::path::Path::new(&filename).exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&filename)
|
||||
.map_err(|e| format!("Failed to read assignments file: {}", e))?;
|
||||
|
||||
let assignments: Vec<SliceAssignment> = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse assignments: {}", e))?;
|
||||
|
||||
Ok(assignments)
|
||||
}
|
||||
|
||||
/// Get assignment by ID
|
||||
/// Get all assignments for a user
|
||||
pub fn get_user_assignments(&self, user_email: &str) -> Result<Vec<SliceAssignment>, String> {
|
||||
let user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or_else(|| "Failed to load user data".to_string())?;
|
||||
|
||||
let assignments: Vec<SliceAssignment> = user_data.slice_assignments;
|
||||
|
||||
Ok(assignments)
|
||||
}
|
||||
|
||||
/// Get assignment details (alias for get_assignment)
|
||||
pub fn get_assignment_details(&self, assignment_id: &str, user_email: &str) -> Result<Option<SliceAssignment>, String> {
|
||||
self.get_assignment(user_email, assignment_id)
|
||||
}
|
||||
|
||||
/// Update an assignment configuration
|
||||
pub fn update_assignment(&self, assignment_id: &str, user_email: &str, update_config: std::collections::HashMap<String, serde_json::Value>) -> Result<SliceAssignment, String> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or_else(|| "Failed to load user data".to_string())?;
|
||||
|
||||
let mut assignments: Vec<SliceAssignment> = user_data.slice_assignments.clone();
|
||||
|
||||
// Find and update the assignment
|
||||
if let Some(assignment) = assignments.iter_mut().find(|a| a.assignment_id == assignment_id) {
|
||||
// Update deployment config with new values
|
||||
// Note: For now, we'll just update the timestamp since DeploymentConfiguration
|
||||
// is a structured type, not a HashMap. In a real implementation, you'd
|
||||
// need to deserialize the update_config and apply specific field updates.
|
||||
assignment.updated_at = Utc::now();
|
||||
|
||||
// Clone the updated assignment before moving the vector
|
||||
let updated_assignment = assignment.clone();
|
||||
|
||||
// Save back to user data
|
||||
user_data.slice_assignments = assignments;
|
||||
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(updated_assignment)
|
||||
} else {
|
||||
Err("Assignment not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an assignment
|
||||
pub fn delete_assignment(&self, assignment_id: &str, user_email: &str) -> Result<(), String> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or_else(|| "Failed to load user data".to_string())?;
|
||||
|
||||
user_data.slice_assignments = user_data.slice_assignments
|
||||
.into_iter()
|
||||
.filter(|a| a.assignment_id != assignment_id)
|
||||
.collect();
|
||||
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|e| format!("Failed to save user data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploy an assignment (start the actual deployment)
|
||||
pub fn deploy_assignment(&self, assignment_id: &str, user_email: &str) -> Result<serde_json::Value, String> {
|
||||
let assignment = self.get_assignment(user_email, assignment_id)?
|
||||
.ok_or("Assignment not found")?;
|
||||
|
||||
// Create deployment info
|
||||
let deployment_info = serde_json::json!({
|
||||
"assignment_id": assignment_id,
|
||||
"status": "deploying",
|
||||
"deployment_type": "vm",
|
||||
"node_id": assignment.node_id,
|
||||
"farmer_email": assignment.farmer_email,
|
||||
"started_at": Utc::now(),
|
||||
"estimated_completion": Utc::now() + chrono::Duration::minutes(5)
|
||||
});
|
||||
|
||||
Ok(deployment_info)
|
||||
}
|
||||
|
||||
pub fn get_assignment(&self, user_email: &str, assignment_id: &str) -> Result<Option<SliceAssignment>, String> {
|
||||
let assignments = self.load_user_assignments(user_email)?;
|
||||
Ok(assignments.into_iter().find(|a| a.assignment_id == assignment_id))
|
||||
}
|
||||
|
||||
/// Update assignment status
|
||||
pub fn update_assignment_status(&self, user_email: &str, assignment_id: &str, status: DeploymentStatus) -> Result<(), String> {
|
||||
let mut assignments = self.load_user_assignments(user_email)?;
|
||||
|
||||
if let Some(assignment) = assignments.iter_mut().find(|a| a.assignment_id == assignment_id) {
|
||||
assignment.deployment_status = status;
|
||||
assignment.updated_at = Utc::now();
|
||||
|
||||
// Save updated assignments
|
||||
let filename = format!("./user_data/slice_assignments_{}.json", user_email.replace("@", "_at_").replace(".", "_"));
|
||||
let json_data = serde_json::to_string_pretty(&assignments)
|
||||
.map_err(|e| format!("Failed to serialize assignments: {}", e))?;
|
||||
|
||||
std::fs::write(&filename, json_data)
|
||||
.map_err(|e| format!("Failed to write assignments file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Assignment {} not found", assignment_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add deployment endpoint
|
||||
pub fn add_deployment_endpoint(&self, user_email: &str, assignment_id: &str, endpoint: DeploymentEndpoint) -> Result<(), String> {
|
||||
let mut assignments = self.load_user_assignments(user_email)?;
|
||||
|
||||
if let Some(assignment) = assignments.iter_mut().find(|a| a.assignment_id == assignment_id) {
|
||||
assignment.deployment_endpoints.push(endpoint);
|
||||
assignment.updated_at = Utc::now();
|
||||
|
||||
// Save updated assignments
|
||||
let filename = format!("./user_data/slice_assignments_{}.json", user_email.replace("@", "_at_").replace(".", "_"));
|
||||
let json_data = serde_json::to_string_pretty(&assignments)
|
||||
.map_err(|e| format!("Failed to serialize assignments: {}", e))?;
|
||||
|
||||
std::fs::write(&filename, json_data)
|
||||
.map_err(|e| format!("Failed to write assignments file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Assignment {} not found", assignment_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all active assignments for a user
|
||||
pub fn get_active_assignments(&self, user_email: &str) -> Result<Vec<SliceAssignment>, String> {
|
||||
let assignments = self.load_user_assignments(user_email)?;
|
||||
let now = Utc::now();
|
||||
|
||||
Ok(assignments.into_iter()
|
||||
.filter(|a| a.expires_at > now && matches!(a.deployment_status, DeploymentStatus::Running | DeploymentStatus::Provisioning | DeploymentStatus::Deploying))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Terminate assignment
|
||||
pub fn terminate_assignment(&self, user_email: &str, assignment_id: &str) -> Result<(), String> {
|
||||
self.update_assignment_status(user_email, assignment_id, DeploymentStatus::Terminated)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SliceAssignmentService {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_save: true,
|
||||
}
|
||||
}
|
||||
}
|
406
src/services/slice_calculator.rs
Normal file
406
src/services/slice_calculator.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
//! Slice calculator service for automatic slice calculation from node capacity
|
||||
//! Follows the established builder pattern for consistent API design
|
||||
|
||||
use crate::models::user::{NodeCapacity, FarmNode};
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Base slice unit definition (1 vCPU, 4GB RAM, 200GB storage)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceUnit {
|
||||
pub cpu_cores: u32, // 1
|
||||
pub memory_gb: u32, // 4
|
||||
pub storage_gb: u32, // 200
|
||||
}
|
||||
|
||||
impl Default for SliceUnit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu_cores: 1,
|
||||
memory_gb: 4,
|
||||
storage_gb: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculated slice combination from node capacity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceCombination {
|
||||
pub id: String,
|
||||
pub multiplier: u32, // How many base slices this uses
|
||||
pub cpu_cores: u32, // Slice-specific resource
|
||||
pub memory_gb: u32, // Slice-specific resource
|
||||
pub storage_gb: u32, // Slice-specific resource
|
||||
pub quantity_available: u32, // How many of this combination available
|
||||
pub price_per_hour: Decimal,
|
||||
pub base_slices_required: u32,
|
||||
|
||||
// Inherited from parent node
|
||||
pub node_uptime_percentage: f64,
|
||||
pub node_bandwidth_mbps: u32,
|
||||
pub node_location: String,
|
||||
pub node_certification_type: String,
|
||||
pub node_id: String,
|
||||
pub farmer_email: String,
|
||||
}
|
||||
|
||||
/// Track individual slice rentals
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceAllocation {
|
||||
pub allocation_id: String,
|
||||
pub slice_combination_id: String,
|
||||
pub renter_email: String,
|
||||
pub base_slices_used: u32,
|
||||
pub rental_start: DateTime<Utc>,
|
||||
pub rental_end: Option<DateTime<Utc>>,
|
||||
pub status: AllocationStatus,
|
||||
pub monthly_cost: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AllocationStatus {
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Pricing configuration for node slices
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlicePricing {
|
||||
pub base_price_per_hour: Decimal, // Price for 1 base slice per hour
|
||||
pub currency: String,
|
||||
pub pricing_multiplier: Decimal, // Farmer can adjust pricing (0.5x - 2.0x)
|
||||
}
|
||||
|
||||
impl Default for SlicePricing {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_price_per_hour: Decimal::from(1), // $1 per hour for base slice
|
||||
currency: "USD".to_string(),
|
||||
pricing_multiplier: Decimal::from(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for slice calculations following builder pattern
|
||||
#[derive(Clone)]
|
||||
pub struct SliceCalculatorService {
|
||||
base_slice: SliceUnit,
|
||||
pricing_limits: PricingLimits,
|
||||
}
|
||||
|
||||
/// Platform-enforced pricing limits
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PricingLimits {
|
||||
pub min_price_per_hour: Decimal, // e.g., $0.10
|
||||
pub max_price_per_hour: Decimal, // e.g., $10.00
|
||||
}
|
||||
|
||||
impl Default for PricingLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_price_per_hour: Decimal::from_str_exact("0.10").unwrap(),
|
||||
max_price_per_hour: Decimal::from_str_exact("10.00").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for SliceCalculatorService
|
||||
#[derive(Default)]
|
||||
pub struct SliceCalculatorServiceBuilder {
|
||||
base_slice: Option<SliceUnit>,
|
||||
pricing_limits: Option<PricingLimits>,
|
||||
}
|
||||
|
||||
impl SliceCalculatorServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn base_slice(mut self, base_slice: SliceUnit) -> Self {
|
||||
self.base_slice = Some(base_slice);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pricing_limits(mut self, limits: PricingLimits) -> Self {
|
||||
self.pricing_limits = Some(limits);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SliceCalculatorService, String> {
|
||||
Ok(SliceCalculatorService {
|
||||
base_slice: self.base_slice.unwrap_or_default(),
|
||||
pricing_limits: self.pricing_limits.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SliceCalculatorService {
|
||||
pub fn builder() -> SliceCalculatorServiceBuilder {
|
||||
SliceCalculatorServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Calculate maximum base slices from node capacity
|
||||
pub fn calculate_max_base_slices(&self, capacity: &NodeCapacity) -> u32 {
|
||||
let cpu_slices = capacity.cpu_cores as u32 / self.base_slice.cpu_cores;
|
||||
let memory_slices = capacity.memory_gb as u32 / self.base_slice.memory_gb;
|
||||
let storage_slices = capacity.storage_gb as u32 / self.base_slice.storage_gb;
|
||||
|
||||
// Return the limiting factor
|
||||
std::cmp::min(std::cmp::min(cpu_slices, memory_slices), storage_slices)
|
||||
}
|
||||
|
||||
/// Generate all possible slice combinations from available base slices
|
||||
pub fn generate_slice_combinations(
|
||||
&self,
|
||||
max_base_slices: u32,
|
||||
allocated_slices: u32,
|
||||
node: &FarmNode,
|
||||
farmer_email: &str
|
||||
) -> Vec<SliceCombination> {
|
||||
let available_base_slices = max_base_slices.saturating_sub(allocated_slices);
|
||||
let mut combinations = Vec::new();
|
||||
|
||||
if available_base_slices == 0 {
|
||||
return combinations;
|
||||
}
|
||||
|
||||
// Generate practical slice combinations up to a reasonable limit
|
||||
// We'll generate combinations for multipliers 1x, 2x, 3x, 4x, 5x, 6x, 8x, 10x, 12x, 16x, 20x, 24x, 32x
|
||||
let practical_multipliers = vec![1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32];
|
||||
|
||||
for multiplier in practical_multipliers {
|
||||
// Skip if multiplier is larger than available slices
|
||||
if multiplier > available_base_slices {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how many complete units of this multiplier we can create
|
||||
let quantity = available_base_slices / multiplier;
|
||||
|
||||
// Skip if we can't create at least one complete unit
|
||||
if quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let combination = SliceCombination {
|
||||
id: format!("{}x{}", quantity, multiplier),
|
||||
multiplier,
|
||||
cpu_cores: self.base_slice.cpu_cores * multiplier,
|
||||
memory_gb: self.base_slice.memory_gb * multiplier,
|
||||
storage_gb: self.base_slice.storage_gb * multiplier,
|
||||
quantity_available: quantity,
|
||||
price_per_hour: self.calculate_combination_price(multiplier, node.slice_pricing.as_ref()
|
||||
.and_then(|sp| serde_json::from_value(sp.clone()).ok())
|
||||
.as_ref()
|
||||
.unwrap_or(&crate::services::slice_calculator::SlicePricing::default())),
|
||||
base_slices_required: multiplier,
|
||||
|
||||
// Inherited from parent node
|
||||
node_uptime_percentage: node.uptime_percentage as f64,
|
||||
node_bandwidth_mbps: node.capacity.bandwidth_mbps as u32,
|
||||
node_location: node.location.clone(),
|
||||
node_certification_type: node.grid_data.as_ref()
|
||||
.map(|g| g.get("certification_type")
|
||||
.and_then(|cert| cert.as_str())
|
||||
.unwrap_or("DIY")
|
||||
.to_string())
|
||||
.unwrap_or_else(|| "DIY".to_string()),
|
||||
node_id: node.id.clone(),
|
||||
farmer_email: farmer_email.to_string(),
|
||||
};
|
||||
|
||||
combinations.push(combination);
|
||||
}
|
||||
|
||||
// Sort by multiplier (smallest slices first)
|
||||
combinations.sort_by_key(|c| c.multiplier);
|
||||
combinations
|
||||
}
|
||||
|
||||
/// Generate slice combinations with explicit SLA values (for user-defined SLAs)
|
||||
pub fn generate_slice_combinations_with_sla(
|
||||
&self,
|
||||
max_base_slices: u32,
|
||||
allocated_slices: u32,
|
||||
node: &FarmNode,
|
||||
farmer_email: &str,
|
||||
uptime_percentage: f64,
|
||||
bandwidth_mbps: u32,
|
||||
base_price_per_hour: Decimal
|
||||
) -> Vec<SliceCombination> {
|
||||
let available_base_slices = max_base_slices.saturating_sub(allocated_slices);
|
||||
let mut combinations = Vec::new();
|
||||
|
||||
if available_base_slices == 0 {
|
||||
return combinations;
|
||||
}
|
||||
|
||||
// Generate practical slice combinations up to a reasonable limit
|
||||
// We'll generate combinations for multipliers 1x, 2x, 3x, 4x, 5x, 6x, 8x, 10x, 12x, 16x, 20x, 24x, 32x
|
||||
let practical_multipliers = vec![1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32];
|
||||
|
||||
// Create custom pricing with user's base price
|
||||
let custom_pricing = SlicePricing {
|
||||
base_price_per_hour,
|
||||
currency: "USD".to_string(),
|
||||
pricing_multiplier: Decimal::from(1),
|
||||
};
|
||||
|
||||
for multiplier in practical_multipliers {
|
||||
// Skip if multiplier is larger than available slices
|
||||
if multiplier > available_base_slices {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how many complete units of this multiplier we can create
|
||||
let quantity = available_base_slices / multiplier;
|
||||
|
||||
// Skip if we can't create at least one complete unit
|
||||
if quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let combination = SliceCombination {
|
||||
id: format!("{}x{}", quantity, multiplier),
|
||||
multiplier,
|
||||
cpu_cores: self.base_slice.cpu_cores * multiplier,
|
||||
memory_gb: self.base_slice.memory_gb * multiplier,
|
||||
storage_gb: self.base_slice.storage_gb * multiplier,
|
||||
quantity_available: quantity,
|
||||
price_per_hour: self.calculate_combination_price(multiplier, &custom_pricing),
|
||||
base_slices_required: multiplier,
|
||||
|
||||
// Use explicit SLA values instead of inheriting from node
|
||||
node_uptime_percentage: uptime_percentage,
|
||||
node_bandwidth_mbps: bandwidth_mbps,
|
||||
node_location: node.location.clone(),
|
||||
node_certification_type: node.grid_data.as_ref()
|
||||
.map(|g| g.get("certification_type")
|
||||
.and_then(|cert| cert.as_str())
|
||||
.unwrap_or("DIY")
|
||||
.to_string())
|
||||
.unwrap_or_else(|| "DIY".to_string()),
|
||||
node_id: node.id.clone(),
|
||||
farmer_email: farmer_email.to_string(),
|
||||
};
|
||||
|
||||
combinations.push(combination);
|
||||
}
|
||||
|
||||
// Sort by multiplier (smallest slices first)
|
||||
combinations.sort_by_key(|c| c.multiplier);
|
||||
combinations
|
||||
}
|
||||
|
||||
/// Calculate price for a slice combination
|
||||
fn calculate_combination_price(&self, multiplier: u32, pricing: &SlicePricing) -> Decimal {
|
||||
pricing.base_price_per_hour * pricing.pricing_multiplier * Decimal::from(multiplier)
|
||||
}
|
||||
|
||||
/// Update availability after rental
|
||||
pub fn update_availability_after_rental(
|
||||
&self,
|
||||
node: &mut FarmNode,
|
||||
rented_base_slices: u32,
|
||||
farmer_email: &str
|
||||
) -> Result<(), String> {
|
||||
// Update allocated count
|
||||
node.allocated_base_slices += rented_base_slices as i32;
|
||||
|
||||
// Recalculate available combinations
|
||||
let combinations = self.generate_slice_combinations(
|
||||
node.total_base_slices as u32,
|
||||
node.allocated_base_slices as u32,
|
||||
node,
|
||||
farmer_email
|
||||
);
|
||||
node.available_combinations = combinations.iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update availability after rental expiry
|
||||
pub fn update_availability_after_release(
|
||||
&self,
|
||||
node: &mut FarmNode,
|
||||
released_base_slices: u32,
|
||||
farmer_email: &str
|
||||
) -> Result<(), String> {
|
||||
// Update allocated count
|
||||
node.allocated_base_slices = node.allocated_base_slices.saturating_sub(released_base_slices as i32);
|
||||
|
||||
// Recalculate available combinations
|
||||
node.available_combinations = self.generate_slice_combinations(
|
||||
node.total_base_slices as u32,
|
||||
node.allocated_base_slices as u32,
|
||||
node,
|
||||
farmer_email
|
||||
).iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate slice price within platform limits
|
||||
pub fn validate_slice_price(&self, price: Decimal) -> Result<(), String> {
|
||||
if price < self.pricing_limits.min_price_per_hour {
|
||||
return Err(format!("Price too low. Minimum: ${}/hour", self.pricing_limits.min_price_per_hour));
|
||||
}
|
||||
if price > self.pricing_limits.max_price_per_hour {
|
||||
return Err(format!("Price too high. Maximum: ${}/hour", self.pricing_limits.max_price_per_hour));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice rental record for users with deployment options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SliceRental {
|
||||
pub rental_id: String,
|
||||
pub slice_combination_id: String,
|
||||
pub node_id: String,
|
||||
pub farmer_email: String,
|
||||
pub slice_allocation: SliceAllocation,
|
||||
pub total_cost: Decimal,
|
||||
pub payment_status: PaymentStatus,
|
||||
#[serde(default)]
|
||||
pub slice_format: String,
|
||||
#[serde(default)]
|
||||
pub user_email: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub start_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(default)]
|
||||
pub rental_duration_days: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub monthly_cost: Option<Decimal>,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
// NEW: Deployment information
|
||||
#[serde(default)]
|
||||
pub deployment_type: Option<String>, // "vm" or "kubernetes"
|
||||
#[serde(default)]
|
||||
pub deployment_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deployment_config: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub deployment_status: Option<String>, // "Provisioning", "Active", "Stopped", "Failed"
|
||||
#[serde(default)]
|
||||
pub deployment_endpoint: Option<String>, // Access URL/IP for the deployment
|
||||
#[serde(default)]
|
||||
pub deployment_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PaymentStatus {
|
||||
Pending,
|
||||
Paid,
|
||||
Failed,
|
||||
Refunded,
|
||||
}
|
391
src/services/slice_rental.rs
Normal file
391
src/services/slice_rental.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! Slice rental service for managing slice rentals and availability
|
||||
//! Follows the established builder pattern for consistent API design
|
||||
|
||||
use crate::services::slice_calculator::{SliceCalculatorService, SliceAllocation, SliceRental, AllocationStatus, PaymentStatus};
|
||||
use crate::services::user_persistence::{UserPersistence, UserPersistentData};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::*;
|
||||
use std::str::FromStr;
|
||||
use chrono::Utc;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::fs::OpenOptions;
|
||||
|
||||
/// Service for slice rental operations
|
||||
#[derive(Clone)]
|
||||
pub struct SliceRentalService {
|
||||
slice_calculator: SliceCalculatorService,
|
||||
enable_file_locking: bool,
|
||||
}
|
||||
|
||||
/// Builder for SliceRentalService
|
||||
#[derive(Default)]
|
||||
pub struct SliceRentalServiceBuilder {
|
||||
slice_calculator: Option<SliceCalculatorService>,
|
||||
enable_file_locking: Option<bool>,
|
||||
}
|
||||
|
||||
impl SliceRentalServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn slice_calculator(mut self, slice_calculator: SliceCalculatorService) -> Self {
|
||||
self.slice_calculator = Some(slice_calculator);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn enable_file_locking(mut self, enabled: bool) -> Self {
|
||||
self.enable_file_locking = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SliceRentalService, String> {
|
||||
let slice_calculator = self.slice_calculator.unwrap_or_else(|| {
|
||||
SliceCalculatorService::builder().build().expect("Failed to create default SliceCalculatorService")
|
||||
});
|
||||
|
||||
Ok(SliceRentalService {
|
||||
slice_calculator,
|
||||
enable_file_locking: self.enable_file_locking.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SliceRentalService {
|
||||
pub fn builder() -> SliceRentalServiceBuilder {
|
||||
SliceRentalServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Rent a slice combination from a farmer's node
|
||||
pub fn rent_slice_combination(
|
||||
&self,
|
||||
renter_email: &str,
|
||||
farmer_email: &str,
|
||||
node_id: &str,
|
||||
combination_id: &str,
|
||||
quantity: u32,
|
||||
rental_duration_hours: u32,
|
||||
) -> Result<SliceRental, String> {
|
||||
// Atomic operation with file locking to prevent conflicts
|
||||
if self.enable_file_locking {
|
||||
self.rent_with_file_lock(renter_email, farmer_email, node_id, combination_id, quantity, rental_duration_hours)
|
||||
} else {
|
||||
self.rent_without_lock(renter_email, farmer_email, node_id, combination_id, quantity, rental_duration_hours)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rent a slice combination with deployment options (VM/Kubernetes)
|
||||
pub fn rent_slice_combination_with_deployment(
|
||||
&self,
|
||||
renter_email: &str,
|
||||
farmer_email: &str,
|
||||
node_id: &str,
|
||||
combination_id: &str,
|
||||
quantity: u32,
|
||||
rental_duration_hours: u32,
|
||||
deployment_type: &str,
|
||||
deployment_name: &str,
|
||||
deployment_config: Option<serde_json::Value>,
|
||||
) -> Result<SliceRental, String> {
|
||||
// First rent the slice combination
|
||||
let mut rental = self.rent_slice_combination(
|
||||
renter_email, farmer_email, node_id, combination_id, quantity, rental_duration_hours
|
||||
)?;
|
||||
|
||||
// Add deployment metadata to the rental
|
||||
rental.deployment_type = Some(deployment_type.to_string());
|
||||
rental.deployment_name = Some(deployment_name.to_string());
|
||||
rental.deployment_config = deployment_config;
|
||||
rental.deployment_status = Some("Provisioning".to_string());
|
||||
|
||||
// Save the enhanced rental to user's persistent data
|
||||
self.save_rental_to_user_data(renter_email, &rental)?;
|
||||
|
||||
Ok(rental)
|
||||
}
|
||||
|
||||
/// Get user's slice rentals
|
||||
pub fn get_user_slice_rentals(&self, user_email: &str) -> Vec<SliceRental> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
persistent_data.slice_rentals
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Save rental to user's persistent data
|
||||
fn save_rental_to_user_data(&self, user_email: &str, rental: &SliceRental) -> Result<(), String> {
|
||||
let mut persistent_data = UserPersistence::load_user_data(user_email)
|
||||
.unwrap_or_else(|| UserPersistentData::default());
|
||||
|
||||
// Add or update the rental
|
||||
if let Some(existing_index) = persistent_data.slice_rentals.iter().position(|r| r.rental_id == rental.rental_id) {
|
||||
persistent_data.slice_rentals[existing_index] = rental.clone();
|
||||
} else {
|
||||
persistent_data.slice_rentals.push(rental.clone());
|
||||
}
|
||||
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save rental to user data: {}", e))
|
||||
}
|
||||
|
||||
/// Rent slice with file locking for atomic operations
|
||||
fn rent_with_file_lock(
|
||||
&self,
|
||||
renter_email: &str,
|
||||
farmer_email: &str,
|
||||
node_id: &str,
|
||||
combination_id: &str,
|
||||
quantity: u32,
|
||||
rental_duration_hours: u32,
|
||||
) -> Result<SliceRental, String> {
|
||||
// Create lock file
|
||||
let lock_file_path = format!("./user_data/.lock_{}_{}", farmer_email.replace("@", "_"), node_id);
|
||||
let _lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&lock_file_path)
|
||||
.map_err(|e| format!("Failed to create lock file: {}", e))?;
|
||||
|
||||
let result = self.rent_without_lock(renter_email, farmer_email, node_id, combination_id, quantity, rental_duration_hours);
|
||||
|
||||
// Clean up lock file
|
||||
let _ = std::fs::remove_file(&lock_file_path);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Rent slice without file locking
|
||||
fn rent_without_lock(
|
||||
&self,
|
||||
renter_email: &str,
|
||||
farmer_email: &str,
|
||||
node_id: &str,
|
||||
combination_id: &str,
|
||||
quantity: u32,
|
||||
rental_duration_hours: u32,
|
||||
) -> Result<SliceRental, String> {
|
||||
// Load farmer data
|
||||
let mut farmer_data = UserPersistence::load_user_data(farmer_email)
|
||||
.ok_or_else(|| "Farmer not found".to_string())?;
|
||||
|
||||
// Find the node
|
||||
let node_index = farmer_data.nodes.iter().position(|n| n.id == node_id)
|
||||
.ok_or_else(|| "Node not found".to_string())?;
|
||||
|
||||
let node = &mut farmer_data.nodes[node_index];
|
||||
|
||||
// Find the slice combination
|
||||
let combination = node.available_combinations.iter()
|
||||
.find(|c| c.get("id").and_then(|v| v.as_str()) == Some(combination_id))
|
||||
.ok_or_else(|| "Slice combination not found".to_string())?
|
||||
.clone();
|
||||
|
||||
// Check availability
|
||||
let available_qty = combination.get("quantity_available").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
if available_qty < quantity {
|
||||
return Err(format!("Insufficient availability. Available: {}, Requested: {}",
|
||||
available_qty, quantity));
|
||||
}
|
||||
|
||||
// Calculate costs
|
||||
let base_slices_required = combination.get("base_slices_required").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
|
||||
let total_base_slices_needed = base_slices_required * quantity;
|
||||
let price_per_hour = combination.get("price_per_hour")
|
||||
.and_then(|p| p.as_str())
|
||||
.and_then(|p_str| rust_decimal::Decimal::from_str(p_str).ok())
|
||||
.unwrap_or_else(|| Decimal::from_f64(1.0).unwrap_or_default());
|
||||
let hourly_cost = price_per_hour * Decimal::from(quantity);
|
||||
let total_cost = hourly_cost * Decimal::from(rental_duration_hours);
|
||||
|
||||
// Check renter's balance
|
||||
let mut renter_data = UserPersistence::load_user_data(renter_email)
|
||||
.unwrap_or_else(|| self.create_default_user_data(renter_email));
|
||||
|
||||
if renter_data.wallet_balance_usd < total_cost {
|
||||
return Err(format!("Insufficient balance. Required: ${}, Available: ${}",
|
||||
total_cost, renter_data.wallet_balance_usd));
|
||||
}
|
||||
|
||||
// Create allocation
|
||||
let allocation_id = format!("alloc_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let rental_id = format!("rental_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let allocation = SliceAllocation {
|
||||
allocation_id: allocation_id.clone(),
|
||||
slice_combination_id: combination_id.to_string(),
|
||||
renter_email: renter_email.to_string(),
|
||||
base_slices_used: total_base_slices_needed,
|
||||
rental_start: Utc::now(),
|
||||
rental_end: Some(Utc::now() + chrono::Duration::hours(rental_duration_hours as i64)),
|
||||
status: AllocationStatus::Active,
|
||||
monthly_cost: hourly_cost * Decimal::from(24 * 30), // Approximate monthly cost
|
||||
};
|
||||
|
||||
// Update node availability
|
||||
self.slice_calculator.update_availability_after_rental(
|
||||
node,
|
||||
total_base_slices_needed,
|
||||
farmer_email
|
||||
)?;
|
||||
|
||||
// Add allocation to node
|
||||
node.slice_allocations.push(serde_json::to_value(allocation.clone()).unwrap_or_default());
|
||||
|
||||
// Create rental record
|
||||
let slice_rental = SliceRental {
|
||||
rental_id: rental_id.clone(),
|
||||
slice_combination_id: combination_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
farmer_email: farmer_email.to_string(),
|
||||
slice_allocation: allocation,
|
||||
total_cost,
|
||||
payment_status: PaymentStatus::Paid,
|
||||
slice_format: "standard".to_string(),
|
||||
user_email: renter_email.to_string(),
|
||||
status: "Active".to_string(),
|
||||
start_date: Some(chrono::Utc::now()),
|
||||
rental_duration_days: Some(30),
|
||||
monthly_cost: Some(total_cost),
|
||||
id: rental_id.clone(),
|
||||
deployment_type: None,
|
||||
deployment_name: None,
|
||||
deployment_config: None,
|
||||
deployment_status: None,
|
||||
deployment_endpoint: None,
|
||||
deployment_metadata: None,
|
||||
};
|
||||
|
||||
// Deduct payment from renter
|
||||
renter_data.wallet_balance_usd -= total_cost;
|
||||
renter_data.slice_rentals.push(slice_rental.clone());
|
||||
|
||||
// Add earnings to farmer
|
||||
farmer_data.wallet_balance_usd += total_cost;
|
||||
|
||||
// Save both user data
|
||||
UserPersistence::save_user_data(&farmer_data)
|
||||
.map_err(|e| format!("Failed to save farmer data: {}", e))?;
|
||||
UserPersistence::save_user_data(&renter_data)
|
||||
.map_err(|e| format!("Failed to save renter data: {}", e))?;
|
||||
|
||||
Ok(slice_rental)
|
||||
}
|
||||
|
||||
/// Release expired slice rentals
|
||||
pub fn release_expired_rentals(&self, farmer_email: &str) -> Result<u32, String> {
|
||||
let mut farmer_data = UserPersistence::load_user_data(farmer_email)
|
||||
.ok_or_else(|| "Farmer not found".to_string())?;
|
||||
|
||||
let mut released_count = 0;
|
||||
let now = Utc::now();
|
||||
|
||||
for node in &mut farmer_data.nodes {
|
||||
let mut expired_allocations = Vec::new();
|
||||
|
||||
// Find expired allocations
|
||||
for (index, allocation) in node.slice_allocations.iter().enumerate() {
|
||||
if let Some(end_time) = allocation.get("rental_end")
|
||||
.and_then(|r| r.as_str())
|
||||
.and_then(|r_str| chrono::DateTime::parse_from_rfc3339(r_str).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc)) {
|
||||
if now > end_time && allocation.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s == "Active")
|
||||
.unwrap_or(false) {
|
||||
expired_allocations.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired allocations and update availability
|
||||
for &index in expired_allocations.iter().rev() {
|
||||
let base_slices_used = {
|
||||
let allocation = &mut node.slice_allocations[index];
|
||||
if let Some(allocation_obj) = allocation.as_object_mut() {
|
||||
allocation_obj.insert("status".to_string(), serde_json::Value::String("Expired".to_string()));
|
||||
}
|
||||
allocation.get("base_slices_used")
|
||||
.and_then(|b| b.as_u64())
|
||||
.unwrap_or(0) as u32
|
||||
};
|
||||
|
||||
// Update availability
|
||||
self.slice_calculator.update_availability_after_release(
|
||||
node,
|
||||
base_slices_used,
|
||||
farmer_email
|
||||
)?;
|
||||
|
||||
released_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if released_count > 0 {
|
||||
UserPersistence::save_user_data(&farmer_data)
|
||||
.map_err(|e| format!("Failed to save farmer data: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(released_count)
|
||||
}
|
||||
|
||||
|
||||
/// Get slice rental statistics for a farmer
|
||||
pub fn get_farmer_slice_statistics(&self, farmer_email: &str) -> SliceRentalStatistics {
|
||||
if let Some(farmer_data) = UserPersistence::load_user_data(farmer_email) {
|
||||
let mut stats = SliceRentalStatistics::default();
|
||||
|
||||
for node in &farmer_data.nodes {
|
||||
stats.total_nodes += 1;
|
||||
stats.total_base_slices += node.total_base_slices as u32;
|
||||
stats.allocated_base_slices += node.allocated_base_slices as u32;
|
||||
stats.active_rentals += node.slice_allocations.iter()
|
||||
.filter(|a| a.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s == "Active")
|
||||
.unwrap_or(false))
|
||||
.count() as u32;
|
||||
|
||||
// Calculate earnings from slice rentals
|
||||
for allocation in &node.slice_allocations {
|
||||
if allocation.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s == "Active")
|
||||
.unwrap_or(false) {
|
||||
let monthly_cost = allocation.get("monthly_cost")
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(|c_str| rust_decimal::Decimal::from_str(c_str).ok())
|
||||
.unwrap_or_default();
|
||||
stats.monthly_earnings += monthly_cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.utilization_percentage = if stats.total_base_slices > 0 {
|
||||
(stats.allocated_base_slices as f64 / stats.total_base_slices as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
stats
|
||||
} else {
|
||||
SliceRentalStatistics::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create default user data using centralized builder
|
||||
fn create_default_user_data(&self, user_email: &str) -> UserPersistentData {
|
||||
crate::models::builders::SessionDataBuilder::new_user(user_email)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics for farmer slice rentals
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SliceRentalStatistics {
|
||||
pub total_nodes: u32,
|
||||
pub total_base_slices: u32,
|
||||
pub allocated_base_slices: u32,
|
||||
pub active_rentals: u32,
|
||||
pub utilization_percentage: f64,
|
||||
pub monthly_earnings: Decimal,
|
||||
}
|
581
src/services/ssh_key_service.rs
Normal file
581
src/services/ssh_key_service.rs
Normal file
@@ -0,0 +1,581 @@
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use chrono::Utc;
|
||||
use std::time::Instant;
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::str;
|
||||
|
||||
use crate::models::ssh_key::{SSHKey, SSHKeyType, SSHKeyValidationError};
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
|
||||
/// Configuration for SSH key validation service
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SSHKeyServiceConfig {
|
||||
pub min_rsa_key_size: u32,
|
||||
pub max_keys_per_user: Option<u32>,
|
||||
pub allowed_key_types: Vec<SSHKeyType>,
|
||||
pub validate_fingerprints: bool,
|
||||
}
|
||||
|
||||
impl Default for SSHKeyServiceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_rsa_key_size: 2048,
|
||||
max_keys_per_user: Some(20), // Reasonable limit
|
||||
allowed_key_types: vec![
|
||||
SSHKeyType::Ed25519,
|
||||
SSHKeyType::EcdsaP256,
|
||||
SSHKeyType::EcdsaP384,
|
||||
SSHKeyType::EcdsaP521,
|
||||
SSHKeyType::Rsa,
|
||||
],
|
||||
validate_fingerprints: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for SSH key service following established patterns
|
||||
#[derive(Default)]
|
||||
pub struct SSHKeyServiceBuilder {
|
||||
min_rsa_key_size: Option<u32>,
|
||||
max_keys_per_user: Option<u32>,
|
||||
allowed_key_types: Option<Vec<SSHKeyType>>,
|
||||
validate_fingerprints: Option<bool>,
|
||||
}
|
||||
|
||||
impl SSHKeyServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn min_rsa_key_size(mut self, size: u32) -> Self {
|
||||
self.min_rsa_key_size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_keys_per_user(mut self, max: u32) -> Self {
|
||||
self.max_keys_per_user = Some(max);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn allowed_key_types(mut self, types: Vec<SSHKeyType>) -> Self {
|
||||
self.allowed_key_types = Some(types);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate_fingerprints(mut self, validate: bool) -> Self {
|
||||
self.validate_fingerprints = Some(validate);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SSHKeyService, String> {
|
||||
let config = SSHKeyServiceConfig {
|
||||
min_rsa_key_size: self.min_rsa_key_size.unwrap_or(2048),
|
||||
max_keys_per_user: self.max_keys_per_user.or(Some(20)),
|
||||
allowed_key_types: self.allowed_key_types.unwrap_or_else(|| {
|
||||
vec![
|
||||
SSHKeyType::Ed25519,
|
||||
SSHKeyType::EcdsaP256,
|
||||
SSHKeyType::EcdsaP384,
|
||||
SSHKeyType::EcdsaP521,
|
||||
SSHKeyType::Rsa,
|
||||
]
|
||||
}),
|
||||
validate_fingerprints: self.validate_fingerprints.unwrap_or(true),
|
||||
};
|
||||
|
||||
Ok(SSHKeyService { config })
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH key validation and management service
|
||||
#[derive(Clone)]
|
||||
pub struct SSHKeyService {
|
||||
config: SSHKeyServiceConfig,
|
||||
}
|
||||
|
||||
impl SSHKeyService {
|
||||
/// Create a new builder for SSH key service
|
||||
pub fn builder() -> SSHKeyServiceBuilder {
|
||||
SSHKeyServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Create SSH key service with default configuration
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: SSHKeyServiceConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and parse an SSH public key
|
||||
pub fn validate_ssh_key(&self, public_key: &str, name: &str) -> Result<(SSHKeyType, String), SSHKeyValidationError> {
|
||||
// Basic validation
|
||||
if public_key.trim().is_empty() {
|
||||
return Err(SSHKeyValidationError::EmptyKey);
|
||||
}
|
||||
|
||||
if name.trim().is_empty() || !self.is_valid_key_name(name) {
|
||||
return Err(SSHKeyValidationError::InvalidName);
|
||||
}
|
||||
|
||||
// Parse the SSH key format: "type base64-key [comment]"
|
||||
let parts: Vec<&str> = public_key.trim().split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(SSHKeyValidationError::InvalidFormat);
|
||||
}
|
||||
|
||||
let key_type_str = parts[0];
|
||||
let key_data_str = parts[1];
|
||||
|
||||
// Determine key type
|
||||
let key_type = match key_type_str {
|
||||
"ssh-ed25519" => SSHKeyType::Ed25519,
|
||||
"ssh-rsa" => SSHKeyType::Rsa,
|
||||
"ecdsa-sha2-nistp256" => SSHKeyType::EcdsaP256,
|
||||
"ecdsa-sha2-nistp384" => SSHKeyType::EcdsaP384,
|
||||
"ecdsa-sha2-nistp521" => SSHKeyType::EcdsaP521,
|
||||
_ => return Err(SSHKeyValidationError::UnsupportedKeyType),
|
||||
};
|
||||
|
||||
// Check if key type is allowed
|
||||
if !self.config.allowed_key_types.contains(&key_type) {
|
||||
return Err(SSHKeyValidationError::UnsupportedKeyType);
|
||||
}
|
||||
|
||||
// Validate base64 encoding
|
||||
let key_bytes = general_purpose::STANDARD
|
||||
.decode(key_data_str)
|
||||
.map_err(|_| SSHKeyValidationError::InvalidEncoding)?;
|
||||
|
||||
// Additional RSA key size validation
|
||||
if key_type == SSHKeyType::Rsa {
|
||||
// For RSA keys, we should validate the key size
|
||||
// This is a simplified check - in production you might want more thorough validation
|
||||
if key_bytes.len() < 256 { // Rough estimate for 2048-bit RSA key
|
||||
return Err(SSHKeyValidationError::KeyTooShort);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SHA256 fingerprint
|
||||
let fingerprint = self.generate_fingerprint(&key_bytes);
|
||||
|
||||
Ok((key_type, fingerprint))
|
||||
}
|
||||
|
||||
/// Generate SHA256 fingerprint for SSH key
|
||||
fn generate_fingerprint(&self, key_bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_bytes);
|
||||
let result = hasher.finalize();
|
||||
|
||||
// Format as SHA256:base64 (modern format)
|
||||
format!("SHA256:{}", general_purpose::STANDARD.encode(result))
|
||||
}
|
||||
|
||||
/// Validate key name (alphanumeric, spaces, hyphens, underscores)
|
||||
fn is_valid_key_name(&self, name: &str) -> bool {
|
||||
if name.len() > 100 {
|
||||
return false;
|
||||
}
|
||||
|
||||
name.chars().all(|c| c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Check if user has reached maximum keys limit
|
||||
pub fn check_key_limit(&self, user_email: &str) -> Result<(), SSHKeyValidationError> {
|
||||
if let Some(max_keys) = self.config.max_keys_per_user {
|
||||
if let Some(user_data) = UserPersistence::load_user_data(user_email) {
|
||||
if user_data.ssh_keys.len() >= max_keys as usize {
|
||||
return Err(SSHKeyValidationError::InvalidFormat); // Reuse this for now
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async: Check if user has reached maximum keys limit using locked persistence
|
||||
pub async fn check_key_limit_async(&self, user_email: &str, req_id: Option<&str>) -> Result<(), SSHKeyValidationError> {
|
||||
if let Some(max_keys) = self.config.max_keys_per_user {
|
||||
if let Some(user_data) = UserPersistence::load_user_data_locked(user_email, req_id).await {
|
||||
if user_data.ssh_keys.len() >= max_keys as usize {
|
||||
return Err(SSHKeyValidationError::InvalidFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if user already has this SSH key (prevent duplicates within same user)
|
||||
pub fn check_duplicate_key(&self, user_email: &str, public_key: &str) -> Result<(), SSHKeyValidationError> {
|
||||
if let Some(user_data) = UserPersistence::load_user_data(user_email) {
|
||||
for existing_key in &user_data.ssh_keys {
|
||||
if existing_key.matches_public_key(public_key) {
|
||||
return Err(SSHKeyValidationError::DuplicateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async: Check duplicate key using locked persistence
|
||||
pub async fn check_duplicate_key_async(&self, user_email: &str, public_key: &str, req_id: Option<&str>) -> Result<(), SSHKeyValidationError> {
|
||||
if let Some(user_data) = UserPersistence::load_user_data_locked(user_email, req_id).await {
|
||||
for existing_key in &user_data.ssh_keys {
|
||||
if existing_key.matches_public_key(public_key) {
|
||||
return Err(SSHKeyValidationError::DuplicateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add SSH key for a user
|
||||
pub fn add_ssh_key(&self, user_email: &str, name: &str, public_key: &str, is_default: bool) -> Result<SSHKey, SSHKeyValidationError> {
|
||||
// Validate the key
|
||||
let (key_type, fingerprint) = self.validate_ssh_key(public_key, name)?;
|
||||
|
||||
// Check limits and duplicates
|
||||
self.check_key_limit(user_email)?;
|
||||
self.check_duplicate_key(user_email, public_key)?;
|
||||
|
||||
// Load user data and add the key
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.unwrap_or_else(|| {
|
||||
crate::models::builders::SessionDataBuilder::new_user(user_email)
|
||||
});
|
||||
|
||||
// Auto-default logic: if this is the first key, make it default regardless of is_default parameter
|
||||
let is_first_key = user_data.ssh_keys.is_empty();
|
||||
let should_be_default = is_default || is_first_key;
|
||||
|
||||
// If this should be default, unset other default keys
|
||||
if should_be_default {
|
||||
for existing_key in &mut user_data.ssh_keys {
|
||||
existing_key.is_default = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the key with correct default status
|
||||
let ssh_key = SSHKey::builder()
|
||||
.name(name)
|
||||
.public_key(public_key)
|
||||
.fingerprint(fingerprint)
|
||||
.key_type(key_type)
|
||||
.is_default(should_be_default)
|
||||
.build()
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
user_data.ssh_keys.push(ssh_key.clone());
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
Ok(ssh_key)
|
||||
}
|
||||
|
||||
/// Async: Add SSH key using locked persistence and req_id propagation
|
||||
pub async fn add_ssh_key_async(&self, user_email: &str, name: &str, public_key: &str, is_default: bool, req_id: Option<&str>) -> Result<SSHKey, SSHKeyValidationError> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
log::info!(target: "api.ssh_keys", "add_ssh_key:start req_id={} email={} name={} default={}", req, user_email, name, is_default);
|
||||
// Validate the key
|
||||
let (key_type, fingerprint) = self.validate_ssh_key(public_key, name)?;
|
||||
|
||||
// Check limits and duplicates (async)
|
||||
self.check_key_limit_async(user_email, req_id).await?;
|
||||
self.check_duplicate_key_async(user_email, public_key, req_id).await?;
|
||||
|
||||
// Load user data and add the key
|
||||
let mut user_data = UserPersistence::load_user_data_locked(user_email, req_id).await
|
||||
.unwrap_or_else(|| crate::models::builders::SessionDataBuilder::new_user(user_email));
|
||||
|
||||
// Auto-default logic
|
||||
let is_first_key = user_data.ssh_keys.is_empty();
|
||||
let should_be_default = is_default || is_first_key;
|
||||
if should_be_default {
|
||||
for existing_key in &mut user_data.ssh_keys {
|
||||
existing_key.is_default = false;
|
||||
}
|
||||
}
|
||||
|
||||
let ssh_key = SSHKey::builder()
|
||||
.name(name)
|
||||
.public_key(public_key)
|
||||
.fingerprint(fingerprint)
|
||||
.key_type(key_type)
|
||||
.is_default(should_be_default)
|
||||
.build()
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
user_data.ssh_keys.push(ssh_key.clone());
|
||||
|
||||
// Save updated user data with lock
|
||||
UserPersistence::save_user_data_locked(&user_data, req_id)
|
||||
.await
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "add_ssh_key:success req_id={} email={} ms={}", req, user_email, total_ms);
|
||||
Ok(ssh_key)
|
||||
}
|
||||
|
||||
/// Get all SSH keys for a user
|
||||
pub fn get_user_ssh_keys(&self, user_email: &str) -> Vec<SSHKey> {
|
||||
UserPersistence::load_user_data(user_email)
|
||||
.map(|data| data.ssh_keys)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Async: Get all SSH keys for a user using locked persistence
|
||||
pub async fn get_user_ssh_keys_async(&self, user_email: &str, req_id: Option<&str>) -> Vec<SSHKey> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
let result = UserPersistence::load_user_data_locked(user_email, req_id)
|
||||
.await
|
||||
.map(|data| data.ssh_keys)
|
||||
.unwrap_or_default();
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "list_keys:success req_id={} email={} count={} ms={}", req, user_email, result.len(), total_ms);
|
||||
result
|
||||
}
|
||||
|
||||
/// Get SSH key by ID for a user
|
||||
pub fn get_ssh_key_by_id(&self, user_email: &str, key_id: &str) -> Option<SSHKey> {
|
||||
self.get_user_ssh_keys(user_email)
|
||||
.into_iter()
|
||||
.find(|key| key.id == key_id)
|
||||
}
|
||||
|
||||
/// Async: Get SSH key by ID
|
||||
pub async fn get_ssh_key_by_id_async(&self, user_email: &str, key_id: &str, req_id: Option<&str>) -> Option<SSHKey> {
|
||||
self.get_user_ssh_keys_async(user_email, req_id).await
|
||||
.into_iter()
|
||||
.find(|key| key.id == key_id)
|
||||
}
|
||||
|
||||
/// Update SSH key (name, default status)
|
||||
pub fn update_ssh_key(&self, user_email: &str, key_id: &str, name: Option<&str>, is_default: Option<bool>) -> Result<SSHKey, SSHKeyValidationError> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Find the key to update
|
||||
let key_index = user_data.ssh_keys
|
||||
.iter()
|
||||
.position(|key| key.id == key_id)
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Update the key
|
||||
if let Some(new_name) = name {
|
||||
if !self.is_valid_key_name(new_name) {
|
||||
return Err(SSHKeyValidationError::InvalidName);
|
||||
}
|
||||
user_data.ssh_keys[key_index].name = new_name.to_string();
|
||||
}
|
||||
|
||||
if let Some(set_default) = is_default {
|
||||
if set_default {
|
||||
// Unset all other default keys
|
||||
for key in &mut user_data.ssh_keys {
|
||||
key.is_default = false;
|
||||
}
|
||||
}
|
||||
user_data.ssh_keys[key_index].is_default = set_default;
|
||||
}
|
||||
|
||||
let updated_key = user_data.ssh_keys[key_index].clone();
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
Ok(updated_key)
|
||||
}
|
||||
|
||||
/// Async: Update SSH key using locked persistence
|
||||
pub async fn update_ssh_key_async(&self, user_email: &str, key_id: &str, name: Option<&str>, is_default: Option<bool>, req_id: Option<&str>) -> Result<SSHKey, SSHKeyValidationError> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
log::info!(target: "api.ssh_keys", "update_key:start req_id={} email={} key_id={} name_set={} default_set={}", req, user_email, key_id, name.is_some(), is_default.is_some());
|
||||
let mut user_data = UserPersistence::load_user_data_locked(user_email, req_id)
|
||||
.await
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Find the key to update
|
||||
let key_index = user_data.ssh_keys
|
||||
.iter()
|
||||
.position(|key| key.id == key_id)
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Update the key
|
||||
if let Some(new_name) = name {
|
||||
if !self.is_valid_key_name(new_name) {
|
||||
return Err(SSHKeyValidationError::InvalidName);
|
||||
}
|
||||
user_data.ssh_keys[key_index].name = new_name.to_string();
|
||||
}
|
||||
|
||||
if let Some(set_default) = is_default {
|
||||
if set_default {
|
||||
for key in &mut user_data.ssh_keys {
|
||||
key.is_default = false;
|
||||
}
|
||||
}
|
||||
user_data.ssh_keys[key_index].is_default = set_default;
|
||||
}
|
||||
|
||||
let updated_key = user_data.ssh_keys[key_index].clone();
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data_locked(&user_data, req_id)
|
||||
.await
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "update_key:success req_id={} email={} key_id={} ms={}", req, user_email, key_id, total_ms);
|
||||
Ok(updated_key)
|
||||
}
|
||||
|
||||
/// Delete SSH key
|
||||
pub fn delete_ssh_key(&self, user_email: &str, key_id: &str) -> Result<(), SSHKeyValidationError> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Find and remove the key
|
||||
let initial_len = user_data.ssh_keys.len();
|
||||
user_data.ssh_keys.retain(|key| key.id != key_id);
|
||||
|
||||
if user_data.ssh_keys.len() == initial_len {
|
||||
return Err(SSHKeyValidationError::InvalidFormat); // Key not found
|
||||
}
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async: Delete SSH key using locked persistence
|
||||
pub async fn delete_ssh_key_async(&self, user_email: &str, key_id: &str, req_id: Option<&str>) -> Result<(), SSHKeyValidationError> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
log::info!(target: "api.ssh_keys", "delete_key:start req_id={} email={} key_id={}", req, user_email, key_id);
|
||||
let mut user_data = UserPersistence::load_user_data_locked(user_email, req_id)
|
||||
.await
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
let initial_len = user_data.ssh_keys.len();
|
||||
user_data.ssh_keys.retain(|key| key.id != key_id);
|
||||
if user_data.ssh_keys.len() == initial_len {
|
||||
return Err(SSHKeyValidationError::InvalidFormat);
|
||||
}
|
||||
|
||||
UserPersistence::save_user_data_locked(&user_data, req_id)
|
||||
.await
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "delete_key:success req_id={} email={} key_id={} ms={}", req, user_email, key_id, total_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set SSH key as default
|
||||
pub fn set_default_ssh_key(&self, user_email: &str, key_id: &str) -> Result<(), SSHKeyValidationError> {
|
||||
self.update_ssh_key(user_email, key_id, None, Some(true))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async: Set SSH key as default
|
||||
pub async fn set_default_ssh_key_async(&self, user_email: &str, key_id: &str, req_id: Option<&str>) -> Result<(), SSHKeyValidationError> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
log::info!(target: "api.ssh_keys", "set_default:start req_id={} email={} key_id={}", req, user_email, key_id);
|
||||
self.update_ssh_key_async(user_email, key_id, None, Some(true), req_id).await?;
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "set_default:success req_id={} email={} key_id={} ms={}", req, user_email, key_id, total_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get default SSH key for user
|
||||
pub fn get_default_ssh_key(&self, user_email: &str) -> Option<SSHKey> {
|
||||
self.get_user_ssh_keys(user_email)
|
||||
.into_iter()
|
||||
.find(|key| key.is_default)
|
||||
}
|
||||
|
||||
/// Async: Get default SSH key for user
|
||||
pub async fn get_default_ssh_key_async(&self, user_email: &str, req_id: Option<&str>) -> Option<SSHKey> {
|
||||
self.get_user_ssh_keys_async(user_email, req_id).await
|
||||
.into_iter()
|
||||
.find(|key| key.is_default)
|
||||
}
|
||||
|
||||
/// Update last used timestamp for SSH key
|
||||
pub fn mark_key_used(&self, user_email: &str, key_id: &str) -> Result<(), SSHKeyValidationError> {
|
||||
let mut user_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
// Find the key and update last_used
|
||||
if let Some(key) = user_data.ssh_keys.iter_mut().find(|key| key.id == key_id) {
|
||||
key.last_used = Some(Utc::now());
|
||||
|
||||
// Save updated user data
|
||||
UserPersistence::save_user_data(&user_data)
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async: Update last used timestamp for SSH key using locked persistence
|
||||
pub async fn mark_key_used_async(&self, user_email: &str, key_id: &str, req_id: Option<&str>) -> Result<(), SSHKeyValidationError> {
|
||||
let req = req_id.unwrap_or("-");
|
||||
let start_total = Instant::now();
|
||||
log::info!(target: "api.ssh_keys", "mark_used:start req_id={} email={} key_id={}", req, user_email, key_id);
|
||||
let mut user_data = UserPersistence::load_user_data_locked(user_email, req_id)
|
||||
.await
|
||||
.ok_or(SSHKeyValidationError::InvalidFormat)?;
|
||||
|
||||
if let Some(key) = user_data.ssh_keys.iter_mut().find(|key| key.id == key_id) {
|
||||
key.last_used = Some(Utc::now());
|
||||
UserPersistence::save_user_data_locked(&user_data, req_id)
|
||||
.await
|
||||
.map_err(|_| SSHKeyValidationError::InvalidFormat)?;
|
||||
}
|
||||
let total_ms = start_total.elapsed().as_millis();
|
||||
log::info!(target: "api.ssh_keys", "mark_used:success req_id={} email={} key_id={} ms={}", req, user_email, key_id, total_ms);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_ed25519_key() {
|
||||
let service = SSHKeyService::new();
|
||||
let ed25519_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG4rT3vTt99Ox5H5NIhL6uT+uenX7vtknT/b+O9/7Gq8 test@example.com";
|
||||
|
||||
let result = service.validate_ssh_key(ed25519_key, "Test Key");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (key_type, _fingerprint) = result.unwrap();
|
||||
assert_eq!(key_type, SSHKeyType::Ed25519);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_key_format() {
|
||||
let service = SSHKeyService::new();
|
||||
let invalid_key = "invalid-key-format";
|
||||
|
||||
let result = service.validate_ssh_key(invalid_key, "Test Key");
|
||||
assert!(matches!(result, Err(SSHKeyValidationError::InvalidFormat)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_key_name() {
|
||||
let service = SSHKeyService::new();
|
||||
let ed25519_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG4rT3vTt99Ox5H5NIhL6uT+uenX7vtknT/b+O9/7Gq8";
|
||||
|
||||
let result = service.validate_ssh_key(ed25519_key, "");
|
||||
assert!(matches!(result, Err(SSHKeyValidationError::InvalidName)));
|
||||
}
|
||||
}
|
1513
src/services/user_persistence.rs
Normal file
1513
src/services/user_persistence.rs
Normal file
File diff suppressed because it is too large
Load Diff
437
src/services/user_service.rs
Normal file
437
src/services/user_service.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use crate::models::user::{UserActivity, UsageStatistics, UserPreferences, Transaction};
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::{Utc, Datelike};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for UserService
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserServiceConfig {
|
||||
pub activity_limit: usize,
|
||||
}
|
||||
|
||||
impl Default for UserServiceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
activity_limit: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for UserService following established pattern
|
||||
#[derive(Default)]
|
||||
pub struct UserServiceBuilder {
|
||||
activity_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl UserServiceBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn activity_limit(mut self, limit: usize) -> Self {
|
||||
self.activity_limit = Some(limit);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<UserService, String> {
|
||||
let config = UserServiceConfig {
|
||||
activity_limit: self.activity_limit.unwrap_or(50),
|
||||
};
|
||||
|
||||
Ok(UserService { config })
|
||||
}
|
||||
}
|
||||
|
||||
/// Main UserService for managing user dashboard data
|
||||
#[derive(Clone)]
|
||||
pub struct UserService {
|
||||
config: UserServiceConfig,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
/// Create a new builder for UserService
|
||||
pub fn builder() -> UserServiceBuilder {
|
||||
UserServiceBuilder::new()
|
||||
}
|
||||
|
||||
/// Get user activities with optional limit
|
||||
pub fn get_user_activities(&self, user_email: &str, limit: Option<usize>) -> Vec<UserActivity> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
let mut activities = persistent_data.user_activities;
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
activities.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
// Apply limit
|
||||
let limit = limit.unwrap_or(self.config.activity_limit);
|
||||
activities.truncate(limit);
|
||||
activities
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user purchase history from transactions
|
||||
pub fn get_purchase_history(&self, user_email: &str) -> Vec<Transaction> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
let purchases: Vec<Transaction> = persistent_data.transactions
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t.transaction_type, crate::models::user::TransactionType::Purchase { .. }))
|
||||
.collect();
|
||||
purchases
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or calculate usage statistics
|
||||
pub fn get_usage_statistics(&self, user_email: &str) -> Option<UsageStatistics> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
// Return existing statistics or calculate new ones
|
||||
if let Some(stats) = persistent_data.usage_statistics {
|
||||
Some(stats)
|
||||
} else {
|
||||
Some(self.calculate_usage_statistics(user_email, &persistent_data))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active deployments for user
|
||||
pub fn get_active_deployments(&self, user_email: &str) -> Vec<crate::models::user::UserDeployment> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
// Convert active product rentals to deployments
|
||||
let deployments: Vec<crate::models::user::UserDeployment> = persistent_data.active_product_rentals
|
||||
.into_iter()
|
||||
.filter(|r| r.status == "Active")
|
||||
.map(|rental| crate::models::user::UserDeployment {
|
||||
id: rental.id,
|
||||
app_name: rental.product_name,
|
||||
status: crate::models::user::DeploymentStatus::Active,
|
||||
cost_per_month: rental.monthly_cost,
|
||||
deployed_at: chrono::DateTime::parse_from_rfc3339(&rental.start_date)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
provider: rental.provider_email,
|
||||
region: rental.metadata.get("region")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
resource_usage: rental.metadata.get("resource_usage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
deployments
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get user's published services (for service-provider dashboard)
|
||||
pub fn get_user_published_services(&self, user_email: &str) -> Vec<crate::models::user::Service> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
persistent_data.services
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user's purchased services (for user dashboard) - derived from service bookings
|
||||
pub fn get_user_purchased_services(&self, user_email: &str) -> Vec<crate::models::user::Service> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
// Convert service bookings to service view for user dashboard
|
||||
// IMPORTANT: Only show bookings where THIS user is the customer
|
||||
let purchased_services: Vec<crate::models::user::Service> = persistent_data.service_bookings
|
||||
.into_iter()
|
||||
.filter(|b| b.customer_email == user_email)
|
||||
.map(|booking| {
|
||||
let hourly_rate = (booking.budget / rust_decimal::Decimal::from(booking.estimated_hours.unwrap_or(1).max(1))).to_string().parse::<i32>().unwrap_or(0);
|
||||
crate::models::user::Service {
|
||||
id: booking.service_id,
|
||||
name: booking.service_name,
|
||||
category: "Service".to_string(), // Default category for purchased services
|
||||
description: booking.description.unwrap_or_else(|| "Service booking".to_string()),
|
||||
price_usd: booking.budget,
|
||||
hourly_rate_usd: Some(rust_decimal::Decimal::new(hourly_rate as i64, 0)),
|
||||
availability: true,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&booking.requested_date)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&booking.requested_date)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
price_per_hour_usd: hourly_rate,
|
||||
status: booking.status,
|
||||
clients: 0, // Users don't see client count for purchased services
|
||||
rating: 4.5, // Default rating
|
||||
total_hours: booking.estimated_hours,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
purchased_services
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user's purchased applications (for user dashboard) - derived from deployments
|
||||
pub fn get_user_applications(&self, user_email: &str) -> Vec<crate::models::user::PublishedApp> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
// Convert app deployments to application view for user dashboard
|
||||
// IMPORTANT: Only show deployments where THIS user is the customer
|
||||
let purchased_apps: Vec<crate::models::user::PublishedApp> = persistent_data.app_deployments
|
||||
.into_iter()
|
||||
.filter(|d| d.status == "Active" && d.customer_email == user_email)
|
||||
.map(|deployment| crate::models::user::PublishedApp {
|
||||
id: deployment.app_id,
|
||||
name: deployment.app_name,
|
||||
description: Some("User-deployed application".to_string()),
|
||||
category: "Application".to_string(), // Default category for purchased apps
|
||||
version: "1.0.0".to_string(), // Default version
|
||||
price_usd: rust_decimal::Decimal::ZERO,
|
||||
deployment_count: 1,
|
||||
status: deployment.status,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&deployment.created_at)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&deployment.last_updated)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
auto_scaling: Some(false),
|
||||
auto_healing: deployment.auto_healing,
|
||||
revenue_history: Vec::new(),
|
||||
deployments: 1, // User has 1 deployment of this app
|
||||
rating: 4.5, // Default rating
|
||||
monthly_revenue_usd: rust_decimal::Decimal::ZERO, // Users don't earn revenue from purchased apps
|
||||
last_updated: chrono::DateTime::parse_from_rfc3339(&deployment.last_updated)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
.collect();
|
||||
purchased_apps
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user's active compute resources from rentals
|
||||
pub fn get_user_compute_resources(&self, user_email: &str) -> Vec<crate::models::user::UserComputeResource> {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
let compute_resources: Vec<crate::models::user::UserComputeResource> = persistent_data.active_product_rentals
|
||||
.into_iter()
|
||||
.filter(|r| r.status == "Active")
|
||||
.map(|rental| crate::models::user::UserComputeResource {
|
||||
id: rental.product_name.clone(),
|
||||
resource_type: rental.metadata.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
specs: rental.metadata.get("specs")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
location: rental.metadata.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
status: rental.status.clone(),
|
||||
sla: rental.metadata.get("sla")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
monthly_cost: rental.monthly_cost,
|
||||
provider: rental.provider_email,
|
||||
resource_usage: rental.metadata.get("resource_usage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
compute_resources
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate comprehensive user metrics
|
||||
pub fn calculate_user_metrics(&self, user_email: &str) -> crate::models::user::UserMetrics {
|
||||
if let Some(persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
// Calculate total spent this month
|
||||
let current_month = Utc::now().format("%Y-%m").to_string();
|
||||
let total_spent_this_month: Decimal = persistent_data.transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.timestamp.format("%Y-%m").to_string() == current_month &&
|
||||
matches!(t.transaction_type, crate::models::user::TransactionType::Purchase { .. })
|
||||
})
|
||||
.map(|t| t.amount)
|
||||
.sum();
|
||||
|
||||
// Count active deployments
|
||||
let active_deployments_count = persistent_data.active_product_rentals
|
||||
.iter()
|
||||
.filter(|r| r.status == "Active")
|
||||
.count() as i32;
|
||||
|
||||
// Calculate resource utilization from active rentals
|
||||
let resource_utilization = self.calculate_resource_utilization(&persistent_data.active_product_rentals);
|
||||
|
||||
// Generate cost trend (last 6 months)
|
||||
let cost_trend = self.calculate_cost_trend(&persistent_data.transactions);
|
||||
|
||||
crate::models::user::UserMetrics {
|
||||
total_spent_this_month,
|
||||
active_deployments_count,
|
||||
resource_utilization,
|
||||
cost_trend,
|
||||
wallet_balance: persistent_data.wallet_balance_usd,
|
||||
total_transactions: persistent_data.transactions.len() as i32,
|
||||
}
|
||||
} else {
|
||||
crate::models::user::UserMetrics::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add new user activity
|
||||
pub fn add_user_activity(&self, user_email: &str, activity: UserActivity) -> Result<(), String> {
|
||||
if let Some(mut persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
persistent_data.user_activities.push(activity);
|
||||
|
||||
// Keep only recent activities (limit to prevent file bloat)
|
||||
persistent_data.user_activities.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
persistent_data.user_activities.truncate(100);
|
||||
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save user activity: {}", e))
|
||||
} else {
|
||||
Err("User data not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
pub fn update_user_preferences(&self, user_email: &str, preferences: UserPreferences) -> Result<(), String> {
|
||||
if let Some(mut persistent_data) = UserPersistence::load_user_data(user_email) {
|
||||
persistent_data.user_preferences = Some(preferences);
|
||||
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save user preferences: {}", e))
|
||||
} else {
|
||||
Err("User data not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
fn calculate_usage_statistics(&self, _user_email: &str, persistent_data: &crate::services::user_persistence::UserPersistentData) -> UsageStatistics {
|
||||
let total_deployments = persistent_data.active_product_rentals.len() as i32;
|
||||
let total_spent = persistent_data.transactions
|
||||
.iter()
|
||||
.filter(|t| matches!(t.transaction_type, crate::models::user::TransactionType::Purchase { .. }))
|
||||
.map(|t| t.amount)
|
||||
.sum();
|
||||
|
||||
// Calculate favorite categories from purchase history
|
||||
let mut category_counts: HashMap<String, i32> = HashMap::new();
|
||||
for rental in &persistent_data.active_product_rentals {
|
||||
if let Some(category) = rental.metadata.get("category").and_then(|v| v.as_str()) {
|
||||
*category_counts.entry(category.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut favorite_categories_vec: Vec<(String, i32)> = category_counts
|
||||
.into_iter()
|
||||
.collect();
|
||||
favorite_categories_vec.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let favorite_categories = favorite_categories_vec
|
||||
.into_iter()
|
||||
.take(5)
|
||||
.map(|(category, _)| category)
|
||||
.collect();
|
||||
|
||||
UsageStatistics {
|
||||
cpu_usage: 15.0, // TODO: Calculate from actual deployments
|
||||
memory_usage: 45.0, // TODO: Calculate from actual deployments
|
||||
storage_usage: 60.0, // TODO: Calculate from actual deployments
|
||||
network_usage: 25.0, // TODO: Calculate from actual deployments
|
||||
total_deployments,
|
||||
active_services: persistent_data.services.len() as i32,
|
||||
total_spent,
|
||||
favorite_categories,
|
||||
usage_trends: Vec::new(), // TODO: Implement trend calculation
|
||||
login_frequency: 3.5, // TODO: Calculate from activity log
|
||||
preferred_regions: vec!["Amsterdam".to_string(), "New York".to_string()], // TODO: Calculate from deployments
|
||||
account_age_days: 90, // TODO: Calculate from creation date
|
||||
last_activity: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_resource_utilization(&self, rentals: &[crate::services::user_persistence::ProductRental]) -> crate::models::user::ResourceUtilization {
|
||||
// Calculate average resource utilization across active rentals
|
||||
if rentals.is_empty() {
|
||||
return crate::models::user::ResourceUtilization {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
storage: 0,
|
||||
network: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let mut total_cpu = 0;
|
||||
let mut total_memory = 0;
|
||||
let mut total_storage = 0;
|
||||
let mut total_network = 0;
|
||||
let mut count = 0;
|
||||
|
||||
for rental in rentals {
|
||||
if let Some(usage) = rental.metadata.get("resource_usage") {
|
||||
if let Ok(usage) = serde_json::from_value::<crate::models::user::ResourceUtilization>(usage.clone()) {
|
||||
total_cpu += usage.cpu;
|
||||
total_memory += usage.memory;
|
||||
total_storage += usage.storage;
|
||||
total_network += usage.network;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
crate::models::user::ResourceUtilization {
|
||||
cpu: total_cpu / count,
|
||||
memory: total_memory / count,
|
||||
storage: total_storage / count,
|
||||
network: total_network / count,
|
||||
}
|
||||
} else {
|
||||
crate::models::user::ResourceUtilization {
|
||||
cpu: 45, // Default reasonable values
|
||||
memory: 60,
|
||||
storage: 35,
|
||||
network: 25,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_cost_trend(&self, transactions: &[Transaction]) -> Vec<i32> {
|
||||
// Calculate monthly spending for last 6 months
|
||||
let mut monthly_costs = vec![0; 6];
|
||||
let current_date = Utc::now();
|
||||
|
||||
for transaction in transactions {
|
||||
if matches!(transaction.transaction_type, crate::models::user::TransactionType::Purchase { .. }) {
|
||||
let months_ago = (current_date.year() * 12 + current_date.month() as i32) -
|
||||
(transaction.timestamp.year() * 12 + transaction.timestamp.month() as i32);
|
||||
|
||||
if months_ago >= 0 && months_ago < 6 {
|
||||
let index = (5 - months_ago) as usize;
|
||||
if index < monthly_costs.len() {
|
||||
monthly_costs[index] += transaction.amount.to_string().parse::<i32>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monthly_costs
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user