init projectmycelium

This commit is contained in:
mik-tf
2025-09-01 21:37:01 -04:00
commit b41efb0e99
319 changed files with 128160 additions and 0 deletions

172
src/services/auto_topup.rs Normal file
View 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
View 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(&currency_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(&currency.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
View 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

File diff suppressed because it is too large Load Diff

362
src/services/grid.rs Normal file
View 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)
}
}

View 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
View 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
View 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()
}
}

View 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(&region.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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
}
}

View 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(())
}
}

View 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,
}
}
}

View 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,
}

View 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,
}

View 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)));
}
}

File diff suppressed because it is too large Load Diff

View 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
}
}