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, fetched_at: Instant, } struct CatalogCache { with_slice: Option, without_slice: Option, } impl Default for CatalogCache { fn default() -> Self { Self { with_slice: None, without_slice: None } } } static CATALOG_CACHE: OnceLock> = OnceLock::new(); /// Product search and filter criteria #[derive(Debug, Clone)] pub struct ProductSearchCriteria { pub query: Option, pub category_id: Option, pub min_price: Option, pub max_price: Option, pub provider_id: Option, pub location: Option, pub tags: Vec, pub availability: Option, pub featured_only: bool, pub attributes: HashMap, } /// Product search results with metadata #[derive(Debug, Clone)] pub struct ProductSearchResult { pub products: Vec, 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, ) -> 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 { 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 { 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 { self.get_all_products() .into_iter() .find(|p| p.id == id) } /// Get slice products only pub fn get_slice_products(&self) -> Vec { 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 { 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(resource_provider_email) = product.attributes.get("resource_provider_email") { details["resource_provider_email"] = resource_provider_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) -> Vec { 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 { self.get_all_products() .into_iter() .filter(|p| p.category_id == category_id) .collect() } /// Get featured products pub fn get_featured_products(&self) -> Vec { 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 { 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 { 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 = 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 { 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 { 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, 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 { if let Some(product) = self.get_product_by_id(product_id) { // Simple recommendation logic: same category, different products let mut recommendations: Vec = 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 { 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 = HashMap::default(); for product in &products { *category_counts.entry(product.category_id.clone()).or_insert(0) += 1; } let category_stats: Vec = 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 = 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::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 = 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, ) -> 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 { 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::>(&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 { use std::collections::HashSet; let mut seen: HashSet = 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, max_price: Option) -> 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) -> 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 } }