This repository has been archived on 2025-12-01. You can view files and clone it, but cannot push or open issues or pull requests.
Files
projectmycelium_old/src/services/product.rs

772 lines
28 KiB
Rust

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