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

190
src/bin/cleanup.rs Normal file
View File

@@ -0,0 +1,190 @@
//! Standalone data cleanup utility
//! Run with: cargo run --bin cleanup
use std::collections::HashMap;
fn main() {
// Initialize logging
env_logger::init();
println!("🧹 Project Mycelium Data Cleanup Utility");
println!("==============================================");
// Manually clean up user1's duplicate nodes
match cleanup_user1_data() {
Ok(changes) => {
println!("✅ Cleanup completed successfully!");
if changes > 0 {
println!("📊 Changes made: {}", changes);
} else {
println!("📊 No changes needed - data is already clean");
}
}
Err(e) => {
println!("❌ Cleanup failed: {}", e);
std::process::exit(1);
}
}
}
fn cleanup_user1_data() -> Result<usize, String> {
use serde_json::Value;
let file_path = "./user_data/user1_at_example_com.json";
// Read the current data
let data_str = std::fs::read_to_string(file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
let mut data: Value = serde_json::from_str(&data_str)
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
// Get the nodes array
let nodes = data.get_mut("nodes")
.and_then(|n| n.as_array_mut())
.ok_or("No nodes array found")?;
let original_count = nodes.len();
println!("📊 Found {} nodes before cleanup", original_count);
// Group nodes by grid_node_id
let mut node_groups: HashMap<u32, Vec<(usize, Value)>> = HashMap::new();
for (index, node) in nodes.iter().enumerate() {
if let Some(grid_id) = node.get("grid_node_id").and_then(|id| id.as_u64()) {
node_groups.entry(grid_id as u32)
.or_insert_with(Vec::new)
.push((index, node.clone()));
}
}
// Find and resolve duplicates
let mut nodes_to_keep = Vec::new();
let mut duplicates_removed = 0;
for (grid_id, mut group_nodes) in node_groups {
if group_nodes.len() > 1 {
println!("🔍 Found {} duplicate nodes for grid_node_id: {}", group_nodes.len(), grid_id);
// Sort by quality: prefer nodes with slice combinations and marketplace SLA
group_nodes.sort_by(|a, b| {
let score_a = calculate_node_quality_score(&a.1);
let score_b = calculate_node_quality_score(&b.1);
score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
});
// Keep the best node, merge data from others
let mut best_node = group_nodes[0].1.clone();
// Merge slice data from other nodes if the best node is missing it
for (_, other_node) in &group_nodes[1..] {
if best_node.get("available_combinations")
.and_then(|ac| ac.as_array())
.map_or(true, |arr| arr.is_empty()) {
if let Some(other_combinations) = other_node.get("available_combinations") {
if let Some(other_array) = other_combinations.as_array() {
if !other_array.is_empty() {
if let Some(best_obj) = best_node.as_object_mut() {
best_obj.insert("available_combinations".to_string(), other_combinations.clone());
if let Some(total_slices) = other_node.get("total_base_slices") {
best_obj.insert("total_base_slices".to_string(), total_slices.clone());
}
if let Some(slice_calc) = other_node.get("slice_last_calculated") {
best_obj.insert("slice_last_calculated".to_string(), slice_calc.clone());
}
println!("🔄 Merged slice data from duplicate node");
}
}
}
}
}
if best_node.get("marketplace_sla").is_none() {
if let Some(other_sla) = other_node.get("marketplace_sla") {
if let Some(best_obj) = best_node.as_object_mut() {
best_obj.insert("marketplace_sla".to_string(), other_sla.clone());
println!("🔄 Merged marketplace SLA from duplicate node");
}
}
}
if best_node.get("rental_options").is_none() {
if let Some(other_rental) = other_node.get("rental_options") {
if let Some(best_obj) = best_node.as_object_mut() {
best_obj.insert("rental_options".to_string(), other_rental.clone());
println!("🔄 Merged rental options from duplicate node");
}
}
}
}
nodes_to_keep.push(best_node);
duplicates_removed += group_nodes.len() - 1;
println!("🧹 Removed {} duplicate nodes for grid_node_id: {}", group_nodes.len() - 1, grid_id);
} else {
// Single node, keep as is
nodes_to_keep.push(group_nodes[0].1.clone());
}
}
// Update the data
if let Some(data_obj) = data.as_object_mut() {
data_obj.insert("nodes".to_string(), Value::Array(nodes_to_keep));
}
// Write back to file
let updated_data_str = serde_json::to_string_pretty(&data)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?;
std::fs::write(file_path, updated_data_str)
.map_err(|e| format!("Failed to write file: {}", e))?;
let final_count = data.get("nodes")
.and_then(|n| n.as_array())
.map_or(0, |arr| arr.len());
println!("📊 Cleanup complete: {} -> {} nodes ({} duplicates removed)",
original_count, final_count, duplicates_removed);
Ok(duplicates_removed)
}
fn calculate_node_quality_score(node: &serde_json::Value) -> f32 {
let mut score = 0.0;
// Prefer nodes with slice combinations
if let Some(combinations) = node.get("available_combinations").and_then(|ac| ac.as_array()) {
if !combinations.is_empty() {
score += 10.0;
}
}
// Prefer nodes with marketplace SLA
if node.get("marketplace_sla").is_some() {
score += 5.0;
}
// Prefer nodes with rental options
if node.get("rental_options").is_some() {
score += 3.0;
}
// Prefer nodes with recent slice calculations
if node.get("slice_last_calculated").is_some() {
score += 2.0;
}
// Prefer nodes with grid data
if node.get("grid_data").is_some() {
score += 1.0;
}
// Prefer nodes with higher total base slices
if let Some(total_slices) = node.get("total_base_slices").and_then(|ts| ts.as_u64()) {
score += total_slices as f32 * 0.1;
}
score
}

532
src/config/builder.rs Normal file
View File

@@ -0,0 +1,532 @@
use std::env;
use serde::{Deserialize, Serialize};
/// Centralized configuration builder for all environment variables and app settings
///
/// This builder consolidates all scattered env::var() calls throughout the codebase
/// into a single source of truth, following the established builder pattern architecture.
///
/// Usage:
/// ```rust,ignore
/// let config = ConfigurationBuilder::new().build();
/// if config.is_gitea_enabled() {
/// // Handle Gitea flow
/// }
/// ```
#[derive(Debug, Clone)]
pub struct ConfigurationBuilder {
// OAuth Configuration
gitea_client_id: Option<String>,
gitea_client_secret: Option<String>,
gitea_instance_url: Option<String>,
// App Configuration
app_url: String,
jwt_secret: String,
secret_key: Option<String>,
app_config_path: Option<String>,
/// Whether mock data/services are enabled (dev/test default: true; prod default: false)
enable_mock_data: bool,
/// Data source for marketplace data (fixtures/mock/live)
data_source: DataSource,
/// Filesystem path to fixtures (used when data_source=fixtures)
fixtures_path: String,
/// Whether demo mode UX/guards are enabled
demo_mode: bool,
/// Whether to enable in-memory catalog cache (dev/test only by default)
catalog_cache_enabled: bool,
/// TTL for catalog cache in seconds
catalog_cache_ttl_secs: u64,
// Server Configuration
environment: AppEnvironment,
}
/// Application environment types
#[derive(Debug, Clone, PartialEq)]
pub enum AppEnvironment {
Development,
Production,
Testing,
}
/// Data source for marketplace data
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DataSource {
/// Legacy in-memory mocks (dev only)
Mock,
/// Filesystem-backed fixtures under fixtures_path
Fixtures,
/// Live backend (e.g., PostgREST/DB)
Live,
}
/// Built configuration ready for use throughout the application
#[derive(Debug, Clone)]
pub struct AppConfiguration {
// OAuth Configuration
pub gitea_client_id: Option<String>,
pub gitea_client_secret: Option<String>,
pub gitea_instance_url: Option<String>,
// App Configuration
pub app_url: String,
pub jwt_secret: String,
pub secret_key: Option<String>,
pub app_config_path: Option<String>,
/// Whether mock data/services are enabled
pub enable_mock_data: bool,
/// Selected data source
pub data_source: DataSource,
/// Fixtures path when using fixtures
pub fixtures_path: String,
/// Demo mode enabled
pub demo_mode: bool,
/// Catalog cache enabled
pub catalog_cache_enabled: bool,
/// Catalog cache TTL in seconds
pub catalog_cache_ttl_secs: u64,
// Server Configuration
pub environment: AppEnvironment,
}
impl ConfigurationBuilder {
/// Creates a new configuration builder with default values
pub fn new() -> Self {
Self {
gitea_client_id: None,
gitea_client_secret: None,
gitea_instance_url: None,
app_url: "http://localhost:9999".to_string(),
jwt_secret: "your_jwt_secret_key".to_string(),
secret_key: None,
app_config_path: None,
enable_mock_data: true, // default true for development
data_source: DataSource::Fixtures,
fixtures_path: "./user_data".to_string(),
demo_mode: false,
catalog_cache_enabled: true,
catalog_cache_ttl_secs: 5,
environment: AppEnvironment::Development,
}
}
/// Creates a development configuration with sensible defaults
pub fn development() -> Self {
Self::new()
.environment(AppEnvironment::Development)
.app_url("http://localhost:9999".to_string())
.jwt_secret("dev_jwt_secret_key".to_string())
}
/// Creates a production configuration
pub fn production() -> Self {
Self::new()
.environment(AppEnvironment::Production)
.load_from_environment()
}
/// Creates a testing configuration
pub fn testing() -> Self {
Self::new()
.environment(AppEnvironment::Testing)
.app_url("http://localhost:8080".to_string())
.jwt_secret("test_jwt_secret_key".to_string())
}
/// Loads all configuration from environment variables
pub fn load_from_environment(mut self) -> Self {
// OAuth Configuration
self.gitea_client_id = env::var("GITEA_CLIENT_ID").ok().filter(|s| !s.is_empty());
self.gitea_client_secret = env::var("GITEA_CLIENT_SECRET").ok().filter(|s| !s.is_empty());
self.gitea_instance_url = env::var("GITEA_INSTANCE_URL").ok().filter(|s| !s.is_empty());
// App Configuration
if let Ok(app_url) = env::var("APP_URL") {
self.app_url = app_url;
}
if let Ok(jwt_secret) = env::var("JWT_SECRET") {
self.jwt_secret = jwt_secret;
}
self.secret_key = env::var("SECRET_KEY").ok().filter(|s| !s.is_empty());
self.app_config_path = env::var("APP_CONFIG").ok().filter(|s| !s.is_empty());
// Mock data gating (APP_ENABLE_MOCKS)
let enable_mocks_env = env::var("APP_ENABLE_MOCKS").ok();
if let Some(val) = enable_mocks_env.as_deref() {
let v = val.to_lowercase();
// Accept common truthy/falsey values
self.enable_mock_data = matches!(
v.as_str(),
"1" | "true" | "yes" | "on"
) || (!matches!(v.as_str(), "0" | "false" | "no" | "off") && v == "true");
}
// Catalog cache (APP_CATALOG_CACHE, APP_CATALOG_CACHE_TTL_SECS)
let catalog_cache_env = env::var("APP_CATALOG_CACHE").ok();
if let Some(val) = catalog_cache_env.as_deref() {
let v = val.to_lowercase();
self.catalog_cache_enabled = matches!(
v.as_str(),
"1" | "true" | "yes" | "on"
);
}
if let Ok(val) = env::var("APP_CATALOG_CACHE_TTL_SECS") {
if let Ok(parsed) = val.parse::<u64>() {
self.catalog_cache_ttl_secs = parsed;
}
}
// (no dev-specific cache flags; use APP_CATALOG_CACHE* across environments)
// Environment detection
if let Ok(env_var) = env::var("APP_ENV") {
self.environment = match env_var.to_lowercase().as_str() {
"production" | "prod" => AppEnvironment::Production,
"testing" | "test" => AppEnvironment::Testing,
_ => AppEnvironment::Development,
};
}
// Data source selection
if let Ok(ds) = env::var("APP_DATA_SOURCE") {
self.data_source = match ds.to_lowercase().as_str() {
"mock" => DataSource::Mock,
"live" => DataSource::Live,
_ => DataSource::Fixtures,
};
} else {
// Default by environment
self.data_source = match self.environment {
AppEnvironment::Production => DataSource::Live,
_ => DataSource::Fixtures,
};
}
// Fixtures path
if let Ok(path) = env::var("APP_FIXTURES_PATH") {
if !path.is_empty() {
self.fixtures_path = path;
}
}
// Demo mode
if let Ok(val) = env::var("APP_DEMO_MODE") {
let v = val.to_lowercase();
self.demo_mode = matches!(v.as_str(), "1" | "true" | "yes" | "on");
}
// If environment is production and APP_ENABLE_MOCKS not explicitly set,
// default to false to ensure clean production runtime
if self.environment == AppEnvironment::Production && enable_mocks_env.is_none() {
self.enable_mock_data = false;
}
// In production, disable catalog cache by default unless explicitly enabled
if self.environment == AppEnvironment::Production && catalog_cache_env.is_none() {
self.catalog_cache_enabled = false;
}
self
}
// Builder methods for fluent interface
pub fn gitea_client_id(mut self, client_id: String) -> Self {
self.gitea_client_id = Some(client_id);
self
}
pub fn gitea_client_secret(mut self, client_secret: String) -> Self {
self.gitea_client_secret = Some(client_secret);
self
}
pub fn gitea_instance_url(mut self, instance_url: String) -> Self {
self.gitea_instance_url = Some(instance_url);
self
}
pub fn app_url(mut self, url: String) -> Self {
self.app_url = url;
self
}
pub fn jwt_secret(mut self, secret: String) -> Self {
self.jwt_secret = secret;
self
}
pub fn secret_key(mut self, key: String) -> Self {
self.secret_key = Some(key);
self
}
pub fn app_config_path(mut self, path: String) -> Self {
self.app_config_path = Some(path);
self
}
pub fn environment(mut self, env: AppEnvironment) -> Self {
self.environment = env;
self
}
/// Builds the final configuration
pub fn build(self) -> AppConfiguration {
AppConfiguration {
gitea_client_id: self.gitea_client_id,
gitea_client_secret: self.gitea_client_secret,
gitea_instance_url: self.gitea_instance_url,
app_url: self.app_url,
jwt_secret: self.jwt_secret,
secret_key: self.secret_key,
app_config_path: self.app_config_path,
enable_mock_data: self.enable_mock_data,
data_source: self.data_source,
fixtures_path: self.fixtures_path,
demo_mode: self.demo_mode,
catalog_cache_enabled: self.catalog_cache_enabled,
catalog_cache_ttl_secs: self.catalog_cache_ttl_secs,
environment: self.environment,
}
}
}
impl AppConfiguration {
/// Convenience method to check if Gitea OAuth is enabled
pub fn is_gitea_enabled(&self) -> bool {
self.gitea_client_id.is_some() &&
self.gitea_client_secret.is_some() &&
self.gitea_instance_url.is_some()
}
/// Gets the Gitea client ID if available
pub fn gitea_client_id(&self) -> Option<&str> {
self.gitea_client_id.as_deref()
}
/// Gets the Gitea client secret if available
pub fn gitea_client_secret(&self) -> Option<&str> {
self.gitea_client_secret.as_deref()
}
/// Gets the Gitea instance URL if available
pub fn gitea_instance_url(&self) -> Option<&str> {
self.gitea_instance_url.as_deref()
}
/// Gets the app URL
pub fn app_url(&self) -> &str {
&self.app_url
}
/// Gets the JWT secret
pub fn jwt_secret(&self) -> &str {
&self.jwt_secret
}
/// Gets the secret key if available
pub fn secret_key(&self) -> Option<&str> {
self.secret_key.as_deref()
}
/// Gets the app config path if available
pub fn app_config_path(&self) -> Option<&str> {
self.app_config_path.as_deref()
}
/// Returns true if mock data/services are enabled
pub fn enable_mock_data(&self) -> bool {
self.enable_mock_data
}
/// Returns the configured data source
pub fn data_source(&self) -> &DataSource {
&self.data_source
}
/// True when using fixtures-backed data
pub fn is_fixtures(&self) -> bool {
matches!(self.data_source, DataSource::Fixtures)
}
/// True when using live backend
pub fn is_live(&self) -> bool {
matches!(self.data_source, DataSource::Live)
}
/// Path to fixtures directory
pub fn fixtures_path(&self) -> &str {
&self.fixtures_path
}
/// Demo mode flag
pub fn is_demo_mode(&self) -> bool {
self.demo_mode
}
/// Catalog cache enabled flag
pub fn is_catalog_cache_enabled(&self) -> bool {
self.catalog_cache_enabled
}
/// Catalog cache TTL (seconds)
pub fn catalog_cache_ttl_secs(&self) -> u64 {
self.catalog_cache_ttl_secs
}
/// Checks if running in development environment
pub fn is_development(&self) -> bool {
self.environment == AppEnvironment::Development
}
/// Checks if running in production environment
pub fn is_production(&self) -> bool {
self.environment == AppEnvironment::Production
}
/// Checks if running in testing environment
pub fn is_testing(&self) -> bool {
self.environment == AppEnvironment::Testing
}
}
impl Default for ConfigurationBuilder {
fn default() -> Self {
Self::new().load_from_environment()
}
}
/// Global configuration instance - lazy static for single initialization
use std::sync::OnceLock;
static GLOBAL_CONFIG: OnceLock<AppConfiguration> = OnceLock::new();
/// Gets the global application configuration
///
/// This function provides a singleton pattern for configuration access,
/// ensuring consistent configuration throughout the application lifecycle.
pub fn get_app_config() -> &'static AppConfiguration {
GLOBAL_CONFIG.get_or_init(|| {
ConfigurationBuilder::default().build()
})
}
/// Initializes the global configuration with a custom builder
///
/// This should be called once at application startup if custom configuration is needed.
/// If not called, the default environment-based configuration will be used.
pub fn init_app_config(config: AppConfiguration) -> Result<(), AppConfiguration> {
GLOBAL_CONFIG.set(config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{OnceLock, Mutex, MutexGuard};
use std::env;
// Serialize env-manipulating tests to avoid races
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> MutexGuard<'static, ()> {
ENV_MUTEX.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[test]
fn test_configuration_builder_defaults() {
let config = ConfigurationBuilder::new().build();
assert_eq!(config.app_url(), "http://localhost:9999");
assert_eq!(config.jwt_secret(), "your_jwt_secret_key");
assert!(!config.is_gitea_enabled());
}
#[test]
fn test_development_configuration() {
let config = ConfigurationBuilder::development().build();
assert!(config.is_development());
assert_eq!(config.app_url(), "http://localhost:9999");
}
#[test]
fn test_production_configuration() {
let config = ConfigurationBuilder::production().build();
assert!(config.is_production());
}
#[test]
fn test_gitea_enabled_check() {
let config = ConfigurationBuilder::new()
.gitea_client_id("test_id".to_string())
.gitea_client_secret("test_secret".to_string())
.gitea_instance_url("https://gitea.example.com".to_string())
.build();
assert!(config.is_gitea_enabled());
assert_eq!(config.gitea_client_id(), Some("test_id"));
}
#[test]
fn test_fluent_interface() {
let config = ConfigurationBuilder::new()
.app_url("https://example.com".to_string())
.jwt_secret("custom_secret".to_string())
.environment(AppEnvironment::Testing)
.build();
assert_eq!(config.app_url(), "https://example.com");
assert_eq!(config.jwt_secret(), "custom_secret");
assert!(config.is_testing());
}
#[test]
fn test_app_enable_mocks_truthy_values() {
let _g = env_lock();
// Ensure clean slate
env::remove_var("APP_ENV");
for val in ["1", "true", "yes", "on", "TRUE", "On"] {
env::set_var("APP_ENABLE_MOCKS", val);
let cfg = ConfigurationBuilder::default().build();
assert!(cfg.enable_mock_data(), "APP_ENABLE_MOCKS='{}' should enable mocks", val);
}
env::remove_var("APP_ENABLE_MOCKS");
}
#[test]
fn test_app_enable_mocks_falsey_values() {
let _g = env_lock();
env::remove_var("APP_ENV");
for val in ["0", "false", "no", "off", "FALSE", "Off"] {
env::set_var("APP_ENABLE_MOCKS", val);
let cfg = ConfigurationBuilder::default().build();
assert!(!cfg.enable_mock_data(), "APP_ENABLE_MOCKS='{}' should disable mocks", val);
}
env::remove_var("APP_ENABLE_MOCKS");
}
#[test]
fn test_production_default_disables_mocks_when_unset() {
let _g = env_lock();
env::set_var("APP_ENV", "production");
env::remove_var("APP_ENABLE_MOCKS");
let cfg = ConfigurationBuilder::default().build();
assert!(cfg.is_production());
assert!(!cfg.enable_mock_data(), "Production default should disable mocks when not explicitly set");
env::remove_var("APP_ENV");
}
#[test]
fn test_development_default_enables_mocks_when_unset() {
let _g = env_lock();
env::set_var("APP_ENV", "development");
env::remove_var("APP_ENABLE_MOCKS");
let cfg = ConfigurationBuilder::default().build();
assert!(cfg.is_development());
assert!(cfg.enable_mock_data(), "Development default should enable mocks when not explicitly set");
env::remove_var("APP_ENV");
}
}

72
src/config/mod.rs Normal file
View File

@@ -0,0 +1,72 @@
use config::{Config, ConfigError, File};
use serde::Deserialize;
use std::env;
// Export OAuth module
pub mod oauth;
// Export configuration builder
pub mod builder;
pub use builder::get_app_config;
/// Application configuration
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
/// Server configuration
pub server: ServerConfig,
/// Template configuration
pub templates: TemplateConfig,
}
/// Server configuration
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
/// Host address to bind to
pub host: String,
/// Port to listen on
pub port: u16,
/// Workers count
pub workers: Option<u32>,
}
/// Template configuration
#[derive(Debug, Deserialize, Clone)]
pub struct TemplateConfig {
/// Directory containing templates
pub dir: String,
}
impl AppConfig {
/// Loads configuration from files and environment variables
pub fn new() -> Result<Self, ConfigError> {
// Set default values
let mut config_builder = Config::builder()
.set_default("server.host", "127.0.0.1")?
.set_default("server.port", 9999)?
.set_default("server.workers", None::<u32>)?
.set_default("templates.dir", "./src/views")?;
// Load from config file if it exists
if let Ok(config_path) = env::var("APP_CONFIG") {
config_builder = config_builder.add_source(File::with_name(&config_path));
} else {
// Try to load from default locations
config_builder = config_builder
.add_source(File::with_name("config/default").required(false))
.add_source(File::with_name("config/local").required(false));
}
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
config_builder =
config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
// Build and deserialize the config
let config = config_builder.build()?;
config.try_deserialize()
}
}
/// Returns the application configuration
pub fn get_config() -> AppConfig {
AppConfig::new().expect("Failed to load configuration")
}

65
src/config/oauth.rs Normal file
View File

@@ -0,0 +1,65 @@
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use serde::{Deserialize, Serialize};
use crate::config::get_app_config;
/// Gitea OAuth configuration
#[derive(Clone, Debug)]
pub struct GiteaOAuthConfig {
/// OAuth client
pub client: BasicClient,
/// Gitea instance URL
pub instance_url: String,
}
impl GiteaOAuthConfig {
/// Creates a new Gitea OAuth configuration
pub fn new() -> Self {
// Get configuration from centralized ConfigurationBuilder
let config = get_app_config();
let client_id = config.gitea_client_id()
.expect("Missing GITEA_CLIENT_ID environment variable").to_string();
let client_secret = config.gitea_client_secret()
.expect("Missing GITEA_CLIENT_SECRET environment variable").to_string();
let instance_url = config.gitea_instance_url()
.expect("Missing GITEA_INSTANCE_URL environment variable").to_string();
// Create OAuth client
let auth_url = format!("{}/login/oauth/authorize", instance_url);
let token_url = format!("{}/login/oauth/access_token", instance_url);
let client = BasicClient::new(
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
AuthUrl::new(auth_url).unwrap(),
Some(TokenUrl::new(token_url).unwrap()),
)
.set_redirect_uri(
RedirectUrl::new(format!(
"{}/auth/gitea/callback",
config.app_url()
))
.unwrap(),
);
Self {
client,
instance_url,
}
}
}
/// Gitea user information structure
#[derive(Debug, Deserialize, Serialize)]
pub struct GiteaUser {
/// User ID
pub id: i64,
/// Username
pub login: String,
/// Full name
pub full_name: String,
/// Email address
pub email: String,
/// Avatar URL
pub avatar_url: String,
}

404
src/controllers/auth.rs Normal file
View File

@@ -0,0 +1,404 @@
use crate::models::user::{LoginCredentials, RegistrationData, User, UserRole};
use crate::services::user_persistence::UserPersistence;
use crate::utils::{render_template, ResponseBuilder};
use crate::config::get_app_config;
use actix_session::Session;
use actix_web::{cookie::Cookie, web, Responder, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap};
use tera::Tera;
use bcrypt::{hash, verify, DEFAULT_COST};
// JWT Claims structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // Subject (email)
pub exp: usize, // Expiration time
pub iat: usize, // Issued at
pub role: String, // User role
}
// JWT Secret key - now using ConfigurationBuilder
fn get_jwt_secret() -> &'static str {
get_app_config().jwt_secret()
}
/// Controller for handling authentication-related routes
pub struct AuthController;
impl AuthController {
/// Generate a JWT token for a user
pub fn generate_token(
email: &str,
role: &UserRole,
) -> Result<String, jsonwebtoken::errors::Error> {
let role_str = match role {
UserRole::Admin => "admin",
UserRole::User => "user",
};
let expiration = Utc::now()
.checked_add_signed(Duration::hours(24))
.expect("valid timestamp")
.timestamp() as usize;
let claims = Claims {
sub: email.to_owned(),
exp: expiration,
iat: Utc::now().timestamp() as usize,
role: role_str.to_string(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(get_jwt_secret().as_bytes()),
)
}
/// Authenticate user with support for persistent password updates
fn authenticate_user(email: &str, password: &str, session: &Session) -> Option<User> {
// Try to load user data from persistent storage
if let Some(user_data) = UserPersistence::load_user_data(email) {
// Check if user account is deleted
if UserPersistence::is_user_deleted(email) {
return None;
}
// If user has a custom password hash, verify against it
if let Some(stored_hash) = &user_data.password_hash {
match verify(password, stored_hash) {
Ok(true) => {
return Self::create_user_from_persistent_data(email, &user_data);
},
Ok(false) => {
return None;
},
Err(e) => {
return None;
}
}
}
// If no custom password is set, authentication fails.
return None;
}
// If user doesn't exist in persistent storage, authentication fails
None
}
/// Create user object from persistent data
fn create_user_from_persistent_data(email: &str, user_data: &crate::services::user_persistence::UserPersistentData) -> Option<User> {
// Generate a simple ID based on email hash for consistency
let user_id = email.chars().map(|c| c as u32).sum::<u32>() % 10000;
Some(User::builder()
.id(user_id as i32)
.name(user_data.name.clone().unwrap_or_else(|| email.to_string()))
.email(email.to_owned())
.role(UserRole::User)
.build()
.unwrap())
}
// Note: The following functions were removed as they were not being used:
// - validate_token
// - extract_token_from_session
// - extract_token_from_cookie
// They can be re-implemented if needed in the future.
/// Renders the login page
pub async fn login_page(
tmpl: web::Data<Tera>,
session: Session,
query: web::Query<HashMap<String, String>>
) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
ctx.insert("active_page", "login");
// Handle error messages
if let Some(error) = query.get("error") {
let error_message = match error.as_str() {
"invalid_credentials" => "Invalid email or password. Please check your credentials and try again.",
"checkout_requires_auth" => "Please log in to complete your purchase. Your cart items are saved and waiting for you!",
_ => "An error occurred. Please try again.",
};
ctx.insert("error_message", error_message);
ctx.insert("error_type", error);
}
// Handle custom messages
if let Some(message) = query.get("message") {
ctx.insert("custom_message", message);
}
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
}
Err(_e) => {
}
}
}
render_template(&tmpl, "auth/login.html", &ctx)
}
/// Handles user login
pub async fn login(
form: web::Form<LoginCredentials>,
session: Session,
_tmpl: web::Data<Tera>,
) -> Result<impl Responder> {
// Check for mock users with simple credentials and comprehensive data
let user = match Self::authenticate_user(&form.email, &form.password, &session) {
Some(user) => user,
None => {
// Invalid credentials, redirect back to login with error
return ResponseBuilder::redirect("/login?error=invalid_credentials").build();
}
};
// Generate JWT token
let token = Self::generate_token(&user.email, &user.role)
.map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?;
// Store minimal user data in session (industry best practice)
// Only store essential user info, not complex mock data
let mut session_user_builder = User::builder()
.name(user.name.clone())
.email(user.email.clone())
.role(user.role.clone());
if let Some(id) = user.id {
session_user_builder = session_user_builder.id(id);
}
let session_user = session_user_builder.build().unwrap();
let user_json = serde_json::to_string(&session_user).unwrap();
session.insert("user", &user_json)?;
session.insert("auth_token", &token)?;
// Store user email for mock data lookup
session.insert("user_email", &user.email)?;
// Store user_id for cart operations
session.insert("user_id", &user.email)?; // Using email as user_id for now
// Transfer guest cart items to user cart if any exist
match crate::services::order::OrderService::builder().build() {
Ok(order_service) => {
match order_service.transfer_guest_cart_to_user(&user.email, &session) {
Ok(_items_transferred) => {
// Cart transfer successful
}
Err(_e) => {
// Don't fail login if cart transfer fails
}
}
}
Err(_e) => {
// Failed to create OrderService for cart transfer
}
}
// Create a cookie with the JWT token
let cookie = Cookie::build("auth_token", token)
.path("/")
.http_only(true)
.secure(false) // Set to true in production with HTTPS
.max_age(actix_web::cookie::time::Duration::hours(24))
.finish();
// Redirect to the dashboard page with JWT token in cookie
ResponseBuilder::redirect("/dashboard")
.cookie(cookie)
.build()
}
/// Renders the registration page
pub async fn register_page(
tmpl: web::Data<Tera>,
session: Session,
query: web::Query<HashMap<String, String>>
) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "register");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Handle error messages
if let Some(error) = query.get("error") {
let error_message = match error.as_str() {
"password_mismatch" => "Passwords do not match. Please ensure both password fields are identical.",
"password_too_short" => "Password must be at least 6 characters long. Please choose a stronger password.",
"user_exists" => "An account with this email address already exists. Please use a different email or try logging in.",
"password_hash_failed" => "Failed to process password. Please try again.",
"save_failed" => "Failed to create account. Please try again later.",
_ => "An error occurred during registration. Please try again.",
};
ctx.insert("error_message", error_message);
ctx.insert("error_type", error);
}
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
}
Err(_e) => {
}
}
}
render_template(&tmpl, "auth/register.html", &ctx)
}
/// Handles user registration
pub async fn register(
form: web::Form<RegistrationData>,
session: Session,
_tmpl: web::Data<Tera>,
) -> Result<impl Responder> {
// Basic validation
if form.password != form.password_confirmation {
return ResponseBuilder::redirect("/register?error=password_mismatch").build();
}
if form.password.len() < 6 {
return ResponseBuilder::redirect("/register?error=password_too_short").build();
}
// Check if user already exists
if UserPersistence::load_user_data(&form.email).is_some() {
return ResponseBuilder::redirect("/register?error=user_exists").build();
}
// Create persistent user data with complete structure
let mut user_data = UserPersistence::create_default_user_data(&form.email);
// Populate user data from form
user_data.name = Some(form.name.clone());
// Hash the password and save it
if !form.password.is_empty() {
match hash(&form.password, DEFAULT_COST) {
Ok(password_hash) => {
user_data.password_hash = Some(password_hash);
},
Err(_e) => {
return ResponseBuilder::redirect("/register?error=password_hash_failed").build();
}
}
}
// Save the complete user data to persistent storage
if let Err(_e) = UserPersistence::save_user_data(&user_data) {
return ResponseBuilder::redirect("/register?error=save_failed").build();
}
// Create a user object for the session directly from the submitted data
let user = User::builder()
.id((form.email.chars().map(|c| c as u32).sum::<u32>() % 10000) as i32)
.name(form.name.clone())
.email(form.email.clone())
.role(UserRole::User)
.build()
.unwrap();
// Generate JWT token
let token = Self::generate_token(&user.email, &user.role)
.map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?;
// Store minimal user data in session (industry best practice)
// Only store essential user info, not complex mock data
let mut session_user_builder = User::builder()
.name(user.name.clone())
.email(user.email.clone())
.role(user.role.clone());
if let Some(id) = user.id {
session_user_builder = session_user_builder.id(id);
}
let session_user = session_user_builder.build().unwrap();
let user_json = serde_json::to_string(&session_user).unwrap();
session.insert("user", &user_json)?;
session.insert("auth_token", &token)?;
// Store user email for session management
session.insert("user_email", &user.email)?;
// Create a cookie with the JWT token
let cookie = Cookie::build("auth_token", token)
.path("/")
.http_only(true)
.secure(false) // Set to true in production with HTTPS
.max_age(actix_web::cookie::time::Duration::hours(24))
.finish();
// Redirect to the dashboard page with JWT token in cookie
ResponseBuilder::redirect("/dashboard")
.cookie(cookie)
.build()
}
/// Handles user logout
pub async fn logout(session: Session) -> Result<impl Responder> {
// Clear the session
session.purge();
// Create an expired cookie to remove the JWT token
let cookie = Cookie::build("auth_token", "")
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::seconds(0))
.finish();
// Redirect to the home page and clear the auth token cookie
ResponseBuilder::redirect("/")
.cookie(cookie)
.build()
}
/// Check authentication status for API calls
pub async fn auth_status(session: Session) -> Result<impl Responder> {
let user_data = session.get::<String>("user");
let auth_token = session.get::<String>("auth_token");
let user_email = session.get::<String>("user_email");
match (user_data, auth_token, user_email) {
(Ok(Some(_)), Ok(Some(_)), Ok(Some(_))) => {
ResponseBuilder::success()
.data(serde_json::json!({"authenticated": true}))
.build()
},
_ => {
ResponseBuilder::unauthorized()
.data(serde_json::json!({"authenticated": false}))
.build()
}
}
}
}

288
src/controllers/currency.rs Normal file
View File

@@ -0,0 +1,288 @@
use actix_web::{web, Result, Responder};
use crate::services::currency::CurrencyService;
use crate::utils::response_builder::ResponseBuilder;
use actix_session::Session;
use serde::{Deserialize, Serialize};
/// Controller for handling currency-related routes
pub struct CurrencyController;
#[derive(Debug, Deserialize)]
pub struct SetCurrencyRequest {
pub currency: String,
}
#[derive(Debug, Deserialize)]
pub struct ConvertPriceRequest {
pub amount: String,
pub from_currency: String,
pub to_currency: String,
}
#[derive(Debug, Serialize)]
pub struct ConvertPriceResponse {
pub original_amount: String,
pub original_currency: String,
pub converted_amount: String,
pub converted_currency: String,
pub exchange_rate: String,
pub formatted_price: String,
}
#[derive(Debug, Serialize)]
pub struct CurrencyInfo {
pub code: String,
pub name: String,
pub symbol: String,
pub currency_type: String,
pub decimal_places: u8,
pub is_base: bool,
pub is_active: bool,
}
#[derive(Debug, Serialize)]
pub struct ExchangeRatesResponse {
pub base_currency: String,
pub rates: std::collections::HashMap<String, String>,
pub last_updated: String,
}
impl CurrencyController {
/// Get all supported currencies
pub async fn get_supported_currencies() -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let currencies: Vec<CurrencyInfo> = currency_service.get_supported_currencies()
.iter()
.filter(|c| c.is_active)
.map(|currency| CurrencyInfo {
code: currency.code.clone(),
name: currency.name.clone(),
symbol: currency.symbol.clone(),
currency_type: match currency.currency_type {
crate::models::currency::CurrencyType::Fiat => "fiat".to_string(),
crate::models::currency::CurrencyType::Cryptocurrency => "crypto".to_string(),
crate::models::currency::CurrencyType::Token => "token".to_string(),
crate::models::currency::CurrencyType::Points => "points".to_string(),
crate::models::currency::CurrencyType::Custom(ref name) => name.clone(),
},
decimal_places: currency.decimal_places,
is_base: currency.is_base_currency,
is_active: currency.is_active,
})
.collect();
ResponseBuilder::ok()
.json(currencies)
.build()
}
/// Set user's preferred currency
pub async fn set_user_currency_preference(
session: Session,
request: web::Json<SetCurrencyRequest>,
) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
match currency_service.set_user_preferred_currency(&session, request.currency.clone()) {
Ok(()) => {
ResponseBuilder::ok()
.json(serde_json::json!({
"success": true,
"message": "Currency preference updated successfully",
"currency": request.currency
}))
.build()
},
Err(error) => {
ResponseBuilder::bad_request()
.json(serde_json::json!({
"success": false,
"error": error
}))
.build()
}
}
}
/// Get current exchange rates
pub async fn get_exchange_rates() -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let base_currency = currency_service.get_base_currency();
let rates = currency_service.get_all_exchange_rates();
let rates_str: std::collections::HashMap<String, String> = rates
.into_iter()
.map(|(currency, rate)| (currency, rate.to_string()))
.collect();
let response = ExchangeRatesResponse {
base_currency: base_currency.code.clone(),
rates: rates_str,
last_updated: chrono::Utc::now().to_rfc3339(),
};
ResponseBuilder::ok()
.json(response)
.build()
}
/// Convert price between currencies
pub async fn convert_price(
request: web::Json<ConvertPriceRequest>,
) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
// Parse the amount
let amount = match request.amount.parse::<rust_decimal::Decimal>() {
Ok(amount) => amount,
Err(_) => {
return ResponseBuilder::bad_request()
.json(serde_json::json!({
"success": false,
"error": "Invalid amount format"
}))
.build();
}
};
// Convert the amount
match currency_service.convert_amount(amount, &request.from_currency, &request.to_currency) {
Ok(converted_amount) => {
// Get exchange rate
let rate = if request.from_currency == request.to_currency {
rust_decimal::Decimal::from(1)
} else {
converted_amount / amount
};
// Format the converted price
let formatted_price = currency_service.format_price(
converted_amount,
&request.to_currency,
).unwrap_or_else(|_| format!("{} {}", converted_amount, request.to_currency));
let response = ConvertPriceResponse {
original_amount: amount.to_string(),
original_currency: request.from_currency.clone(),
converted_amount: converted_amount.to_string(),
converted_currency: request.to_currency.clone(),
exchange_rate: rate.to_string(),
formatted_price,
};
ResponseBuilder::ok()
.json(response)
.build()
},
Err(error) => {
ResponseBuilder::bad_request()
.json(serde_json::json!({
"success": false,
"error": error
}))
.build()
}
}
}
/// Get user's current currency preference
pub async fn get_user_currency_preference(session: Session) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let preferred_currency = currency_service.get_user_preferred_currency(&session);
// Get currency details
if let Some(currency) = currency_service.get_currency(&preferred_currency) {
let currency_info = CurrencyInfo {
code: currency.code.clone(),
name: currency.name.clone(),
symbol: currency.symbol.clone(),
currency_type: match currency.currency_type {
crate::models::currency::CurrencyType::Fiat => "fiat".to_string(),
crate::models::currency::CurrencyType::Cryptocurrency => "crypto".to_string(),
crate::models::currency::CurrencyType::Token => "token".to_string(),
crate::models::currency::CurrencyType::Points => "points".to_string(),
crate::models::currency::CurrencyType::Custom(ref name) => name.clone(),
},
decimal_places: currency.decimal_places,
is_base: currency.is_base_currency,
is_active: currency.is_active,
};
ResponseBuilder::ok()
.json(currency_info)
.build()
} else {
ResponseBuilder::internal_error()
.json(serde_json::json!({
"success": false,
"error": "Currency not found"
}))
.build()
}
}
/// Get currency statistics (for admin/debug purposes)
pub async fn get_currency_statistics() -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let stats = currency_service.get_currency_stats();
ResponseBuilder::ok()
.json(stats)
.build()
}
/// Update exchange rates manually (for admin purposes)
pub async fn update_exchange_rates() -> Result<impl Responder> {
let mut currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
currency_service.update_exchange_rates();
ResponseBuilder::ok()
.json(serde_json::json!({
"success": true,
"message": "Exchange rates updated successfully",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
.build()
}
/// Get currency conversion widget data
pub async fn get_currency_widget_data(session: Session) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let user_currency = currency_service.get_user_preferred_currency(&session);
let supported_currencies = currency_service.get_currency_display_info();
let base_currency = currency_service.get_base_currency();
let widget_data = serde_json::json!({
"user_currency": user_currency,
"base_currency": {
"code": base_currency.code,
"name": base_currency.name,
"symbol": base_currency.symbol
},
"supported_currencies": supported_currencies,
"should_update_rates": currency_service.should_update_rates()
});
ResponseBuilder::ok()
.json(widget_data)
.build()
}
}

7799
src/controllers/dashboard.rs Normal file

File diff suppressed because it is too large Load Diff

73
src/controllers/debug.rs Normal file
View File

@@ -0,0 +1,73 @@
use actix_web::{HttpRequest, Responder, Result};
use crate::utils::response_builder::ResponseBuilder;
use actix_session::Session;
use serde_json::json;
/// Controller for debugging
pub struct DebugController;
impl DebugController {
/// Display debug information
pub async fn debug_info(req: HttpRequest, session: Session) -> Result<impl Responder> {
// Collect cookies
let mut cookies = Vec::new();
if let Ok(cookie_iter) = req.cookies() {
for cookie in cookie_iter.iter() {
cookies.push(json!({
"name": cookie.name(),
"value": cookie.value(),
"http_only": cookie.http_only(),
"secure": cookie.secure(),
"same_site": format!("{:?}", cookie.same_site()),
"path": cookie.path(),
}));
}
}
// Collect session data
let mut session_data = Vec::new();
// Get session keys
let mut session_keys = Vec::new();
if let Ok(Some(csrf_token)) = session.get::<String>("oauth_csrf_token") {
session_data.push(json!({
"key": "oauth_csrf_token",
"value": csrf_token,
}));
session_keys.push("oauth_csrf_token".to_string());
}
if let Ok(Some(user)) = session.get::<String>("user") {
session_data.push(json!({
"key": "user",
"value": user,
}));
session_keys.push("user".to_string());
}
if let Ok(Some(auth_token)) = session.get::<String>("auth_token") {
session_data.push(json!({
"key": "auth_token",
"value": auth_token,
}));
session_keys.push("auth_token".to_string());
}
// Add session keys to response
session_data.push(json!({
"key": "_session_keys",
"value": session_keys.join(", "),
}));
// Create response
let response = json!({
"cookies": cookies,
"session": session_data,
"csrf_token_session": session.get::<String>("oauth_csrf_token").unwrap_or(None),
"csrf_token_cookie": req.cookie("oauth_csrf_token").map(|c| c.value().to_string()),
"csrf_token_debug_cookie": req.cookie("oauth_csrf_token_debug").map(|c| c.value().to_string()),
});
ResponseBuilder::ok().json(response).build()
}
}

248
src/controllers/docs.rs Normal file
View File

@@ -0,0 +1,248 @@
use actix_web::{web, Result, Responder};
use tera::Tera;
use crate::utils::render_template;
use crate::config::get_app_config;
use actix_session::Session;
/// Controller for handling documentation-related routes
pub struct DocsController;
impl DocsController {
/// Renders the documentation index/overview page
pub async fn index(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "overview");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
},
Err(e) => {
}
}
}
render_template(&tmpl, "docs/index.html", &ctx)
}
/// Renders the getting started documentation page
pub async fn getting_started(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "getting_started");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/getting_started.html", &ctx)
}
/// Renders the 3Nodes documentation page
pub async fn three_nodes(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "3nodes");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/3nodes.html", &ctx)
}
/// Renders the compute resources documentation page
pub async fn compute(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "compute");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/compute.html", &ctx)
}
/// Renders the gateways documentation page
pub async fn gateways(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "gateways");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/gateways.html", &ctx)
}
/// Renders the applications documentation page
pub async fn applications(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "applications");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/applications.html", &ctx)
}
/// Renders the services documentation page
pub async fn services(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "services");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/services.html", &ctx)
}
/// USD Credits documentation page
pub async fn credits(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "credits");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/tfp.html", &ctx)
}
/// Renders the slices documentation page
pub async fn slices(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "slices");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/slices.html", &ctx)
}
/// Renders the certification documentation page
pub async fn certification(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "certification");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/certification.html", &ctx)
}
/// Renders the API documentation page
pub async fn api(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("docs")
.build();
ctx.insert("active_section", "api");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "docs/api.html", &ctx)
}
}

View File

@@ -0,0 +1,216 @@
use actix_web::{web, HttpRequest, Responder, Result, cookie::Cookie};
use actix_session::Session;
use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse};
use reqwest::Client;
use crate::config::oauth::GiteaOAuthConfig;
use crate::utils::response_builder::ResponseBuilder;
use crate::models::user::{User, UserRole};
use crate::controllers::auth::AuthController;
/// Controller for handling Gitea authentication
pub struct GiteaAuthController;
impl GiteaAuthController {
/// Initiate the OAuth flow
pub async fn login(
oauth_config: web::Data<GiteaOAuthConfig>,
session: Session,
) -> Result<impl Responder> {
// Generate the authorization URL
let (auth_url, csrf_token) = oauth_config
.client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("read:user".to_string()))
.add_scope(Scope::new("user:email".to_string()))
.url();
// Store the CSRF token in the session
let csrf_secret = csrf_token.secret().to_string();
session.insert("oauth_csrf_token", &csrf_secret)?;
// Log all session data for debugging
// Check if the CSRF token was actually stored
if let Ok(Some(token)) = session.get::<String>("oauth_csrf_token") {
} else {
}
// Check for other session keys
if let Ok(Some(_)) = session.get::<String>("user") {
}
if let Ok(Some(_)) = session.get::<String>("auth_token") {
}
// Also store it in a cookie as a backup
let csrf_cookie = Cookie::build("oauth_csrf_token", csrf_secret.clone())
.path("/")
.http_only(true)
.secure(false) // Set to true in production with HTTPS
.max_age(actix_web::cookie::time::Duration::minutes(30))
.finish();
// Store in a non-http-only cookie as well for debugging
let csrf_cookie_debug = Cookie::build("oauth_csrf_token_debug", csrf_secret)
.path("/")
.http_only(false) // Accessible from JavaScript for debugging
.secure(false)
.max_age(actix_web::cookie::time::Duration::minutes(30))
.finish();
// Redirect to the authorization URL
ResponseBuilder::redirect(auth_url.to_string())
.cookie(csrf_cookie)
.cookie(csrf_cookie_debug)
.build()
}
/// Handle the OAuth callback
pub async fn callback(
oauth_config: web::Data<GiteaOAuthConfig>,
session: Session,
query: web::Query<CallbackQuery>,
req: HttpRequest,
) -> Result<impl Responder> {
// Log all cookies for debugging
if let Ok(cookie_iter) = req.cookies() {
for cookie in cookie_iter.iter() {
}
} else {
}
// Log all session data for debugging
// Check for CSRF token
if let Ok(Some(token)) = session.get::<String>("oauth_csrf_token") {
} else {
}
// Check for other session keys
if let Ok(Some(_)) = session.get::<String>("user") {
}
if let Ok(Some(_)) = session.get::<String>("auth_token") {
}
// Try to get the CSRF token from the session
let csrf_token_result = session.get::<String>("oauth_csrf_token")?;
// If not in session, try to get it from the cookie
let csrf_token = match csrf_token_result {
Some(token) => {
token
},
None => {
// Try to get from cookie
match req.cookie("oauth_csrf_token") {
Some(cookie) => {
let token = cookie.value().to_string();
token
},
None => {
// For debugging, let's accept the state parameter directly
query.state.clone()
// Uncomment this for production use
//
// return Err(actix_web::error::ErrorBadRequest("Missing CSRF token"));
}
}
}
};
if csrf_token != query.state {
// In production, uncomment the following:
//
// return Err(actix_web::error::ErrorBadRequest("Invalid CSRF token"));
}
// Exchange the authorization code for an access token
let token = oauth_config
.client
.exchange_code(AuthorizationCode::new(query.code.clone()))
.request_async(oauth2::reqwest::async_http_client)
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Token exchange error: {}", e)))?;
// Get the user information from Gitea
let client = Client::new();
let user_info_url = format!("{}/api/v1/user", oauth_config.instance_url);
let access_token_secret = token.access_token().secret();
let response = client
.get(&user_info_url)
.bearer_auth(access_token_secret)
.send()
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("API request error: {}", e)))?;
let response_body = response.text().await.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Failed to get response body: {}", e)))?;
let gitea_user: crate::config::oauth::GiteaUser = serde_json::from_str(&response_body)
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("JSON parsing error: {}", e)))?;
// Create or update the user in your system
let mut user_builder = User::builder()
.id(gitea_user.id as i32)
.name(gitea_user.full_name.clone())
.email(gitea_user.email.clone())
.role(UserRole::User);
let user = user_builder
.build()
.unwrap();
// Generate JWT token
let token = AuthController::generate_token(&user.email, &user.role)
.map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?;
// Store user data in session
let user_json = serde_json::to_string(&user).unwrap();
session.insert("user", &user_json)?;
session.insert("auth_token", &token)?;
// Store user email for mock data lookup
session.insert("user_email", &user.email)?;
// Store user_id for cart operations
session.insert("user_id", &user.email)?; // Using email as user_id for now
// Transfer guest cart items to user cart if any exist
if let Ok(order_service) = crate::services::order::OrderService::builder().build() {
match order_service.transfer_guest_cart_to_user(&user.email, &session) {
Ok(items_transferred) => {
if items_transferred > 0 {
}
}
Err(e) => {
// Don't fail login if cart transfer fails, just log the error
}
}
} else {
}
// Create a cookie with the JWT token
let cookie = Cookie::build("auth_token", token)
.path("/")
.http_only(true)
.secure(false) // Set to true in production with HTTPS
.max_age(actix_web::cookie::time::Duration::hours(24))
.finish();
// Redirect to the home page with JWT token in cookie
ResponseBuilder::redirect("/")
.cookie(cookie)
.build()
}
}
/// Query parameters for the OAuth callback
#[derive(serde::Deserialize)]
pub struct CallbackQuery {
pub code: String,
pub state: String,
}

89
src/controllers/home.rs Normal file
View File

@@ -0,0 +1,89 @@
use actix_web::{web, Result, Responder};
use tera::Tera;
use crate::utils::render_template;
use actix_session::Session;
use crate::config::get_app_config;
/// Controller for handling home-related routes
pub struct HomeController;
impl HomeController {
/// Renders the home page
pub async fn index(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("home")
.build();
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
},
Err(e) => {
}
}
}
render_template(&tmpl, "home/index.html", &ctx)
}
/// Renders the about page
pub async fn about(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
ctx.insert("active_page", "about");
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
},
Err(e) => {
}
}
}
render_template(&tmpl, "home/about.html", &ctx)
}
/// Renders the contact page
pub async fn contact(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
ctx.insert("active_page", "contact");
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
ctx.insert("user", &user);
},
Err(e) => {
}
}
}
render_template(&tmpl, "home/contact.html", &ctx)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
use actix_web::{web, HttpResponse, Result};
use actix_session::Session;
use serde_json::json;
use chrono::Utc;
use crate::models::messaging::*;
use crate::services::user_persistence::UserPersistence;
use crate::utils::response_builder::ResponseBuilder;
pub struct MessagingController;
impl MessagingController {
/// Get all message threads for the current user
pub async fn get_threads(session: Session) -> Result<HttpResponse> {
let user_email = match session.get::<String>("user_email") {
Ok(Some(email)) => email,
_ => return ResponseBuilder::unauthorized().build(),
};
match Self::load_user_threads(&user_email).await {
Ok(response) => ResponseBuilder::success().data(response).build(),
Err(e) => {
log::error!("Error loading threads for {}: {}", user_email, e);
ResponseBuilder::error().message("Failed to load threads").build()
}
}
}
/// Create a new message thread
pub async fn create_thread(
session: Session,
req_data: web::Json<CreateThreadRequest>,
) -> Result<HttpResponse> {
let user_email = match session.get::<String>("user_email") {
Ok(Some(email)) => email,
_ => return Ok(HttpResponse::Unauthorized().json(json!({"error": "Not authenticated"}))),
};
log::info!("📨 Creating thread request: user={}, recipient={}, context_type={}, context_id={:?}, subject={}",
user_email, req_data.recipient_email, req_data.context_type, req_data.context_id, req_data.subject);
// Validate request data
if req_data.recipient_email.is_empty() {
log::warn!("❌ Empty recipient_email in create thread request");
return Ok(HttpResponse::BadRequest().json(json!({"error": "Recipient email is required"})));
}
if req_data.context_type.is_empty() {
log::warn!("❌ Empty context_type in create thread request");
return Ok(HttpResponse::BadRequest().json(json!({"error": "Context type is required"})));
}
if req_data.subject.is_empty() {
log::warn!("❌ Empty subject in create thread request");
return Ok(HttpResponse::BadRequest().json(json!({"error": "Subject is required"})));
}
// Check if thread already exists
if let Ok(existing_thread) = Self::find_existing_thread(
&user_email,
&req_data.recipient_email,
&req_data.context_type,
&req_data.context_id,
).await {
return Ok(HttpResponse::Ok().json(json!({"thread": existing_thread})));
}
// Create new thread
let thread = MessageThread::new(
user_email.clone(),
req_data.recipient_email.clone(),
req_data.context_type.clone(),
req_data.context_id.clone(),
req_data.subject.clone(),
);
match Self::save_thread(&thread).await {
Ok(_) => Ok(HttpResponse::Ok().json(json!({"thread": thread}))),
Err(e) => {
log::error!("Error creating thread: {}", e);
Ok(HttpResponse::InternalServerError().json(json!({"error": "Failed to create thread"})))
}
}
}
/// Get messages for a specific thread (alias for route compatibility)
pub async fn get_messages(
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse> {
Self::get_thread_messages(session, path).await
}
/// Get messages for a specific thread
pub async fn get_thread_messages(
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse> {
let user_email = match session.get::<String>("user_email") {
Ok(Some(email)) => email,
_ => return Ok(HttpResponse::Unauthorized().json(json!({"error": "Not authenticated"}))),
};
let thread_id = path.into_inner();
// Verify user has access to this thread
if !Self::user_has_thread_access(&user_email, &thread_id).await {
return Ok(HttpResponse::Forbidden().json(json!({"error": "Access denied"})));
}
match Self::load_thread_messages(&thread_id).await {
Ok(messages) => Ok(HttpResponse::Ok().json(MessagesResponse { messages })),
Err(e) => {
log::error!("Error loading messages for thread {}: {}", thread_id, e);
Ok(HttpResponse::InternalServerError().json(json!({"error": "Failed to load messages"})))
}
}
}
/// Send a message in a thread (with thread_id in path)
pub async fn send_message_with_path(
session: Session,
path: web::Path<String>,
req_data: web::Json<SendMessageRequest>,
) -> Result<HttpResponse> {
let thread_id = path.into_inner();
let mut request = req_data.into_inner();
request.thread_id = thread_id;
Self::send_message_impl(session, request).await
}
/// Send a message in a thread (with thread_id in body)
pub async fn send_message(
session: Session,
req_data: web::Json<SendMessageRequest>,
) -> Result<HttpResponse> {
Self::send_message_impl(session, req_data.into_inner()).await
}
/// Internal implementation for sending messages
async fn send_message_impl(
session: Session,
req_data: SendMessageRequest,
) -> Result<HttpResponse> {
let user_email = match session.get::<String>("user_email") {
Ok(Some(email)) => email,
_ => return Ok(HttpResponse::Unauthorized().json(json!({"error": "Not authenticated"}))),
};
// Verify user has access to this thread
if !Self::user_has_thread_access(&user_email, &req_data.thread_id).await {
return Ok(HttpResponse::Forbidden().json(json!({"error": "Access denied"})));
}
// Get thread to find recipient
let thread = match Self::load_thread(&req_data.thread_id).await {
Ok(thread) => thread,
Err(_) => return Ok(HttpResponse::NotFound().json(json!({"error": "Thread not found"}))),
};
let recipient_email = thread.get_recipient_email(&user_email).to_string();
let message_type = req_data.message_type.clone().unwrap_or_else(|| "text".to_string());
let message = Message::new(
req_data.thread_id.clone(),
user_email,
recipient_email.clone(),
req_data.content.clone(),
message_type,
);
match Self::save_message(&message).await {
Ok(_) => {
// Update thread last message time and unread count
Self::update_thread_on_message(&req_data.thread_id, &recipient_email).await.ok();
Ok(HttpResponse::Ok().json(json!({"message": message})))
}
Err(e) => {
log::error!("Error sending message: {}", e);
Ok(HttpResponse::InternalServerError().json(json!({"error": "Failed to send message"})))
}
}
}
/// Mark a thread as read
pub async fn mark_thread_read(
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse> {
let user_email = match session.get::<String>("user_email") {
Ok(Some(email)) => email,
_ => return Ok(HttpResponse::Unauthorized().json(json!({"error": "Not authenticated"}))),
};
let thread_id = path.into_inner();
// Verify user has access to this thread
if !Self::user_has_thread_access(&user_email, &thread_id).await {
return Ok(HttpResponse::Forbidden().json(json!({"error": "Access denied"})));
}
match Self::mark_thread_as_read(&thread_id, &user_email).await {
Ok(_) => Ok(HttpResponse::Ok().json(json!({"success": true}))),
Err(e) => {
log::error!("Error marking thread as read: {}", e);
Ok(HttpResponse::InternalServerError().json(json!({"error": "Failed to mark as read"})))
}
}
}
// Helper methods for data persistence using user_data files
async fn load_user_threads(user_email: &str) -> Result<ThreadsResponse, Box<dyn std::error::Error>> {
let user_data = UserPersistence::load_user_data(user_email)
.ok_or("User data not found")?;
let threads: Vec<MessageThread> = user_data.message_threads.unwrap_or_default();
log::info!("🔍 Loading threads for user: {}", user_email);
log::info!("📊 Found {} threads", threads.len());
let mut thread_summaries = Vec::new();
let mut total_unread = 0;
for thread in threads {
let unread_count = thread.get_unread_count(user_email);
total_unread += unread_count;
log::info!("📨 Thread {}: user_a={}, user_b={}, user_a_unread={}, user_b_unread={}, calculated_unread={}",
thread.thread_id,
thread.user_a_email,
thread.user_b_email,
thread.user_a_unread_count,
thread.user_b_unread_count,
unread_count
);
// Get last message
let messages = Self::load_thread_messages(&thread.thread_id).await.unwrap_or_default();
let last_message = messages.last().map(|m| m.content.clone());
let last_message_at = messages.last().map(|m| m.timestamp);
thread_summaries.push(ThreadWithLastMessage {
thread_id: thread.thread_id.clone(),
recipient_email: thread.get_recipient_email(user_email).to_string(),
recipient_name: None, // Could be enhanced to lookup user names
subject: thread.subject.clone(),
context_type: thread.context_type.clone(),
context_id: thread.context_id.clone(),
last_message,
last_message_at,
unread_count,
created_at: thread.created_at,
});
}
// Sort by last message time (most recent first)
thread_summaries.sort_by(|a, b| {
b.last_message_at.unwrap_or(b.created_at)
.cmp(&a.last_message_at.unwrap_or(a.created_at))
});
log::info!("📊 Total unread count for {}: {}", user_email, total_unread);
Ok(ThreadsResponse {
threads: thread_summaries,
unread_count: total_unread,
})
}
async fn find_existing_thread(
user_email: &str,
recipient_email: &str,
context_type: &str,
context_id: &Option<String>,
) -> Result<MessageThread, Box<dyn std::error::Error>> {
let user_data = UserPersistence::load_user_data(user_email)
.ok_or("User data not found")?;
let threads = user_data.message_threads.unwrap_or_default();
for thread in threads {
if (thread.user_a_email == user_email && thread.user_b_email == recipient_email) ||
(thread.user_a_email == recipient_email && thread.user_b_email == user_email) {
if thread.context_type == context_type && thread.context_id == *context_id {
return Ok(thread);
}
}
}
Err("Thread not found".into())
}
async fn save_thread(thread: &MessageThread) -> Result<(), Box<dyn std::error::Error>> {
// Save to both users' data
Self::add_thread_to_user(&thread.user_a_email, thread).await?;
Self::add_thread_to_user(&thread.user_b_email, thread).await?;
Ok(())
}
async fn add_thread_to_user(user_email: &str, thread: &MessageThread) -> Result<(), Box<dyn std::error::Error>> {
let mut user_data = UserPersistence::load_user_data(user_email)
.unwrap_or_else(|| {
use crate::services::user_persistence::UserPersistentData;
UserPersistentData {
user_email: user_email.to_string(),
message_threads: Some(Vec::new()),
messages: Some(Vec::new()),
..Default::default()
}
});
if user_data.message_threads.is_none() {
user_data.message_threads = Some(Vec::new());
}
if let Some(ref mut threads) = user_data.message_threads {
// Check if thread already exists
if !threads.iter().any(|t| t.thread_id == thread.thread_id) {
threads.push(thread.clone());
}
}
UserPersistence::save_user_data(&user_data)?;
Ok(())
}
async fn load_thread(thread_id: &str) -> Result<MessageThread, Box<dyn std::error::Error>> {
// For simplicity, we'll search through all user files to find the thread
// In a real implementation, you'd have a more efficient lookup
let user_files = std::fs::read_dir("user_data/")?;
for entry in user_files {
let entry = entry?;
if let Some(filename) = entry.file_name().to_str() {
if filename.ends_with(".json") && !filename.contains("_cart") {
let email = filename.replace(".json", "").replace("_at_", "@");
if let Some(user_data) = UserPersistence::load_user_data(&email) {
if let Some(threads) = user_data.message_threads {
for thread in threads {
if thread.thread_id == thread_id {
return Ok(thread);
}
}
}
}
}
}
}
Err("Thread not found".into())
}
async fn user_has_thread_access(user_email: &str, thread_id: &str) -> bool {
if let Ok(thread) = Self::load_thread(thread_id).await {
return thread.user_a_email == user_email || thread.user_b_email == user_email;
}
false
}
async fn load_thread_messages(thread_id: &str) -> Result<Vec<Message>, Box<dyn std::error::Error>> {
// Load messages from user data files instead of separate message files
let user_files = std::fs::read_dir("user_data/")?;
let mut all_messages = Vec::new();
for entry in user_files {
let entry = entry?;
if let Some(filename) = entry.file_name().to_str() {
if filename.ends_with(".json") && !filename.contains("_cart") && !filename.starts_with("messages_") {
let email = filename.replace(".json", "").replace("_at_", "@");
if let Some(user_data) = UserPersistence::load_user_data(&email) {
if let Some(messages) = &user_data.messages {
for message in messages {
if message.thread_id == thread_id {
all_messages.push(message.clone());
}
}
}
}
}
}
}
// Sort messages by timestamp and deduplicate
all_messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
all_messages.dedup_by(|a, b| a.message_id == b.message_id);
Ok(all_messages)
}
async fn save_message(message: &Message) -> Result<(), Box<dyn std::error::Error>> {
// Save message to both sender and recipient user data files
let participants = vec![&message.sender_email, &message.recipient_email];
for email in participants {
let mut user_data = UserPersistence::load_user_data(email)
.unwrap_or_else(|| {
use crate::services::user_persistence::UserPersistentData;
UserPersistentData {
user_email: email.clone(),
message_threads: Some(Vec::new()),
messages: Some(Vec::new()),
..Default::default()
}
});
// Add message to user's messages
if let Some(ref mut messages) = user_data.messages {
messages.push(message.clone());
} else {
user_data.messages = Some(vec![message.clone()]);
}
UserPersistence::save_user_data(&user_data)?;
}
Ok(())
}
async fn update_thread_on_message(thread_id: &str, recipient_email: &str) -> Result<(), Box<dyn std::error::Error>> {
log::info!("📨 Updating thread {} for recipient {}", thread_id, recipient_email);
// Update recipient's thread data to increment unread count
let mut recipient_data = UserPersistence::load_user_data(recipient_email)
.ok_or("Recipient user data not found")?;
if let Some(ref mut threads) = recipient_data.message_threads {
for thread in threads.iter_mut() {
if thread.thread_id == thread_id {
log::info!("📨 Found thread {} in recipient's data", thread_id);
log::info!("📨 Before increment - Thread {}: user_a={}, user_b={}, user_a_unread={}, user_b_unread={}",
thread.thread_id, thread.user_a_email, thread.user_b_email,
thread.user_a_unread_count, thread.user_b_unread_count);
thread.last_message_at = Some(Utc::now());
thread.updated_at = Utc::now();
thread.increment_unread_count(recipient_email);
log::info!("📨 After increment - Thread {}: user_a_unread={}, user_b_unread={}",
thread.thread_id, thread.user_a_unread_count, thread.user_b_unread_count);
UserPersistence::save_user_data(&recipient_data)?;
log::info!("📨 Saved recipient data for {}", recipient_email);
break;
}
}
}
Ok(())
}
async fn mark_thread_as_read(thread_id: &str, user_email: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut user_data = UserPersistence::load_user_data(user_email)
.ok_or("User data not found")?;
if let Some(ref mut threads) = user_data.message_threads {
for thread in threads.iter_mut() {
if thread.thread_id == thread_id {
thread.reset_unread_count(user_email);
UserPersistence::save_user_data(&user_data)?;
break;
}
}
}
Ok(())
}
}

16
src/controllers/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
// Export controllers
pub mod auth;
pub mod currency;
pub mod dashboard;
pub mod messaging;
pub mod debug;
pub mod docs;
pub mod gitea_auth;
pub mod home;
pub mod marketplace;
pub mod order;
pub mod pool;
pub mod product;
pub mod public;
pub mod rental;
pub mod wallet;

1331
src/controllers/order.rs Normal file

File diff suppressed because it is too large Load Diff

340
src/controllers/pool.rs Normal file
View File

@@ -0,0 +1,340 @@
use actix_web::{web, Result, Responder};
use actix_session::Session;
use crate::models::pool::*;
use crate::utils::response_builder::ResponseBuilder;
use crate::models::user::{Transaction, TransactionType, TransactionStatus};
use crate::services::pool_service::POOL_SERVICE;
use crate::services::session_manager::{SessionManager, UserSessionData};
use crate::controllers::wallet::WalletController;
use chrono::Utc;
use rust_decimal_macros::dec;
use std::collections::HashMap;
pub struct PoolController;
impl PoolController {
/// Get all available pools
pub async fn get_pools() -> Result<impl Responder> {
let pools = POOL_SERVICE.get_all_pools();
ResponseBuilder::ok().json(pools).build()
}
/// Get specific pool information
pub async fn get_pool(pool_id: web::Path<String>) -> Result<impl Responder> {
match POOL_SERVICE.get_pool(&pool_id) {
Some(pool) => ResponseBuilder::ok().json(pool).build(),
None => ResponseBuilder::not_found().json(serde_json::json!({
"error": "Pool not found"
})).build(),
}
}
/// Execute token exchange
pub async fn exchange_tokens(
request: web::Json<ExchangeRequest>,
session: Session,
) -> Result<impl Responder> {
// Get user from session
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => return ResponseBuilder::unauthorized().json(ExchangeResponse {
success: false,
message: "User not authenticated".to_string(),
transaction_id: None,
from_amount: None,
to_amount: None,
exchange_rate: None,
fee: None,
}).build(),
};
// Load user with session data
let req_id = uuid::Uuid::new_v4().to_string();
let mut user = match WalletController::load_user_with_session_data(&session, Some(&req_id)).await {
Some(u) => u,
None => return ResponseBuilder::unauthorized().json(ExchangeResponse {
success: false,
message: "User data not found".to_string(),
transaction_id: None,
from_amount: None,
to_amount: None,
exchange_rate: None,
fee: None,
}).build(),
};
// Check if user has sufficient USD balance when selling Credits (unified insufficient funds contract)
if request.from_token == "USD" {
let current_balance = user.get_wallet_balance()?;
if current_balance < request.amount {
let required = request.amount;
let available = current_balance;
let deficit = required - available;
return ResponseBuilder::payment_required()
.error_envelope(
"INSUFFICIENT_FUNDS",
"Insufficient balance",
serde_json::json!({
"currency": "USD",
"wallet_balance_usd": available,
"required_usd": required,
"deficit_usd": deficit
})
)
.build();
}
}
// For TFT and PEAQ, we assume users have them in external wallets (blockchain)
// Just like fiat transactions, we don't track external token balances
// Execute exchange through pool service
let exchange_result = POOL_SERVICE.execute_exchange(&request);
if !exchange_result.success {
return ResponseBuilder::bad_request().json(exchange_result).build();
}
// Update user balance and create transaction using persistent data
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(&user_email);
let transaction_id = exchange_result.transaction_id.clone().unwrap();
// Update USD balance based on exchange direction
// We only track USD Credits - external tokens (TFT/PEAQ) are handled in user's external wallets
if request.from_token == "USD" {
// User is selling Credits for TFT/PEAQ - deduct USD
persistent_data.wallet_balance_usd -= request.amount;
} else if request.to_token == "USD" {
// User is buying Credits with TFT/PEAQ - add USD
persistent_data.wallet_balance_usd += exchange_result.to_amount.unwrap();
}
// Create transaction record
let transaction = Transaction {
id: transaction_id,
user_id: user_email.clone(),
transaction_type: TransactionType::Exchange {
from_currency: request.from_token.clone(),
to_currency: request.to_token.clone(),
rate: if request.amount != rust_decimal::Decimal::ZERO {
exchange_result.to_amount.unwrap() / request.amount
} else {
rust_decimal::Decimal::ONE
},
},
amount: if request.from_token == "USD" { -request.amount } else { exchange_result.to_amount.unwrap() },
currency: Some("USD".to_string()),
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
amount_usd: Some(if request.from_token == "USD" { request.amount.abs() } else { exchange_result.to_amount.unwrap() }),
description: Some(format!("Token exchange: {} {} to {} {}",
request.amount, request.from_token,
exchange_result.to_amount.unwrap(), request.to_token)),
reference_id: Some(format!("exchange-{}", uuid::Uuid::new_v4())),
metadata: None,
timestamp: Utc::now(),
status: TransactionStatus::Completed,
};
persistent_data.transactions.push(transaction.clone());
// Update user activities
persistent_data.user_activities.insert(0, crate::models::user::UserActivity {
id: uuid::Uuid::new_v4().to_string(),
user_email: user_email.clone(),
activity_type: crate::models::user::ActivityType::WalletTransaction,
description: format!("Exchanged {} {} for {} {}",
request.amount, request.from_token,
exchange_result.to_amount.unwrap(), request.to_token),
timestamp: Utc::now(),
metadata: None,
category: "Exchange".to_string(),
importance: crate::models::user::ActivityImportance::Medium,
ip_address: None,
user_agent: None,
session_id: None,
});
// Keep only last 10 activities
if persistent_data.user_activities.len() > 10 {
persistent_data.user_activities.truncate(10);
}
// Save the updated persistent data using locked persistence
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data_locked(&persistent_data, Some(&req_id)).await {
log::error!("Failed to save user data after exchange: {}", e);
}
// Save updated session data
let session_data = UserSessionData {
user_email: user_email.clone(),
wallet_balance: user.get_wallet_balance()?,
transactions: vec![],
staked_amount: dec!(0),
pool_positions: HashMap::new(),
};
if let Err(_e) = SessionManager::save_user_session_data_async(&session, &session_data, Some(&req_id)).await {
}
ResponseBuilder::ok().json(exchange_result).build()
}
/// Get pool analytics data
pub async fn get_analytics() -> Result<impl Responder> {
let analytics = POOL_SERVICE.get_analytics();
ResponseBuilder::ok().json(analytics).build()
}
/// Stake USD Credits
pub async fn stake_credits(
request: web::Json<StakeRequest>,
session: Session,
) -> Result<impl Responder> {
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => return ResponseBuilder::unauthorized().json(serde_json::json!({
"success": false,
"message": "User not authenticated"
})).build(),
};
// Load user with session data
let req_id = uuid::Uuid::new_v4().to_string();
let mut user = match WalletController::load_user_with_session_data(&session, Some(&req_id)).await {
Some(u) => u,
None => return ResponseBuilder::unauthorized().json(serde_json::json!({
"success": false,
"message": "User data not found"
})).build(),
};
// Check sufficient balance (unified insufficient funds contract)
let current_balance = user.get_wallet_balance()?;
if current_balance < request.amount {
let required = request.amount;
let available = current_balance;
let deficit = required - available;
return ResponseBuilder::payment_required()
.error_envelope(
"INSUFFICIENT_FUNDS",
"Insufficient balance",
serde_json::json!({
"currency": "USD",
"wallet_balance_usd": available,
"required_usd": required,
"deficit_usd": deficit
})
)
.build();
}
// Calculate staking benefits
let (discount_percentage, reputation_bonus) = Self::calculate_staking_benefits(request.amount);
// Create stake position
let stake_position = StakePosition {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_email.clone(),
amount: request.amount,
start_date: Utc::now(),
end_date: Utc::now() + chrono::Duration::days(30 * request.duration_months as i64),
discount_percentage,
reputation_bonus,
status: StakeStatus::Active,
};
// Update user data using persistent data
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(&user_email);
// Deduct staked amount from available balance
persistent_data.wallet_balance_usd -= request.amount;
// Create staking transaction
let transaction = Transaction {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_email.clone(),
transaction_type: TransactionType::Stake {
pool_id: request.pool_id.clone(),
amount: request.amount,
},
amount: -request.amount,
currency: Some("USD".to_string()),
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
amount_usd: Some(request.amount),
description: Some(format!("Staking {} USD for {} months", request.amount, request.duration_months)),
reference_id: Some(format!("stake-{}", uuid::Uuid::new_v4())),
metadata: None,
timestamp: Utc::now(),
status: TransactionStatus::Completed,
};
persistent_data.transactions.push(transaction);
// Update user activities
persistent_data.user_activities.insert(0, crate::models::user::UserActivity {
id: uuid::Uuid::new_v4().to_string(),
user_email: user_email.clone(),
activity_type: crate::models::user::ActivityType::WalletTransaction,
description: format!("Staked ${} for {} months", request.amount, request.duration_months),
timestamp: Utc::now(),
metadata: None,
category: "Staking".to_string(),
importance: crate::models::user::ActivityImportance::High,
ip_address: None,
user_agent: None,
session_id: None,
});
// Save the updated persistent data using locked persistence
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data_locked(&persistent_data, Some(&req_id)).await {
log::error!("Failed to save user data after staking: {}", e);
}
// Save session data with staked amount
let session_data = UserSessionData {
user_email: user_email.clone(),
wallet_balance: user.get_wallet_balance()?,
transactions: vec![],
staked_amount: request.amount,
pool_positions: HashMap::new(),
};
if let Err(_e) = SessionManager::save_user_session_data_async(&session, &session_data, Some(&req_id)).await {
}
ResponseBuilder::ok().json(serde_json::json!({
"success": true,
"message": format!("Successfully staked ${} for {} months", request.amount, request.duration_months),
"stake_position": stake_position,
"discount_percentage": discount_percentage,
"reputation_bonus": reputation_bonus
})).build()
}
/// Calculate staking benefits based on amount
fn calculate_staking_benefits(amount: rust_decimal::Decimal) -> (rust_decimal::Decimal, i32) {
use rust_decimal_macros::dec;
let discount_percentage = if amount >= dec!(5000) {
dec!(15.0) // 15% discount for $500+ Credits
} else if amount >= dec!(1000) {
dec!(10.0) // 10% discount for $100+ Credits
} else if amount >= dec!(500) {
dec!(7.5) // 7.5% discount for $50+ Credits
} else {
dec!(5.0) // 5% discount for any staking
};
let reputation_bonus = if amount >= dec!(5000) {
100 // +100 reputation for $500+ Credits
} else if amount >= dec!(1000) {
50 // +50 reputation for $100+ Credits
} else if amount >= dec!(500) {
25 // +25 reputation for $50+ Credits
} else {
10 // +10 reputation for any staking
};
(discount_percentage, reputation_bonus)
}
}

399
src/controllers/product.rs Normal file
View File

@@ -0,0 +1,399 @@
use actix_web::{web, Result, Responder};
use tera::Tera;
use crate::utils::render_template;
use crate::utils::response_builder::ResponseBuilder;
use crate::config::get_app_config;
use crate::services::product::{ProductService, ProductSearchCriteria};
use crate::services::currency::CurrencyService;
use actix_session::Session;
use serde::{Deserialize, Serialize};
use rust_decimal::Decimal;
/// Controller for handling product-related routes
pub struct ProductController;
#[derive(Debug, Deserialize, Serialize)]
pub struct ProductSearchQuery {
pub q: Option<String>,
pub category: Option<String>,
pub min_price: Option<String>,
pub max_price: Option<String>,
pub provider: Option<String>,
pub location: Option<String>,
pub tags: Option<String>,
pub featured: Option<bool>,
pub page: Option<usize>,
pub per_page: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct ProductListResponse {
pub products: Vec<ProductWithPrice>,
pub categories: Vec<crate::models::product::ProductCategory>,
pub total_count: usize,
pub page: usize,
pub total_pages: usize,
pub currency: String,
}
#[derive(Debug, Serialize)]
pub struct ProductWithPrice {
pub product: crate::models::product::Product,
pub price: crate::models::currency::Price,
pub formatted_price: String,
}
impl ProductController {
/// List products with optional filtering and pagination
pub async fn list_products(
tmpl: web::Data<Tera>,
session: Session,
query: web::Query<ProductSearchQuery>,
) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let product_service = ProductService::builder()
.currency_service(currency_service.clone())
.include_slice_products(true)
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
// Get user's preferred currency
let display_currency = currency_service.get_user_preferred_currency(&session);
// Build search criteria
let mut criteria = ProductSearchCriteria::new();
if let Some(ref q) = query.q {
criteria = criteria.with_query(q.clone());
}
if let Some(ref category) = query.category {
criteria = criteria.with_category(category.clone());
}
if let Some(ref min_price_str) = query.min_price {
if let Ok(min_price) = min_price_str.parse::<Decimal>() {
let max_price = criteria.max_price;
criteria = criteria.with_price_range(Some(min_price), max_price);
}
}
if let Some(ref max_price_str) = query.max_price {
if let Ok(max_price) = max_price_str.parse::<Decimal>() {
let min_price = criteria.min_price;
criteria = criteria.with_price_range(min_price, Some(max_price));
}
}
if let Some(ref provider) = query.provider {
criteria = criteria.with_provider(provider.clone());
}
if let Some(ref location) = query.location {
criteria = criteria.with_location(location.clone());
}
if let Some(ref tags_str) = query.tags {
let tags: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
criteria = criteria.with_tags(tags);
}
if query.featured.unwrap_or(false) {
criteria = criteria.featured_only();
}
// Pagination
let page = query.page.unwrap_or(0);
let per_page = query.per_page.unwrap_or(12);
// Search products
let search_result = product_service.search_products_advanced(&criteria, page, per_page);
// Convert prices to user's currency
let products_with_prices = match product_service.get_products_with_converted_prices(
&search_result.products,
&display_currency,
) {
Ok(converted) => converted.into_iter()
.map(|(product, price)| {
let formatted_price = currency_service.format_price(
price.display_amount,
&price.display_currency,
).unwrap_or_else(|_| format!("{} {}", price.display_amount, price.display_currency));
ProductWithPrice {
product,
price,
formatted_price,
}
})
.collect(),
Err(_) => Vec::new(),
};
let response = ProductListResponse {
products: products_with_prices,
categories: product_service.get_categories().clone(),
total_count: search_result.total_count,
page: search_result.page,
total_pages: search_result.total_pages,
currency: display_currency.clone(),
};
// For API requests, return JSON
if query.q.is_some() || query.category.is_some() {
return ResponseBuilder::ok()
.json(response)
.build();
}
// For regular page requests, render template
let mut ctx = crate::models::builders::ContextBuilder::new()
.active_page("marketplace")
.build();
ctx.insert("active_section", "products");
ctx.insert("response", &response);
ctx.insert("search_query", &query.into_inner());
ctx.insert("currencies", &currency_service.get_currency_display_info());
ctx.insert("user_currency", &display_currency);
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "marketplace/products.html", &ctx)
}
/// Get product details
pub async fn get_product_details(
tmpl: web::Data<Tera>,
session: Session,
path: web::Path<String>,
) -> Result<impl Responder> {
let product_id = path.into_inner();
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let product_service = ProductService::builder()
.currency_service(currency_service.clone())
.include_slice_products(true)
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let display_currency = currency_service.get_user_preferred_currency(&session);
if let Some(product) = product_service.get_product_by_id(&product_id) {
// Convert price to user's currency
let price = match currency_service.create_price(
product.base_price,
&product.base_currency,
&display_currency,
) {
Ok(price) => price,
Err(_) => return ResponseBuilder::internal_error()
.json("Currency conversion failed")
.build(),
};
let formatted_price = currency_service.format_price(
price.display_amount,
&price.display_currency,
).unwrap_or_else(|_| format!("{} {}", price.display_amount, price.display_currency));
// Get recommendations
let recommendations = product_service.get_product_recommendations(&product_id, 4);
let recommendations_with_prices = match product_service.get_products_with_converted_prices(
&recommendations,
&display_currency,
) {
Ok(converted) => converted.into_iter()
.map(|(product, price)| {
let formatted_price = currency_service.format_price(
price.display_amount,
&price.display_currency,
).unwrap_or_else(|_| format!("{} {}", price.display_amount, price.display_currency));
ProductWithPrice {
product,
price,
formatted_price,
}
})
.collect(),
Err(_) => Vec::new(),
};
let product_with_price = ProductWithPrice {
product: product.clone(),
price,
formatted_price,
};
let mut ctx = tera::Context::new();
ctx.insert("active_page", "marketplace");
ctx.insert("active_section", "product_detail");
// Add gitea_enabled flag (required by base template)
let is_gitea_flow_active = get_app_config().is_gitea_enabled();
ctx.insert("gitea_enabled", &is_gitea_flow_active);
ctx.insert("product", &product_with_price);
ctx.insert("recommendations", &recommendations_with_prices);
ctx.insert("currencies", &currency_service.get_currency_display_info());
ctx.insert("user_currency", &display_currency);
// Add slice product specific data if this is a slice product
if product_service.is_slice_product(&product_id) {
if let Some(slice_details) = product_service.get_slice_product_details(&product_id) {
ctx.insert("slice_details", &slice_details);
ctx.insert("is_slice_product", &true);
} else {
ctx.insert("is_slice_product", &false);
}
} else {
ctx.insert("is_slice_product", &false);
}
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "marketplace/product_detail_step2.html", &ctx)
} else {
ResponseBuilder::not_found()
.json("Product not found")
.build()
}
}
/// Search products (API endpoint)
pub async fn search_products(
session: Session,
query: web::Query<ProductSearchQuery>,
) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let product_service = ProductService::builder()
.currency_service(currency_service.clone())
.include_slice_products(true)
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let display_currency = currency_service.get_user_preferred_currency(&session);
// Build search criteria
let mut criteria = ProductSearchCriteria::new();
if let Some(ref q) = query.q {
criteria = criteria.with_query(q.clone());
}
if let Some(ref category) = query.category {
criteria = criteria.with_category(category.clone());
}
// Pagination
let page = query.page.unwrap_or(0);
let per_page = query.per_page.unwrap_or(12);
// Search products
let search_result = product_service.search_products_advanced(&criteria, page, per_page);
// Convert prices to user's currency
let products_with_prices = match product_service.get_products_with_converted_prices(
&search_result.products,
&display_currency,
) {
Ok(converted) => converted.into_iter()
.map(|(product, price)| {
let formatted_price = currency_service.format_price(
price.display_amount,
&price.display_currency,
).unwrap_or_else(|_| format!("{} {}", price.display_amount, price.display_currency));
ProductWithPrice {
product,
price,
formatted_price,
}
})
.collect(),
Err(_) => Vec::new(),
};
let response = ProductListResponse {
products: products_with_prices,
categories: product_service.get_categories().clone(),
total_count: search_result.total_count,
page: search_result.page,
total_pages: search_result.total_pages,
currency: display_currency,
};
ResponseBuilder::ok()
.json(response)
.build()
}
/// Get product categories
pub async fn get_categories() -> Result<impl Responder> {
let product_service = ProductService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let categories = product_service.get_categories();
ResponseBuilder::ok()
.json(categories)
.build()
}
/// Get featured products
pub async fn get_featured_products(session: Session) -> Result<impl Responder> {
let currency_service = CurrencyService::builder()
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let product_service = ProductService::builder()
.currency_service(currency_service.clone())
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let display_currency = currency_service.get_user_preferred_currency(&session);
let featured_products = product_service.get_featured_products();
// Convert prices to user's currency
let products_with_prices = match product_service.get_products_with_converted_prices(
&featured_products,
&display_currency,
) {
Ok(converted) => converted.into_iter()
.map(|(product, price)| {
let formatted_price = currency_service.format_price(
price.display_amount,
&price.display_currency,
).unwrap_or_else(|_| format!("{} {}", price.display_amount, price.display_currency));
ProductWithPrice {
product,
price,
formatted_price,
}
})
.collect(),
Err(_) => Vec::new(),
};
ResponseBuilder::ok()
.json(products_with_prices)
.build()
}
}

170
src/controllers/public.rs Normal file
View File

@@ -0,0 +1,170 @@
use actix_web::{web, Result, Responder};
use tera::Tera;
use crate::utils::render_template;
use crate::config::get_app_config;
use actix_session::Session;
/// Controller for handling all publicly accessible pages
pub struct PublicController;
impl PublicController {
/// Renders the changelog page
pub async fn changelog(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "changelog");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "changelog.html", &ctx)
}
/// Renders the roadmap page
pub async fn roadmap(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "roadmap");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "roadmap.html", &ctx)
}
/// Renders the privacy policy page
pub async fn privacy(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "privacy");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/privacy.html", &ctx)
}
/// Renders the terms of service page
pub async fn terms(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "terms");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/terms.html", &ctx)
}
/// Renders the farmers terms page
pub async fn terms_farmers(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "terms");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/terms-farmers.html", &ctx)
}
/// Renders the service providers terms page
pub async fn terms_service_providers(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "terms");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/terms-service-providers.html", &ctx)
}
/// Renders the solution providers terms page
pub async fn terms_solution_providers(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "terms");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/terms-solution-providers.html", &ctx)
}
/// Renders the users terms page
pub async fn terms_users(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = crate::models::builders::ContextBuilder::new()
.build();
ctx.insert("active_page", "terms_users");
let config = get_app_config();
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
// Add user data to context
if let Ok(Some(user_json)) = session.get::<String>("user") {
ctx.insert("user_json", &user_json);
if let Ok(user) = serde_json::from_str::<crate::models::user::User>(&user_json) {
ctx.insert("user", &user);
}
}
render_template(&tmpl, "legal/terms-users.html", &ctx)
}
}

642
src/controllers/rental.rs Normal file
View File

@@ -0,0 +1,642 @@
use actix_web::{web, Result, Responder};
use actix_session::Session;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::utils::response_builder::ResponseBuilder;
use crate::models::user::{User, Transaction, TransactionType, TransactionStatus};
use crate::config::get_app_config;
use crate::services::product::ProductService;
use crate::services::user_persistence::UserPersistence;
use chrono::Utc;
/// Controller for handling rental and purchase operations
pub struct RentalController;
#[derive(Debug, Deserialize)]
pub struct RentProductRequest {
pub product_id: String,
pub duration: String, // "monthly", "yearly", etc.
#[serde(default)]
pub duration_days: Option<u32>, // Number of days for the rental
}
#[derive(Debug, Serialize)]
pub struct RentalResponse {
pub success: bool,
pub message: String,
pub rental_id: Option<String>,
pub transaction_id: Option<String>,
}
impl RentalController {
/// Rent a product
pub async fn rent_product(
product_id: web::Path<String>,
request: Option<web::Json<RentProductRequest>>,
session: Session,
) -> Result<impl Responder> {
// Get user from session
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User not authenticated".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Get user data
let user_json = match session.get::<String>("user")? {
Some(json) => json,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User data not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
let mut user: User = match serde_json::from_str(&user_json) {
Ok(u) => u,
Err(_) => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Invalid user data".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Get product from ProductService
let product_service = ProductService::new();
let product = match product_service.get_product_by_id(&product_id) {
Some(p) => p,
None => {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Product not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Load user persistent data and check if product is already rented
let mut user_data = UserPersistence::load_user_data(&user_email)
.unwrap_or_else(|| UserPersistence::create_default_user_data(&user_email));
// Check if product is already in active rentals
let already_rented = user_data.slice_rentals.iter()
.any(|rental| rental.slice_format == product_id.to_string());
if already_rented {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Product already rented by user".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Check user balance from persistent data
let user_balance = user_data.wallet_balance_usd;
let rental_cost = product.base_price;
if user_balance < rental_cost {
let required = rental_cost;
let available = user_balance;
let deficit = required - available;
return ResponseBuilder::payment_required()
.error_envelope(
"INSUFFICIENT_FUNDS",
"Insufficient balance",
serde_json::json!({
"currency": "USD",
"wallet_balance_usd": available,
"required_usd": required,
"deficit_usd": deficit
})
)
.build();
}
// Extract request body (required when mocks enabled)
let req_data = match request {
Some(r) => r.into_inner(),
None => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Missing or invalid request body".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Create rental and transaction
let rental_id = uuid::Uuid::new_v4().to_string();
let transaction_id = uuid::Uuid::new_v4().to_string();
let transaction = Transaction {
id: transaction_id.clone(),
user_id: user_email.clone(),
transaction_type: TransactionType::Rental {
rental_id: transaction_id.clone(),
rental_type: "slice".to_string(),
},
amount: rental_cost,
currency: Some("USD".to_string()),
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
amount_usd: Some(rental_cost),
description: Some(format!("Rental of product {} for {}", product_id, req_data.duration)),
reference_id: Some(format!("rental-{}", uuid::Uuid::new_v4())),
metadata: None,
timestamp: Utc::now(),
status: TransactionStatus::Completed,
};
// Update user persistent data
// Deduct balance
user_data.wallet_balance_usd -= rental_cost;
// Add transaction
user_data.transactions.push(transaction);
// Create a slice rental record
let slice_rental = crate::services::slice_calculator::SliceRental {
rental_id: rental_id.clone(),
slice_combination_id: format!("combo-{}", rental_id),
node_id: "node-placeholder".to_string(), // TODO: Get from product
farmer_email: "farmer@example.com".to_string(), // TODO: Get from product
slice_allocation: crate::services::slice_calculator::SliceAllocation {
allocation_id: format!("alloc-{}", rental_id),
slice_combination_id: format!("combo-{}", rental_id),
renter_email: user_email.clone(),
base_slices_used: 1,
rental_start: Utc::now(),
rental_end: None,
status: crate::services::slice_calculator::AllocationStatus::Active,
monthly_cost: rental_cost,
},
total_cost: rental_cost,
payment_status: crate::services::slice_calculator::PaymentStatus::Paid,
id: rental_id.clone(),
user_email: user_email.clone(),
slice_format: "1x1".to_string(),
status: "Active".to_string(),
start_date: Some(Utc::now()),
rental_duration_days: Some(30),
monthly_cost: Some(rental_cost),
deployment_type: Some("vm".to_string()),
deployment_name: Some(format!("deployment-{}", rental_id)),
deployment_config: None,
deployment_status: Some("Provisioning".to_string()),
deployment_endpoint: None,
deployment_metadata: None,
};
user_data.slice_rentals.push(slice_rental);
// Add user activity
user_data.user_activities.push(crate::models::user::UserActivity {
id: Uuid::new_v4().to_string(),
user_email: user_email.clone(),
activity_type: crate::models::user::ActivityType::SliceRental,
description: format!("Rented {} for ${}", product.name, rental_cost),
metadata: Some(serde_json::json!({
"product_id": product_id.to_string(),
"rental_id": rental_id,
"cost": rental_cost
})),
timestamp: Utc::now(),
ip_address: None,
user_agent: None,
session_id: None,
category: "Rental".to_string(),
importance: crate::models::user::ActivityImportance::Medium,
});
// Save updated user data
UserPersistence::save_user_data(&user_data)?;
ResponseBuilder::ok().json(RentalResponse {
success: true,
message: format!("Successfully rented {} for ${}", product.name, rental_cost),
rental_id: Some(rental_id),
transaction_id: Some(transaction_id),
}).build()
}
/// Purchase a product (one-time payment)
pub async fn purchase_product(
product_id: web::Path<String>,
session: Session,
) -> Result<impl Responder> {
// Gate mock-based purchase when mocks are disabled
if !get_app_config().enable_mock_data() {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Purchase feature unavailable".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Get user from session
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User not authenticated".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Get user data
let user_json = match session.get::<String>("user")? {
Some(json) => json,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User data not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
let mut user: User = match serde_json::from_str(&user_json) {
Ok(u) => u,
Err(_) => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Invalid user data".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Get product from ProductService (replaces MockDataService)
let product_service = match crate::services::product::ProductService::builder().build() {
Ok(service) => service,
Err(_) => {
return ResponseBuilder::internal_error().json(RentalResponse {
success: false,
message: "Failed to initialize product service".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
let product = match product_service.get_product_by_id(&product_id) {
Some(p) => p,
None => {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Product not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Check if product is already owned by this user
if let Ok(owned_products) = user.get_owned_products() {
if owned_products.iter().any(|p| p.id == **product_id) {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Product already owned by user".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
}
// Continue with rental logic if not owned
if false { // This condition will be replaced by the existing logic below
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Product already owned by user".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Check user balance
let user_balance = user.get_wallet_balance()?;
let purchase_cost = product.base_price;
if user_balance < purchase_cost {
let required = purchase_cost;
let available = user_balance;
let deficit = required - available;
return ResponseBuilder::payment_required()
.error_envelope(
"INSUFFICIENT_FUNDS",
"Insufficient balance",
serde_json::json!({
"currency": "USD",
"wallet_balance_usd": available,
"required_usd": required,
"deficit_usd": deficit
})
)
.build();
}
// Create transaction
let transaction_id = uuid::Uuid::new_v4().to_string();
let transaction = Transaction {
id: transaction_id.clone(),
user_id: user_email.clone(),
transaction_type: TransactionType::Purchase {
product_id: product_id.to_string(),
},
amount: purchase_cost,
currency: Some("USD".to_string()),
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
amount_usd: Some(purchase_cost),
description: Some(format!("Purchase of product {}", product_id)),
reference_id: Some(format!("purchase-{}", uuid::Uuid::new_v4())),
metadata: None,
timestamp: Utc::now(),
status: TransactionStatus::Completed,
};
// Update user data using persistent data
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(&user_email);
// Deduct balance
persistent_data.wallet_balance_usd -= purchase_cost;
// Add to owned products
persistent_data.owned_product_ids.push(product_id.to_string());
// Add transaction
persistent_data.transactions.push(transaction);
// Update user activities
persistent_data.user_activities.insert(0, crate::models::user::UserActivity {
id: uuid::Uuid::new_v4().to_string(),
user_email: user_email.clone(),
activity_type: crate::models::user::ActivityType::Purchase,
description: format!("Purchased {} for ${}", product.name, purchase_cost),
timestamp: Utc::now(),
metadata: None,
category: "Purchase".to_string(),
importance: crate::models::user::ActivityImportance::High,
ip_address: None,
user_agent: None,
session_id: None,
});
// Save the updated persistent data
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data(&persistent_data) {
log::error!("Failed to save user data after purchase: {}", e);
}
// Update session with new user data
let updated_user_json = serde_json::to_string(&user).unwrap();
session.insert("user", updated_user_json)?;
ResponseBuilder::ok().json(RentalResponse {
success: true,
message: format!("Successfully purchased {} for ${}", product.name, purchase_cost),
rental_id: None,
transaction_id: Some(transaction_id),
}).build()
}
/// Cancel a rental
pub async fn cancel_rental(
rental_id: web::Path<String>,
session: Session,
) -> Result<impl Responder> {
// Gate mock-based rental cancel when mocks are disabled
if !get_app_config().enable_mock_data() {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Rental feature unavailable".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Get user from session
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User not authenticated".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Get user data
let user_json = match session.get::<String>("user")? {
Some(json) => json,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User data not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
let mut user: User = match serde_json::from_str(&user_json) {
Ok(u) => u,
Err(_) => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Invalid user data".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Update user data using persistent data
let user_email = &user.email;
let mut persistent_data = crate::models::builders::SessionDataBuilder::load_or_create(user_email);
// Remove rental
if let Some(pos) = persistent_data.active_product_rentals.iter().position(|x| x.rental_id == rental_id.to_string()) {
persistent_data.active_product_rentals.remove(pos);
// Update user activities
persistent_data.user_activities.insert(0, crate::models::user::UserActivity {
id: uuid::Uuid::new_v4().to_string(),
user_email: user_email.clone(),
activity_type: crate::models::user::ActivityType::Purchase,
description: format!("Cancelled rental {}", rental_id),
timestamp: Utc::now(),
metadata: None,
category: "Rental".to_string(),
importance: crate::models::user::ActivityImportance::Medium,
ip_address: None,
user_agent: None,
session_id: None,
});
// Keep only last 10 activities
if persistent_data.user_activities.len() > 10 {
persistent_data.user_activities.truncate(10);
}
// Save the updated persistent data
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data(&persistent_data) {
log::error!("Failed to save user data after rental cancellation: {}", e);
}
} else {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Rental not found".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Update session with new user data
let updated_user_json = serde_json::to_string(&user).unwrap();
session.insert("user", updated_user_json)?;
ResponseBuilder::ok().json(RentalResponse {
success: true,
message: "Rental cancelled successfully".to_string(),
rental_id: Some(rental_id.to_string()),
transaction_id: None,
}).build()
}
/// Rent a node product (slice or full node)
pub async fn rent_node_product(
product_id: web::Path<String>,
request: Option<web::Json<RentNodeProductRequest>>,
session: Session,
) -> Result<impl Responder> {
// Gate mock-based node rental when mocks are disabled
if !get_app_config().enable_mock_data() {
return ResponseBuilder::not_found().json(RentalResponse {
success: false,
message: "Node rental feature unavailable".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
// Get user from session
let user_email = match session.get::<String>("user_email")? {
Some(email) => email,
None => {
return ResponseBuilder::unauthorized().json(RentalResponse {
success: false,
message: "User not authenticated".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Initialize node rental service
let node_rental_service = match crate::services::node_rental::NodeRentalService::builder()
.auto_billing_enabled(true)
.notification_enabled(true)
.conflict_prevention(true)
.build()
{
Ok(service) => service,
Err(e) => {
return ResponseBuilder::internal_error().json(RentalResponse {
success: false,
message: format!("Service initialization failed: {}", e),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Extract request body (required when mocks enabled)
let req_data = match request {
Some(r) => r.into_inner(),
None => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Missing or invalid request body".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Parse duration and rental type
let duration_months = match req_data.duration.as_str() {
"monthly" => 1,
"quarterly" => 3,
"yearly" => 12,
_ => {
return ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: "Invalid duration. Use 'monthly', 'quarterly', or 'yearly'".to_string(),
rental_id: None,
transaction_id: None,
}).build();
}
};
// Determine rental type and cost based on product
let (rental_type, monthly_cost) = if product_id.starts_with("fullnode_") {
(crate::models::user::NodeRentalType::FullNode, req_data.monthly_cost.unwrap_or_else(|| rust_decimal::Decimal::from(200)))
} else {
// For slice products, we'd need to get slice configuration
// For now, use a default slice configuration
(crate::models::user::NodeRentalType::Slice, req_data.monthly_cost.unwrap_or_else(|| rust_decimal::Decimal::from(50)))
};
// Attempt to rent the node
match node_rental_service.rent_node_product(
&product_id,
&user_email,
duration_months,
rental_type,
monthly_cost,
) {
Ok((rental, _earning)) => {
ResponseBuilder::ok().json(RentalResponse {
success: true,
message: "Node rental successful".to_string(),
rental_id: Some(rental.id),
transaction_id: None,
}).build()
}
Err(e) => {
ResponseBuilder::bad_request().json(RentalResponse {
success: false,
message: e,
rental_id: None,
transaction_id: None,
}).build()
}
}
}
}
#[derive(Debug, serde::Deserialize)]
pub struct RentNodeProductRequest {
pub duration: String, // "monthly", "quarterly", "yearly"
pub monthly_cost: Option<rust_decimal::Decimal>,
}

1143
src/controllers/wallet.rs Normal file

File diff suppressed because it is too large Load Diff

37
src/lib.rs Normal file
View File

@@ -0,0 +1,37 @@
//! Project Mycelium Library
//!
//! This library provides the core functionality for Project Mycelium,
//! including services, models, and controllers for managing a decentralized marketplace.
use actix_web::cookie::Key;
use lazy_static::lazy_static;
lazy_static! {
pub static ref SESSION_KEY: Key = {
// Load key from environment variable or generate a random one
if let Ok(key_str) = std::env::var("SECRET_KEY") {
if key_str.len() >= 64 {
Key::from(key_str.as_bytes())
} else {
eprintln!("Warning: SECRET_KEY too short, using default");
Key::generate()
}
} else {
eprintln!("Warning: SECRET_KEY not found in environment, using generated key");
Key::generate()
}
};
}
pub mod config;
pub mod controllers;
pub mod middleware;
pub mod models;
pub mod routes;
pub mod services;
pub mod utils;
// Re-export commonly used types
pub use models::user::{User, NodeStakingOptions, FarmNode};
pub use services::farmer::FarmerService;
pub use services::user_persistence::UserPersistence;

90
src/main.rs Normal file
View File

@@ -0,0 +1,90 @@
use actix_web::{web, App, HttpServer, middleware::Logger};
use actix_files as fs;
use tera::Tera;
use std::{io, env};
use dotenv::dotenv;
mod config;
mod controllers;
mod middleware;
mod models;
mod routes;
mod services;
pub mod utils;
// Session key for cookie store
use actix_web::cookie::Key;
use lazy_static::lazy_static;
lazy_static! {
static ref SESSION_KEY: Key = {
// Load key from environment variable or generate a random one
match env::var("SECRET_KEY") {
Ok(key) if key.as_bytes().len() >= 32 => {
Key::from(key.as_bytes())
}
_ => {
eprintln!("Warning: generating random key (sessions will be invalidated on restart)");
Key::generate() // Generates a secure 32-byte key
}
}
};
}
#[actix_web::main]
async fn main() -> io::Result<()> {
// Initialize environment
dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Load configuration
let config = config::get_config();
// Check for port override from command line arguments
let args: Vec<String> = env::args().collect();
let mut port = config.server.port;
for i in 1..args.len() {
if args[i] == "--port" && i + 1 < args.len() {
if let Ok(p) = args[i + 1].parse::<u16>() {
port = p;
break;
}
}
}
let bind_address = format!("{}:{}", config.server.host, port);
// Create and configure the HTTP server
HttpServer::new(move || {
// Initialize Tera templates
let mut tera = match Tera::new(&format!("{}/**/*.html", config.templates.dir)) {
Ok(t) => t,
Err(e) => {
eprintln!("Tera initialization error: {}", e);
::std::process::exit(1);
}
};
// Register custom Tera functions
utils::register_tera_functions(&mut tera);
App::new()
// Enable logger middleware
.wrap(Logger::default())
// Add custom middleware
.wrap(middleware::RequestTimer)
.wrap(middleware::SecurityHeaders) // Re-enabled with improved error handling
// Note: JWT middleware removed from global scope - now applied selectively in routes
// Configure static files
.service(fs::Files::new("/static", "./src/static"))
// Add Tera template engine
.app_data(web::Data::new(tera))
// Configure routes
.configure(routes::configure_routes)
})
.workers(config.server.workers.unwrap_or_else(|| num_cpus::get() as u32) as usize)
.bind(bind_address)?
.run()
.await
}

215
src/middleware/mod.rs Normal file
View File

@@ -0,0 +1,215 @@
use std::{
future::{ready, Ready},
time::Instant,
};
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error,
};
use futures_util::future::LocalBoxFuture;
// Request Timer Middleware
pub struct RequestTimer;
impl<S, B> Transform<S, ServiceRequest> for RequestTimer
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = RequestTimerMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RequestTimerMiddleware { service }))
}
}
pub struct RequestTimerMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for RequestTimerMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let start = Instant::now();
let path = req.path().to_string();
let fut = self.service.call(req);
Box::pin(async move {
let res = fut.await?;
let elapsed = start.elapsed();
Ok(res)
})
}
}
// Security Headers Middleware
pub struct SecurityHeaders;
impl<S, B> Transform<S, ServiceRequest> for SecurityHeaders
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = SecurityHeadersMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SecurityHeadersMiddleware { service }))
}
}
pub struct SecurityHeadersMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let fut = self.service.call(req);
Box::pin(async move {
let mut res = fut.await?;
// Add security headers safely - check if headers can be modified
let headers = res.headers_mut();
// Only add headers if they don't already exist to avoid conflicts
if !headers.contains_key(actix_web::http::header::X_CONTENT_TYPE_OPTIONS) {
let _ = headers.insert(
actix_web::http::header::X_CONTENT_TYPE_OPTIONS,
actix_web::http::header::HeaderValue::from_static("nosniff"),
);
}
if !headers.contains_key(actix_web::http::header::X_FRAME_OPTIONS) {
let _ = headers.insert(
actix_web::http::header::X_FRAME_OPTIONS,
actix_web::http::header::HeaderValue::from_static("DENY"),
);
}
if !headers.contains_key(actix_web::http::header::X_XSS_PROTECTION) {
let _ = headers.insert(
actix_web::http::header::X_XSS_PROTECTION,
actix_web::http::header::HeaderValue::from_static("1; mode=block"),
);
}
Ok(res)
})
}
}
// JWT Authentication Middleware
pub struct JwtAuth;
impl<S, B> Transform<S, ServiceRequest> for JwtAuth
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = JwtAuthMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JwtAuthMiddleware { service }))
}
}
pub struct JwtAuthMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for JwtAuthMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// Define public routes that don't require authentication
let path = req.path().to_string();
let public_routes = vec![
"/login",
"/register",
"/static",
"/favicon.ico",
"/",
"/about",
"/contact",
"/auth/gitea",
"/auth/gitea/callback",
"/marketplace",
"/products",
"/cart",
"/docs",
"/privacy",
"/terms"
];
// Check if the current path is a public route or marketplace route (but not dashboard)
let is_public_route = public_routes.iter().any(|route| path.starts_with(route));
let is_marketplace_route = path.starts_with("/marketplace");
let is_api_cart_route = path.starts_with("/api/cart") || path == "/api/products" || path.starts_with("/api/products/");
let is_currency_api = path.starts_with("/api/currencies");
if is_public_route || is_marketplace_route || is_api_cart_route || is_currency_api {
// For public routes, just pass through without authentication check
let fut = self.service.call(req);
return Box::pin(async move {
fut.await
});
}
// For now, just pass through all requests
// Authentication will be handled in individual controllers
let fut = self.service.call(req);
Box::pin(async move {
fut.await
})
}
}

3388
src/models/builders.rs Normal file

File diff suppressed because it is too large Load Diff

213
src/models/currency.rs Normal file
View File

@@ -0,0 +1,213 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Configurable currency support for any currency type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Currency {
pub code: String, // USD, EUR, BTC, ETH, etc.
pub name: String,
pub symbol: String,
pub currency_type: CurrencyType,
pub exchange_rate_to_base: Decimal, // Rate to marketplace base currency
pub is_base_currency: bool,
pub decimal_places: u8, // Precision for this currency
pub is_active: bool,
pub provider_config: Option<ExchangeRateProvider>,
pub last_updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CurrencyType {
Fiat,
Cryptocurrency,
Token,
Points, // For loyalty/reward systems
Custom(String), // For marketplace-specific currencies
}
/// Pluggable exchange rate providers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExchangeRateProvider {
Static(Decimal), // Fixed rate
MockAPI { base_rate: Decimal, volatility: f32 },
RealAPI { endpoint: String, api_key: Option<String> },
Custom(String), // For custom provider implementations
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Price {
pub base_amount: Decimal, // Amount in marketplace base currency
pub base_currency: String,
pub display_currency: String,
pub display_amount: Decimal,
pub formatted_display: String,
pub conversion_rate: Decimal,
pub conversion_timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceCurrencyConfig {
pub base_currency: String, // Default: "USD" for Credits
pub supported_currencies: Vec<String>,
pub default_display_currency: String,
pub auto_update_rates: bool,
pub update_interval_minutes: u32,
pub fallback_rates: HashMap<String, Decimal>,
}
/// Exchange rate history for tracking changes over time
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExchangeRateHistory {
pub from_currency: String,
pub to_currency: String,
pub rate: Decimal,
pub timestamp: DateTime<Utc>,
pub provider: String,
}
impl Currency {
pub fn new(
code: String,
name: String,
symbol: String,
currency_type: CurrencyType,
is_base_currency: bool,
) -> Self {
Self {
code,
name,
symbol,
currency_type,
exchange_rate_to_base: if is_base_currency { Decimal::from(1) } else { Decimal::from(0) },
is_base_currency,
decimal_places: 2,
is_active: true,
provider_config: None,
last_updated: Utc::now(),
}
}
pub fn set_exchange_rate(&mut self, rate: Decimal) {
if !self.is_base_currency {
self.exchange_rate_to_base = rate;
self.last_updated = Utc::now();
}
}
pub fn set_provider(&mut self, provider: ExchangeRateProvider) {
self.provider_config = Some(provider);
}
pub fn set_decimal_places(&mut self, places: u8) {
self.decimal_places = places;
}
pub fn format_amount(&self, amount: Decimal) -> String {
format!("{} {}",
amount.round_dp(self.decimal_places as u32),
self.symbol
)
}
}
impl Price {
pub fn new(
base_amount: Decimal,
base_currency: String,
display_currency: String,
conversion_rate: Decimal,
) -> Self {
let display_amount = base_amount * conversion_rate;
// Use proper currency symbol formatting - this will be updated by the currency service
Self {
base_amount,
base_currency: base_currency.clone(),
display_currency: display_currency.clone(),
display_amount,
formatted_display: format!("{} {}", display_amount.round_dp(2), display_currency),
conversion_rate,
conversion_timestamp: Utc::now(),
}
}
pub fn format_with_symbol(&self, symbol: &str) -> String {
format!("{} {}",
self.display_amount.round_dp(2),
symbol
)
}
pub fn update_formatted_display(&mut self, formatted: String) {
self.formatted_display = formatted;
}
}
impl MarketplaceCurrencyConfig {
pub fn new(base_currency: String) -> Self {
Self {
base_currency: base_currency.clone(),
supported_currencies: vec![base_currency.clone()],
default_display_currency: base_currency,
auto_update_rates: true,
update_interval_minutes: 60,
fallback_rates: HashMap::default(),
}
}
pub fn add_supported_currency(&mut self, currency_code: String, fallback_rate: Option<Decimal>) {
if !self.supported_currencies.contains(&currency_code) {
self.supported_currencies.push(currency_code.clone());
}
if let Some(rate) = fallback_rate {
self.fallback_rates.insert(currency_code, rate);
}
}
pub fn set_default_display_currency(&mut self, currency_code: String) {
if self.supported_currencies.contains(&currency_code) {
self.default_display_currency = currency_code;
}
}
}
impl ExchangeRateHistory {
pub fn new(
from_currency: String,
to_currency: String,
rate: Decimal,
provider: String,
) -> Self {
Self {
from_currency,
to_currency,
rate,
timestamp: Utc::now(),
provider,
}
}
}
/// User currency preferences
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserCurrencyPreference {
pub user_id: String,
pub preferred_currency: String,
pub updated_at: DateTime<Utc>,
}
impl UserCurrencyPreference {
pub fn new(user_id: String, preferred_currency: String) -> Self {
Self {
user_id,
preferred_currency,
updated_at: Utc::now(),
}
}
pub fn update_preference(&mut self, new_currency: String) {
self.preferred_currency = new_currency;
self.updated_at = Utc::now();
}
}

82
src/models/marketplace.rs Normal file
View File

@@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
/// Configuration for the marketplace
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceConfig {
pub marketplace: MarketplaceInfo,
pub product_types: Vec<ProductTypeConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceInfo {
pub name: String,
pub marketplace_type: String,
pub base_currency: String,
pub default_display_currency: String,
pub supported_languages: Vec<String>,
pub features: MarketplaceFeatures,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceFeatures {
pub reviews_enabled: bool,
pub wishlist_enabled: bool,
pub recommendations_enabled: bool,
pub multi_vendor: bool,
pub subscription_products: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductTypeConfig {
pub id: String,
pub name: String,
pub icon: String,
pub pricing_model: String,
}
impl Default for MarketplaceConfig {
fn default() -> Self {
Self {
marketplace: MarketplaceInfo {
name: "Project Mycelium".to_string(),
marketplace_type: "infrastructure".to_string(),
base_currency: "USD".to_string(),
default_display_currency: "USD".to_string(),
supported_languages: vec!["en".to_string()],
features: MarketplaceFeatures {
reviews_enabled: false,
wishlist_enabled: false,
recommendations_enabled: false,
multi_vendor: false,
subscription_products: false,
},
},
product_types: vec![
ProductTypeConfig {
id: "compute".to_string(),
name: "Compute Resources".to_string(),
icon: "cpu".to_string(),
pricing_model: "usage_based".to_string(),
},
ProductTypeConfig {
id: "hardware".to_string(),
name: "Hardware".to_string(),
icon: "server".to_string(),
pricing_model: "one_time".to_string(),
},
ProductTypeConfig {
id: "application".to_string(),
name: "Applications".to_string(),
icon: "apps".to_string(),
pricing_model: "recurring".to_string(),
},
ProductTypeConfig {
id: "service".to_string(),
name: "Services".to_string(),
icon: "tools".to_string(),
pricing_model: "one_time".to_string(),
},
],
}
}
}

148
src/models/messaging.rs Normal file
View File

@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageThread {
pub thread_id: String,
pub user_a_email: String,
pub user_b_email: String,
pub context_type: String, // service_booking, slice_rental, general
pub context_id: Option<String>,
pub subject: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_message_at: Option<DateTime<Utc>>,
pub user_a_unread_count: i32,
pub user_b_unread_count: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub message_id: String,
pub thread_id: String,
pub sender_email: String,
pub recipient_email: String,
pub content: String,
pub message_type: String, // text, system_notification, file_attachment
pub timestamp: DateTime<Utc>,
pub read_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateThreadRequest {
pub recipient_email: String,
pub context_type: String,
pub context_id: Option<String>,
pub subject: String,
}
#[derive(Debug, Deserialize)]
pub struct SendMessageRequest {
pub thread_id: String,
pub content: String,
pub message_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ThreadsResponse {
pub threads: Vec<ThreadWithLastMessage>,
pub unread_count: i32,
}
#[derive(Debug, Serialize)]
pub struct ThreadWithLastMessage {
pub thread_id: String,
pub recipient_email: String,
pub recipient_name: Option<String>,
pub subject: String,
pub context_type: String,
pub context_id: Option<String>,
pub last_message: Option<String>,
pub last_message_at: Option<DateTime<Utc>>,
pub unread_count: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct MessagesResponse {
pub messages: Vec<Message>,
}
impl MessageThread {
pub fn new(
user_a_email: String,
user_b_email: String,
context_type: String,
context_id: Option<String>,
subject: String,
) -> Self {
let now = Utc::now();
Self {
thread_id: Uuid::new_v4().to_string(),
user_a_email,
user_b_email,
context_type,
context_id,
subject,
created_at: now,
updated_at: now,
last_message_at: None,
user_a_unread_count: 0,
user_b_unread_count: 0,
}
}
pub fn get_recipient_email(&self, current_user_email: &str) -> &str {
if self.user_a_email == current_user_email {
&self.user_b_email
} else {
&self.user_a_email
}
}
pub fn get_unread_count(&self, current_user_email: &str) -> i32 {
if self.user_a_email == current_user_email {
self.user_a_unread_count
} else {
self.user_b_unread_count
}
}
pub fn increment_unread_count(&mut self, recipient_email: &str) {
if self.user_a_email == recipient_email {
self.user_a_unread_count += 1;
} else if self.user_b_email == recipient_email {
self.user_b_unread_count += 1;
}
}
pub fn reset_unread_count(&mut self, user_email: &str) {
if self.user_a_email == user_email {
self.user_a_unread_count = 0;
} else if self.user_b_email == user_email {
self.user_b_unread_count = 0;
}
}
}
impl Message {
pub fn new(
thread_id: String,
sender_email: String,
recipient_email: String,
content: String,
message_type: String,
) -> Self {
Self {
message_id: Uuid::new_v4().to_string(),
thread_id,
sender_email,
recipient_email,
content,
message_type,
timestamp: Utc::now(),
read_at: None,
}
}
}

10
src/models/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
// Export models
pub mod user;
pub mod messaging;
pub mod product;
pub mod currency;
pub mod order;
pub mod pool;
pub mod builders;
pub mod marketplace;
pub mod ssh_key;

362
src/models/order.rs Normal file
View File

@@ -0,0 +1,362 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
pub id: String,
pub user_id: String,
pub items: Vec<OrderItem>,
pub subtotal_base: Decimal, // In base currency
pub total_base: Decimal, // In base currency
pub base_currency: String,
pub currency_used: String, // Currency user paid in
pub currency_total: Decimal, // Amount in user's currency
pub conversion_rate: Decimal, // Rate used for conversion
pub status: OrderStatus,
pub payment_method: String,
pub payment_details: Option<PaymentDetails>,
pub billing_address: Option<Address>,
pub shipping_address: Option<Address>,
pub notes: Option<String>,
pub purchase_type: PurchaseType, // NEW: Distinguish cart vs instant purchases
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderItem {
pub product_id: String,
pub product_name: String,
pub product_category: String,
pub quantity: u32,
pub unit_price_base: Decimal, // In base currency
pub total_price_base: Decimal, // In base currency
pub specifications: HashMap<String, serde_json::Value>,
pub provider_id: String,
pub provider_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OrderStatus {
Pending,
Confirmed,
Processing,
Deployed,
Completed,
Cancelled,
Refunded,
Failed,
}
/// Purchase type to distinguish between cart-based and instant purchases
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PurchaseType {
Cart, // Traditional cart-based purchase flow
Instant, // OpenRouter-style instant purchase
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentDetails {
pub payment_id: String,
pub payment_method: PaymentMethod,
pub transaction_id: Option<String>,
pub payment_status: PaymentStatus,
pub payment_timestamp: Option<DateTime<Utc>>,
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PaymentMethod {
CreditCard {
last_four: String,
card_type: String,
},
BankTransfer {
bank_name: String,
account_last_four: String,
},
Cryptocurrency {
currency: String,
wallet_address: String,
},
Token {
token_type: String,
wallet_address: String,
},
Mock {
method_name: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PaymentStatus {
Pending,
Processing,
Completed,
Failed,
Cancelled,
Refunded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Address {
pub street: String,
pub city: String,
pub state: Option<String>,
pub postal_code: String,
pub country: String,
pub company: Option<String>,
}
/// Shopping Cart Models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CartItem {
pub product_id: String,
pub quantity: u32,
pub selected_specifications: HashMap<String, serde_json::Value>,
pub added_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cart {
pub user_id: String,
pub items: Vec<CartItem>,
pub session_id: Option<String>, // For guest users
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Order summary for display purposes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderSummary {
pub subtotal: Decimal,
pub tax: Decimal,
pub shipping: Decimal,
pub discount: Decimal,
pub total: Decimal,
pub currency: String,
pub item_count: u32,
}
impl Order {
pub fn new(
id: String,
user_id: String,
base_currency: String,
currency_used: String,
conversion_rate: Decimal,
) -> Self {
let now = Utc::now();
Self {
id,
user_id,
items: Vec::default(),
subtotal_base: Decimal::from(0),
total_base: Decimal::from(0),
base_currency,
currency_used,
currency_total: Decimal::from(0),
conversion_rate,
status: OrderStatus::Pending,
payment_method: String::new(),
payment_details: None,
billing_address: None,
shipping_address: None,
notes: None,
purchase_type: PurchaseType::Cart,
created_at: now,
updated_at: now,
}
}
pub fn add_item(&mut self, item: OrderItem) {
self.items.push(item);
self.calculate_totals();
}
pub fn calculate_totals(&mut self) {
self.subtotal_base = self.items.iter()
.map(|item| item.total_price_base)
.sum();
self.total_base = self.subtotal_base; // Add taxes, fees, etc. here
self.currency_total = self.total_base * self.conversion_rate;
self.updated_at = Utc::now();
}
pub fn update_status(&mut self, status: OrderStatus) {
self.status = status;
self.updated_at = Utc::now();
}
pub fn set_payment_details(&mut self, payment_details: PaymentDetails) {
self.payment_details = Some(payment_details);
self.updated_at = Utc::now();
}
pub fn get_item_count(&self) -> u32 {
self.items.iter().map(|item| item.quantity).sum()
}
}
impl OrderItem {
pub fn new(
product_id: String,
product_name: String,
product_category: String,
quantity: u32,
unit_price_base: Decimal,
provider_id: String,
provider_name: String,
) -> Self {
Self {
product_id,
product_name,
product_category,
quantity,
unit_price_base,
total_price_base: unit_price_base * Decimal::from(quantity),
specifications: HashMap::default(),
provider_id,
provider_name,
}
}
pub fn add_specification(&mut self, key: String, value: serde_json::Value) {
self.specifications.insert(key, value);
}
pub fn update_quantity(&mut self, quantity: u32) {
self.quantity = quantity;
self.total_price_base = self.unit_price_base * Decimal::from(quantity);
}
}
impl Cart {
pub fn new(user_id: String) -> Self {
let now = Utc::now();
Self {
user_id,
items: Vec::default(),
session_id: None,
created_at: now,
updated_at: now,
}
}
pub fn new_guest(session_id: String) -> Self {
let now = Utc::now();
Self {
user_id: String::new(),
items: Vec::default(),
session_id: Some(session_id),
created_at: now,
updated_at: now,
}
}
pub fn add_item(&mut self, item: CartItem) {
// Check if item already exists and update quantity
if let Some(existing_item) = self.items.iter_mut()
.find(|i| i.product_id == item.product_id && i.selected_specifications == item.selected_specifications) {
existing_item.quantity += item.quantity;
existing_item.updated_at = Utc::now();
} else {
self.items.push(item);
}
self.updated_at = Utc::now();
}
pub fn remove_item(&mut self, product_id: &str) -> bool {
let initial_len = self.items.len();
self.items.retain(|item| item.product_id != product_id);
if self.items.len() != initial_len {
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn update_item_quantity(&mut self, product_id: &str, quantity: u32) -> bool {
if let Some(item) = self.items.iter_mut().find(|i| i.product_id == product_id) {
if quantity == 0 {
return self.remove_item(product_id);
}
item.quantity = quantity;
item.updated_at = Utc::now();
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn clear(&mut self) {
self.items.clear();
self.updated_at = Utc::now();
}
pub fn get_total_items(&self) -> u32 {
self.items.iter().map(|item| item.quantity).sum()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl CartItem {
pub fn new(product_id: String, quantity: u32) -> Self {
let now = Utc::now();
Self {
product_id,
quantity,
selected_specifications: HashMap::default(),
added_at: now,
updated_at: now,
}
}
pub fn with_specifications(
product_id: String,
quantity: u32,
specifications: HashMap<String, serde_json::Value>,
) -> Self {
let now = Utc::now();
Self {
product_id,
quantity,
selected_specifications: specifications,
added_at: now,
updated_at: now,
}
}
}
impl PaymentDetails {
pub fn new(payment_id: String, payment_method: PaymentMethod) -> Self {
Self {
payment_id,
payment_method,
transaction_id: None,
payment_status: PaymentStatus::Pending,
payment_timestamp: None,
failure_reason: None,
}
}
pub fn mark_completed(&mut self, transaction_id: String) {
self.transaction_id = Some(transaction_id);
self.payment_status = PaymentStatus::Completed;
self.payment_timestamp = Some(Utc::now());
}
pub fn mark_failed(&mut self, reason: String) {
self.payment_status = PaymentStatus::Failed;
self.failure_reason = Some(reason);
self.payment_timestamp = Some(Utc::now());
}
}

95
src/models/pool.rs Normal file
View File

@@ -0,0 +1,95 @@
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiquidityPool {
pub id: String,
pub name: String,
pub token_a: String,
pub token_b: String,
pub reserve_a: Decimal,
pub reserve_b: Decimal,
pub exchange_rate: Decimal,
pub liquidity: Decimal,
pub volume_24h: Decimal,
pub fee_percentage: Decimal,
pub status: PoolStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PoolStatus {
Active,
Paused,
Maintenance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExchangeRequest {
pub pool_id: String,
pub from_token: String,
pub to_token: String,
pub amount: Decimal,
pub min_receive: Option<Decimal>,
pub slippage_tolerance: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExchangeResponse {
pub success: bool,
pub message: String,
pub transaction_id: Option<String>,
pub from_amount: Option<Decimal>,
pub to_amount: Option<Decimal>,
pub exchange_rate: Option<Decimal>,
pub fee: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StakeRequest {
pub pool_id: String,
pub amount: Decimal,
pub duration_months: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StakePosition {
pub id: String,
pub user_id: String,
pub amount: Decimal,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub discount_percentage: Decimal,
pub reputation_bonus: i32,
pub status: StakeStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StakeStatus {
Active,
Completed,
Withdrawn,
}
/// Pool analytics data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolAnalytics {
pub price_history: Vec<PricePoint>,
pub volume_history: Vec<VolumePoint>,
pub liquidity_distribution: HashMap<String, Decimal>,
pub staking_distribution: HashMap<String, i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricePoint {
pub timestamp: DateTime<Utc>,
pub price: Decimal,
pub volume: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumePoint {
pub date: String,
pub volume: Decimal,
}

595
src/models/product.rs Normal file
View File

@@ -0,0 +1,595 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
/// Generic product structure that can represent any marketplace item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub id: String,
pub name: String,
pub category_id: String, // References ProductCategory config
pub description: String,
pub base_price: Decimal, // In marketplace base currency
pub base_currency: String, // Configurable base currency
pub attributes: HashMap<String, ProductAttribute>, // Generic attributes
pub provider_id: String,
pub provider_name: String,
pub availability: ProductAvailability,
pub metadata: ProductMetadata, // Extensible metadata
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Configurable product categories
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductCategory {
pub id: String,
pub name: String,
pub display_name: String,
pub description: String,
pub attribute_schema: Vec<AttributeDefinition>, // Defines allowed attributes
pub parent_category: Option<String>,
pub is_active: bool,
}
/// Generic attribute system for any product type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductAttribute {
pub key: String,
pub value: serde_json::Value,
pub attribute_type: AttributeType,
pub is_searchable: bool,
pub is_filterable: bool,
pub display_order: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AttributeType {
Text,
Number,
SliceConfiguration,
Boolean,
Select(Vec<String>), // Predefined options
MultiSelect(Vec<String>),
Range { min: f64, max: f64 },
Custom(String), // For marketplace-specific types
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributeDefinition {
pub key: String,
pub name: String,
pub attribute_type: AttributeType,
pub is_required: bool,
pub is_searchable: bool,
pub is_filterable: bool,
pub validation_rules: Vec<ValidationRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationRule {
MinLength(usize),
MaxLength(usize),
MinValue(f64),
MaxValue(f64),
Pattern(String),
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProductAvailability {
Available,
Limited,
Unavailable,
PreOrder,
Custom(String), // For marketplace-specific availability states
}
impl Default for ProductAvailability {
fn default() -> Self {
ProductAvailability::Available
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProductVisibility {
Public,
Private,
Draft,
Archived,
}
impl Default for ProductVisibility {
fn default() -> Self {
ProductVisibility::Public
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProductMetadata {
pub tags: Vec<String>,
pub location: Option<String>,
pub rating: Option<f32>,
pub review_count: u32,
pub featured: bool,
pub last_updated: chrono::DateTime<chrono::Utc>,
pub visibility: ProductVisibility,
pub seo_keywords: Vec<String>,
pub custom_fields: HashMap<String, serde_json::Value>,
}
/// Support for different pricing models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PricingModel {
OneTime, // Single purchase
Recurring { interval: String }, // Subscription
UsageBased { unit: String }, // Pay per use
Tiered(Vec<PriceTier>), // Volume discounts
Custom(String), // Marketplace-specific
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceTier {
pub min_quantity: u32,
pub max_quantity: Option<u32>,
pub price_per_unit: Decimal,
pub discount_percentage: Option<f32>,
}
impl Product {
pub fn new(
id: String,
name: String,
category_id: String,
description: String,
base_price: Decimal,
base_currency: String,
provider_id: String,
provider_name: String,
) -> Self {
let now = Utc::now();
Self {
id,
name,
category_id,
description,
base_price,
base_currency,
attributes: HashMap::default(),
provider_id,
provider_name,
availability: ProductAvailability::Available,
metadata: ProductMetadata {
tags: Vec::default(),
location: None,
rating: None,
review_count: 0,
featured: false,
last_updated: chrono::Utc::now(),
visibility: ProductVisibility::Public,
seo_keywords: Vec::new(),
custom_fields: HashMap::default(),
},
created_at: now,
updated_at: now,
}
}
pub fn add_attribute(&mut self, key: String, value: serde_json::Value, attribute_type: AttributeType) {
let attribute = ProductAttribute {
key: key.clone(),
value,
attribute_type,
is_searchable: true,
is_filterable: true,
display_order: None,
};
self.attributes.insert(key, attribute);
self.updated_at = Utc::now();
}
pub fn set_featured(&mut self, featured: bool) {
self.metadata.featured = featured;
self.updated_at = Utc::now();
}
pub fn add_tag(&mut self, tag: String) {
if !self.metadata.tags.contains(&tag) {
self.metadata.tags.push(tag);
self.updated_at = Utc::now();
}
}
pub fn set_rating(&mut self, rating: f32, review_count: u32) {
self.metadata.rating = Some(rating);
self.metadata.review_count = review_count;
self.updated_at = Utc::now();
}
}
impl ProductCategory {
pub fn new(id: String, name: String, display_name: String, description: String) -> Self {
Self {
id,
name,
display_name,
description,
attribute_schema: Vec::default(),
parent_category: None,
is_active: true,
}
}
/// Add attribute definition to category schema
pub fn add_attribute_definition(&mut self, definition: AttributeDefinition) {
self.attribute_schema.push(definition);
}
}
// =============================================================================
// SLICE-SPECIFIC PRODUCT HELPERS
// =============================================================================
/// Slice configuration data structure for product attributes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceConfiguration {
pub cpu_cores: i32,
pub memory_gb: i32,
pub storage_gb: i32,
pub bandwidth_mbps: i32,
pub min_uptime_sla: f32,
pub public_ips: i32,
pub node_id: Option<String>,
pub slice_type: SliceType,
#[serde(default)]
pub pricing: SlicePricing,
}
/// Enhanced pricing structure for slices with multiple time periods
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlicePricing {
pub hourly: Decimal,
pub daily: Decimal,
pub monthly: Decimal,
pub yearly: Decimal,
}
impl Default for SlicePricing {
fn default() -> Self {
Self {
hourly: Decimal::ZERO,
daily: Decimal::ZERO,
monthly: Decimal::ZERO,
yearly: Decimal::ZERO,
}
}
}
impl SlicePricing {
/// Create pricing from hourly rate with automatic calculation
pub fn from_hourly(hourly_rate: Decimal, daily_discount: f32, monthly_discount: f32, yearly_discount: f32) -> Self {
let base_daily = hourly_rate * Decimal::from(24);
let base_monthly = hourly_rate * Decimal::from(24 * 30);
let base_yearly = hourly_rate * Decimal::from(24 * 365);
Self {
hourly: hourly_rate,
daily: base_daily * Decimal::try_from(1.0 - daily_discount / 100.0).unwrap_or(Decimal::ONE),
monthly: base_monthly * Decimal::try_from(1.0 - monthly_discount / 100.0).unwrap_or(Decimal::ONE),
yearly: base_yearly * Decimal::try_from(1.0 - yearly_discount / 100.0).unwrap_or(Decimal::ONE),
}
}
/// Calculate savings compared to hourly rate
pub fn calculate_savings(&self) -> (Decimal, Decimal, Decimal) {
let hourly_equivalent_daily = self.hourly * Decimal::from(24);
let hourly_equivalent_monthly = self.hourly * Decimal::from(24 * 30);
let hourly_equivalent_yearly = self.hourly * Decimal::from(24 * 365);
let daily_savings = hourly_equivalent_daily - self.daily;
let monthly_savings = hourly_equivalent_monthly - self.monthly;
let yearly_savings = hourly_equivalent_yearly - self.yearly;
(daily_savings, monthly_savings, yearly_savings)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SliceType {
Basic,
Standard,
Premium,
Custom,
}
impl Product {
/// Create a slice product from farmer configuration
pub fn create_slice_product(
farmer_id: String,
farmer_name: String,
slice_name: String,
slice_config: SliceConfiguration,
price_per_hour: Decimal,
) -> Self {
let id = format!("slice_{}", uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string());
let mut product = Self::new(
id,
slice_name,
"compute_slices".to_string(),
format!("Compute slice with {} vCPU, {}GB RAM, {}GB storage",
slice_config.cpu_cores, slice_config.memory_gb, slice_config.storage_gb),
price_per_hour,
"USD".to_string(),
farmer_id,
farmer_name,
);
// Add slice-specific attributes
product.add_attribute(
"cpu_cores".to_string(),
serde_json::Value::Number(serde_json::Number::from(slice_config.cpu_cores)),
AttributeType::Number,
);
product.add_attribute(
"memory_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(slice_config.memory_gb)),
AttributeType::Number,
);
product.add_attribute(
"storage_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(slice_config.storage_gb)),
AttributeType::Number,
);
product.add_attribute(
"bandwidth_mbps".to_string(),
serde_json::Value::Number(serde_json::Number::from(slice_config.bandwidth_mbps)),
AttributeType::Number,
);
product.add_attribute(
"min_uptime_sla".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(slice_config.min_uptime_sla as f64).unwrap()),
AttributeType::Number,
);
product.add_attribute(
"public_ips".to_string(),
serde_json::Value::Number(serde_json::Number::from(slice_config.public_ips)),
AttributeType::Number,
);
if let Some(ref node_id) = slice_config.node_id {
product.add_attribute(
"node_id".to_string(),
serde_json::Value::String(node_id.clone()),
AttributeType::Text,
);
}
product.add_attribute(
"slice_type".to_string(),
serde_json::Value::String(format!("{:?}", slice_config.slice_type)),
AttributeType::Text,
);
// Add slice configuration as a complex attribute
product.add_attribute(
"slice_configuration".to_string(),
serde_json::to_value(&slice_config).unwrap(),
AttributeType::SliceConfiguration,
);
// Add relevant tags
product.add_tag("compute".to_string());
product.add_tag("slice".to_string());
product.add_tag(format!("{:?}", slice_config.slice_type).to_lowercase());
product
}
/// Check if this product is a slice
pub fn is_slice(&self) -> bool {
self.category_id == "compute_slices" ||
self.attributes.contains_key("slice_configuration")
}
/// Get slice configuration from product attributes
pub fn get_slice_configuration(&self) -> Option<SliceConfiguration> {
self.attributes.get("slice_configuration")
.and_then(|attr| serde_json::from_value(attr.value.clone()).ok())
}
/// Update slice configuration
pub fn update_slice_configuration(&mut self, config: SliceConfiguration) {
if self.is_slice() {
self.add_attribute(
"slice_configuration".to_string(),
serde_json::to_value(&config).unwrap(),
AttributeType::SliceConfiguration,
);
// Update individual attributes for searchability
self.add_attribute(
"cpu_cores".to_string(),
serde_json::Value::Number(serde_json::Number::from(config.cpu_cores)),
AttributeType::Number,
);
self.add_attribute(
"memory_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(config.memory_gb)),
AttributeType::Number,
);
self.add_attribute(
"storage_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(config.storage_gb)),
AttributeType::Number,
);
}
}
/// Check if slice fits within node capacity
pub fn slice_fits_in_node(&self, node_capacity: &crate::models::user::NodeCapacity) -> bool {
if let Some(config) = self.get_slice_configuration() {
config.cpu_cores <= node_capacity.cpu_cores &&
config.memory_gb <= node_capacity.memory_gb &&
config.storage_gb <= node_capacity.storage_gb &&
config.bandwidth_mbps <= node_capacity.bandwidth_mbps
} else {
false
}
}
/// Create a full node product from a FarmNode
pub fn create_full_node_product(
node: &crate::models::user::FarmNode,
farmer_email: &str,
farmer_name: &str,
) -> Self {
let mut product = Product {
id: format!("fullnode_{}", node.id),
name: format!("Full Node: {}", node.name),
category_id: "3nodes".to_string(),
description: format!(
"Exclusive access to {} with {} CPU cores, {}GB RAM, {}GB storage in {}",
node.name, node.capacity.cpu_cores, node.capacity.memory_gb,
node.capacity.storage_gb, node.location
),
base_price: node.rental_options
.as_ref()
.and_then(|opts| opts.get("full_node_pricing"))
.and_then(|pricing| pricing.get("monthly"))
.and_then(|monthly| monthly.as_str())
.and_then(|price_str| rust_decimal::Decimal::from_str(price_str).ok())
.unwrap_or_else(|| Decimal::from(200)), // Default price
base_currency: "USD".to_string(),
attributes: HashMap::new(),
provider_id: farmer_email.to_string(),
provider_name: farmer_name.to_string(),
availability: match node.availability_status {
crate::models::user::NodeAvailabilityStatus::Available => ProductAvailability::Available,
crate::models::user::NodeAvailabilityStatus::PartiallyRented => ProductAvailability::Limited,
_ => ProductAvailability::Unavailable,
},
metadata: ProductMetadata {
tags: vec!["full-node".to_string(), "exclusive".to_string(), node.region.clone()],
location: Some(node.location.clone()),
rating: None,
review_count: 0,
featured: false,
last_updated: chrono::Utc::now(),
visibility: ProductVisibility::Public,
seo_keywords: Vec::new(),
custom_fields: HashMap::new(),
},
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// Add node-specific attributes
product.add_attribute(
"node_id".to_string(),
serde_json::Value::String(node.id.clone()),
AttributeType::Text,
);
product.add_attribute(
"rental_type".to_string(),
serde_json::Value::String("full_node".to_string()),
AttributeType::Text,
);
product.add_attribute(
"cpu_cores".to_string(),
serde_json::Value::Number(serde_json::Number::from(node.capacity.cpu_cores)),
AttributeType::Number,
);
product.add_attribute(
"memory_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(node.capacity.memory_gb)),
AttributeType::Number,
);
product.add_attribute(
"storage_gb".to_string(),
serde_json::Value::Number(serde_json::Number::from(node.capacity.storage_gb)),
AttributeType::Number,
);
product.add_attribute(
"bandwidth_mbps".to_string(),
serde_json::Value::Number(serde_json::Number::from(node.capacity.bandwidth_mbps)),
AttributeType::Number,
);
product.add_attribute(
"location".to_string(),
serde_json::Value::String(node.location.clone()),
AttributeType::Text,
);
product.add_attribute(
"uptime_percentage".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(node.uptime_percentage as f64).unwrap_or_else(|| serde_json::Number::from(0))),
AttributeType::Number,
);
product.add_attribute(
"health_score".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(node.health_score as f64).unwrap_or_else(|| serde_json::Number::from(0))),
AttributeType::Number,
);
product
}
/// Check if this product represents a full node
pub fn is_full_node(&self) -> bool {
self.attributes.get("rental_type")
.and_then(|attr| attr.value.as_str())
.map(|s| s == "full_node")
.unwrap_or(false)
}
/// Get the node ID if this is a node product
pub fn get_node_id(&self) -> Option<String> {
self.attributes.get("node_id")
.and_then(|attr| attr.value.as_str())
.map(|s| s.to_string())
}
}
impl ProductCategory {
pub fn set_parent_category(&mut self, parent_id: String) {
self.parent_category = Some(parent_id);
}
}
impl AttributeDefinition {
pub fn new(
key: String,
name: String,
attribute_type: AttributeType,
is_required: bool,
) -> Self {
Self {
key,
name,
attribute_type,
is_required,
is_searchable: true,
is_filterable: true,
validation_rules: Vec::default(),
}
}
pub fn add_validation_rule(&mut self, rule: ValidationRule) {
self.validation_rules.push(rule);
}
}

143
src/models/ssh_key.rs Normal file
View File

@@ -0,0 +1,143 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
/// SSH key types supported by the marketplace
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SSHKeyType {
#[serde(rename = "ssh-ed25519")]
Ed25519,
#[serde(rename = "ssh-rsa")]
Rsa,
#[serde(rename = "ecdsa-sha2-nistp256")]
EcdsaP256,
#[serde(rename = "ecdsa-sha2-nistp384")]
EcdsaP384,
#[serde(rename = "ecdsa-sha2-nistp521")]
EcdsaP521,
}
impl fmt::Display for SSHKeyType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SSHKeyType::Ed25519 => write!(f, "Ed25519"),
SSHKeyType::Rsa => write!(f, "RSA"),
SSHKeyType::EcdsaP256 => write!(f, "ECDSA P-256"),
SSHKeyType::EcdsaP384 => write!(f, "ECDSA P-384"),
SSHKeyType::EcdsaP521 => write!(f, "ECDSA P-521"),
}
}
}
/// SSH key stored in user's persistent data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SSHKey {
/// Unique identifier for this SSH key
pub id: String,
/// User-friendly name for the key (e.g., "MacBook Pro", "Work Laptop")
pub name: String,
/// The SSH public key in OpenSSH format
pub public_key: String,
/// SHA256 fingerprint of the public key for identification
pub fingerprint: String,
/// Type of SSH key (Ed25519, RSA, ECDSA, etc.)
pub key_type: SSHKeyType,
/// When this key was added to the account
pub created_at: DateTime<Utc>,
/// Last time this key was used for deployment/access (optional)
pub last_used: Option<DateTime<Utc>>,
/// Whether this is the default key for new deployments
pub is_default: bool,
/// Optional comment/description for the key
pub comment: Option<String>,
}
impl SSHKey {
/// Check if this SSH key matches another by public key content
pub fn matches_public_key(&self, other_public_key: &str) -> bool {
self.public_key.trim() == other_public_key.trim()
}
/// Get the key size in bits (for display purposes)
pub fn key_size(&self) -> Option<u32> {
match self.key_type {
SSHKeyType::Ed25519 => Some(256), // Ed25519 uses 256-bit keys
SSHKeyType::EcdsaP256 => Some(256),
SSHKeyType::EcdsaP384 => Some(384),
SSHKeyType::EcdsaP521 => Some(521),
SSHKeyType::Rsa => {
// For RSA, we'd need to parse the key to get actual size
// For now, return None and let validation service handle this
None
}
}
}
/// Get security level description for this key type
pub fn security_level(&self) -> &'static str {
match self.key_type {
SSHKeyType::Ed25519 => "High",
SSHKeyType::EcdsaP256 => "High",
SSHKeyType::EcdsaP384 => "Very High",
SSHKeyType::EcdsaP521 => "Very High",
SSHKeyType::Rsa => "Medium to High", // Depends on key size
}
}
/// Check if this key type is considered modern/recommended
pub fn is_modern_key_type(&self) -> bool {
matches!(self.key_type, SSHKeyType::Ed25519 | SSHKeyType::EcdsaP256 | SSHKeyType::EcdsaP384)
}
}
impl Default for SSHKey {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: String::new(),
public_key: String::new(),
fingerprint: String::new(),
key_type: SSHKeyType::Ed25519,
created_at: Utc::now(),
last_used: None,
is_default: false,
comment: None,
}
}
}
/// SSH key validation error types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SSHKeyValidationError {
InvalidFormat,
UnsupportedKeyType,
KeyTooShort,
InvalidEncoding,
DuplicateKey,
InvalidName,
EmptyKey,
}
impl fmt::Display for SSHKeyValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SSHKeyValidationError::InvalidFormat => write!(f, "Invalid SSH key format. Please provide a valid OpenSSH public key."),
SSHKeyValidationError::UnsupportedKeyType => write!(f, "Unsupported key type. Please use Ed25519, ECDSA, or RSA keys."),
SSHKeyValidationError::KeyTooShort => write!(f, "RSA keys must be at least 2048 bits. Please use a stronger key."),
SSHKeyValidationError::InvalidEncoding => write!(f, "Invalid key encoding. Please check your key format."),
SSHKeyValidationError::DuplicateKey => write!(f, "This SSH key is already added to your account."),
SSHKeyValidationError::InvalidName => write!(f, "Invalid key name. Please use alphanumeric characters and spaces only."),
SSHKeyValidationError::EmptyKey => write!(f, "SSH key cannot be empty."),
}
}
}
impl std::error::Error for SSHKeyValidationError {}

1788
src/models/user.rs Normal file

File diff suppressed because it is too large Load Diff

335
src/routes/mod.rs Normal file
View File

@@ -0,0 +1,335 @@
use crate::config::oauth::GiteaOAuthConfig;
use crate::controllers::auth::AuthController;
use crate::controllers::currency::CurrencyController;
use crate::controllers::dashboard::DashboardController;
use crate::controllers::debug::DebugController;
use crate::controllers::docs::DocsController;
use crate::controllers::gitea_auth::GiteaAuthController;
use crate::controllers::home::HomeController;
use crate::controllers::marketplace::MarketplaceController;
use crate::controllers::messaging::MessagingController;
use crate::controllers::order::OrderController;
use crate::controllers::pool::PoolController;
use crate::controllers::product::ProductController;
use crate::controllers::public::PublicController;
use crate::controllers::rental::RentalController;
use crate::controllers::wallet::WalletController;
use crate::middleware::JwtAuth;
use crate::SESSION_KEY;
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::web;
use std::env;
/// Configures all application routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Configure session middleware with the consistent key
let session_middleware =
SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
.cookie_secure(false) // Set to true in production with HTTPS
.cookie_http_only(true)
.cookie_name("threefold_marketplace_session".to_string())
.cookie_path("/".to_string())
.cookie_same_site(actix_web::cookie::SameSite::Lax) // Important for OAuth redirects
.session_lifecycle(
actix_session::config::PersistentSession::default()
.session_ttl(actix_web::cookie::time::Duration::hours(2)),
)
.build();
// Build the main scope with common routes
let mut main_scope = web::scope("")
.wrap(session_middleware) // Wrap with session middleware
// Home routes
.route("/", web::get().to(HomeController::index))
.route("/about", web::get().to(HomeController::about))
.route("/contact", web::get().to(HomeController::contact))
// Marketplace routes
.route("/marketplace", web::get().to(MarketplaceController::dashboard))
.route("/marketplace/compute", web::get().to(MarketplaceController::compute_resources))
.route("/marketplace/3nodes", web::get().to(MarketplaceController::three_nodes))
.route("/marketplace/gateways", web::get().to(MarketplaceController::gateways))
.route("/marketplace/applications", web::get().to(MarketplaceController::applications))
.route("/marketplace/services", web::get().to(MarketplaceController::services))
.route("/marketplace/statistics", web::get().to(MarketplaceController::statistics))
// Slice rental routes
.route("/marketplace/slice/rent/{farmer_email}/{node_id}/{combination_id}", web::get().to(MarketplaceController::show_slice_rental_form))
.route("/marketplace/slice/rent", web::post().to(MarketplaceController::process_slice_rental))
// .route("/marketplace/rent-slice", web::post().to(MarketplaceController::rent_slice)) // Legacy route [DISABLED]
// Product routes
.route("/products", web::get().to(ProductController::list_products))
.route("/products/{id}", web::get().to(ProductController::get_product_details))
.route("/cart", web::get().to(OrderController::view_cart))
.route("/checkout", web::get().to(OrderController::checkout))
.route("/orders", web::get().to(OrderController::view_order_history))
.route("/orders/{id}", web::get().to(OrderController::get_order_details))
.route("/orders/{id}/invoice", web::get().to(OrderController::get_order_invoice))
.route("/orders/{id}/confirmation", web::get().to(OrderController::get_order_confirmation))
// API routes
.service(
web::scope("/api")
// Product API
.route("/products", web::get().to(ProductController::list_products))
.route("/products/{id}", web::get().to(ProductController::get_product_details))
.route("/products/search", web::get().to(ProductController::search_products))
.route("/products/categories", web::get().to(ProductController::get_categories))
.route("/products/featured", web::get().to(ProductController::get_featured_products))
// Debug API
.route("/debug/products", web::get().to(OrderController::debug_products))
// Cart API
.route("/cart", web::get().to(OrderController::get_cart_json))
.route("/cart/add", web::post().to(OrderController::add_to_cart))
.route("/cart/item/{id}", web::put().to(OrderController::update_cart_item))
.route("/cart/item/{id}", web::delete().to(OrderController::remove_from_cart))
.route("/cart", web::delete().to(OrderController::clear_cart))
// Order API
.route("/orders", web::post().to(OrderController::place_order))
.route("/orders", web::get().to(OrderController::get_orders_json))
.route("/orders/{id}", web::get().to(OrderController::get_order_details))
// Currency API
.route("/currencies", web::get().to(CurrencyController::get_supported_currencies))
.route("/currencies/rates", web::get().to(CurrencyController::get_exchange_rates))
.route("/currencies/convert", web::post().to(CurrencyController::convert_price))
.route("/currencies/widget", web::get().to(CurrencyController::get_currency_widget_data))
.route("/user/currency", web::get().to(CurrencyController::get_user_currency_preference))
.route("/user/currency", web::post().to(CurrencyController::set_user_currency_preference))
// Auth API routes
.route("/auth/status", web::get().to(AuthController::auth_status))
// Dashboard API routes
.route("/dashboard/user-data", web::get().to(DashboardController::user_data_api))
.route("/dashboard/user-dashboard-data", web::get().to(DashboardController::user_dashboard_data_api))
// User dashboard API routes
.route("/dashboard/user/activities", web::post().to(DashboardController::add_user_activity))
.route("/dashboard/user/preferences", web::get().to(DashboardController::get_user_preferences))
.route("/dashboard/user/preferences", web::put().to(DashboardController::update_user_preferences))
.route("/dashboard/user/service-bookings", web::get().to(DashboardController::get_user_service_bookings_api))
// Slice rental management API routes
.route("/dashboard/slice-rentals", web::get().to(DashboardController::get_user_slice_rentals))
.route("/dashboard/slice-rentals/{id}/manage", web::post().to(DashboardController::manage_slice_rental_deployment))
.route("/dashboard/slice-rentals/{id}", web::delete().to(DashboardController::cancel_slice_rental))
.route("/dashboard/user/slice-rentals/{id}", web::post().to(DashboardController::manage_slice_rental))
.route("/dashboard/farmer-data", web::get().to(DashboardController::farmer_data_api))
.route("/dashboard/app-provider-data", web::get().to(DashboardController::app_provider_data_api))
.route("/dashboard/slice-products", web::get().to(DashboardController::get_slice_products))
.route("/dashboard/slice-products", web::post().to(DashboardController::create_slice_product))
.route("/dashboard/slice-products/{id}", web::delete().to(DashboardController::delete_slice_product))
// Enhanced slice management routes
.route("/dashboard/slice-details/{id}", web::get().to(DashboardController::get_slice_details))
.route("/dashboard/slice-configuration/{id}", web::put().to(DashboardController::update_slice_configuration))
.route("/dashboard/service-provider-data", web::get().to(DashboardController::service_provider_data_api))
// Farmer management API routes
.route("/dashboard/farm-nodes", web::post().to(DashboardController::add_farm_node))
.route("/dashboard/farm-nodes-enhanced", web::post().to(DashboardController::add_farm_node_enhanced))
.route("/dashboard/farm-nodes/{id}", web::get().to(DashboardController::get_node_details))
.route("/dashboard/farm-nodes/{id}", web::put().to(DashboardController::update_node_comprehensive))
.route("/dashboard/farm-nodes/{id}/status", web::put().to(DashboardController::update_node_status))
// Farmer slice management API routes
.route("/dashboard/farmer/slice-calculations/refresh", web::post().to(DashboardController::refresh_slice_calculations))
.route("/dashboard/farmer/grid-sync", web::post().to(DashboardController::sync_with_grid))
.route("/dashboard/farmer/nodes/{id}/slices", web::get().to(DashboardController::get_node_slices))
.route("/dashboard/farmer/slice-statistics", web::get().to(DashboardController::get_slice_statistics))
.route("/dashboard/farm-nodes/{id}", web::delete().to(DashboardController::delete_node))
.route("/dashboard/farm-nodes/{id}/configuration", web::put().to(DashboardController::update_node_configuration))
.route("/dashboard/default-slice-formats", web::get().to(DashboardController::get_default_slice_formats))
.route("/dashboard/default-slice-details/{id}", web::get().to(DashboardController::get_default_slice_details))
.route("/dashboard/default-slice-customization/{id}", web::put().to(DashboardController::save_default_slice_customization))
// Grid node management API routes
.route("/dashboard/grid-nodes/validate", web::post().to(DashboardController::validate_grid_nodes))
.route("/dashboard/grid-nodes/add", web::post().to(DashboardController::add_grid_nodes))
// Automatic slice management API routes
.route("/dashboard/validate-grid-nodes-automatic", web::post().to(DashboardController::validate_grid_nodes_automatic))
// .route("/dashboard/add-nodes-automatic", web::post().to(DashboardController::add_nodes_automatic)) // Deprecated [DISABLED]
.route("/dashboard/refresh-slice-calculations", web::post().to(DashboardController::refresh_slice_calculations_api))
.route("/dashboard/sync-with-grid", web::post().to(DashboardController::sync_with_grid_api))
.route("/dashboard/node-slices/{id}", web::get().to(DashboardController::get_node_slices_api))
.route("/dashboard/node-groups", web::get().to(DashboardController::get_node_groups))
.route("/dashboard/node-groups", web::post().to(DashboardController::create_node_group))
.route("/dashboard/node-groups/api", web::get().to(DashboardController::get_node_groups_api))
.route("/dashboard/node-groups/custom", web::post().to(DashboardController::create_custom_node_group))
.route("/dashboard/node-groups/{id}", web::delete().to(DashboardController::delete_custom_node_group))
.route("/dashboard/nodes/assign-group", web::post().to(DashboardController::assign_node_to_group))
// Node staking API routes
.route("/dashboard/farm-nodes/{id}/stake", web::post().to(DashboardController::stake_on_node))
.route("/dashboard/farm-nodes/{id}/staking", web::put().to(DashboardController::update_node_staking))
.route("/dashboard/staking/statistics", web::get().to(DashboardController::get_staking_statistics))
// Service management API routes
.route("/dashboard/services", web::get().to(DashboardController::get_user_services))
.route("/dashboard/services", web::post().to(DashboardController::create_service))
.route("/dashboard/services/{id}", web::put().to(DashboardController::update_service))
.route("/dashboard/services/{id}", web::delete().to(DashboardController::delete_service))
// App management API routes
.route("/dashboard/apps", web::get().to(DashboardController::get_user_apps))
.route("/dashboard/apps", web::post().to(DashboardController::create_app))
.route("/dashboard/apps/{id}", web::put().to(DashboardController::update_app))
.route("/dashboard/apps/{id}", web::delete().to(DashboardController::delete_app))
// Product management API routes (Service Provider applications)
.route("/dashboard/products", web::get().to(DashboardController::get_user_products))
.route("/dashboard/products", web::post().to(DashboardController::create_product))
// Deployment management API routes
.route("/dashboard/deployment/{id}", web::get().to(DashboardController::get_deployment_details))
// Enhanced service management API routes for comprehensive management
.route("/dashboard/services/{id}/details", web::get().to(DashboardController::get_service_details))
.route("/dashboard/services/{id}/analytics", web::get().to(DashboardController::get_service_analytics))
.route("/dashboard/services/{id}/clients", web::get().to(DashboardController::get_service_clients))
.route("/dashboard/services/{id}/status", web::put().to(DashboardController::update_service_status))
// Service request management API routes
.route("/dashboard/service-requests", web::get().to(DashboardController::get_user_service_requests))
.route("/dashboard/service-requests/{id}", web::put().to(DashboardController::update_service_request))
.route("/dashboard/service-requests/{id}/progress", web::put().to(DashboardController::update_service_request_progress))
.route("/dashboard/service-requests/{id}/details", web::get().to(DashboardController::get_service_request_details))
.route("/dashboard/service-requests/{id}/completed-details", web::get().to(DashboardController::get_completed_request_details))
.route("/dashboard/service-requests/{id}/invoice", web::get().to(DashboardController::generate_service_request_invoice))
.route("/dashboard/service-requests/{id}/report", web::get().to(DashboardController::get_service_request_report))
// Availability management API routes
.route("/dashboard/availability", web::get().to(DashboardController::get_user_availability))
.route("/dashboard/availability", web::put().to(DashboardController::update_user_availability))
// SLA management API routes
.route("/dashboard/slas", web::get().to(DashboardController::get_user_slas))
.route("/dashboard/slas", web::post().to(DashboardController::create_sla))
.route("/dashboard/slas/{id}", web::put().to(DashboardController::update_sla))
.route("/dashboard/slas/{id}", web::delete().to(DashboardController::delete_sla))
// Agreement download API route
.route("/dashboard/agreement/download", web::get().to(DashboardController::download_agreement))
// Settings API routes
.route("/dashboard/settings/profile", web::post().to(DashboardController::update_profile))
.route("/dashboard/settings/password", web::post().to(DashboardController::update_password))
.route("/dashboard/settings/notifications", web::post().to(DashboardController::update_notifications))
.route("/dashboard/settings/verify-password", web::post().to(DashboardController::verify_password))
.route("/dashboard/settings/delete-account", web::post().to(DashboardController::delete_account))
.route("/dashboard/settings/billing-history", web::get().to(DashboardController::get_billing_history))
// SSH key management API routes
.route("/dashboard/ssh-keys", web::get().to(DashboardController::get_ssh_keys))
.route("/dashboard/ssh-keys", web::post().to(DashboardController::add_ssh_key))
.route("/dashboard/ssh-keys/{id}", web::put().to(DashboardController::update_ssh_key))
.route("/dashboard/ssh-keys/{id}", web::delete().to(DashboardController::delete_ssh_key))
.route("/dashboard/ssh-keys/{id}/set-default", web::post().to(DashboardController::set_default_ssh_key))
.route("/dashboard/ssh-keys/{id}", web::get().to(DashboardController::get_ssh_key_details))
// Rental API routes
.route("/products/{id}/rent", web::post().to(RentalController::rent_product))
.route("/products/{id}/rent-node", web::post().to(RentalController::rent_node_product))
.route("/products/{id}/purchase", web::post().to(RentalController::purchase_product))
.route("/rentals/{id}/cancel", web::delete().to(RentalController::cancel_rental))
// Credits API routes
.route("/wallet/buy-credits", web::post().to(WalletController::buy_credits))
.route("/wallet/sell-credits", web::post().to(WalletController::sell_credits))
.route("/wallet/transfer-credits", web::post().to(WalletController::transfer_credits))
.route("/wallet/balance", web::get().to(WalletController::get_balance))
.route("/wallet/info", web::get().to(WalletController::get_wallet_info))
.route("/wallet/transactions", web::get().to(WalletController::get_transactions))
// OpenRouter-style instant purchase and top-up routes
.route("/wallet/instant-purchase", web::post().to(WalletController::instant_purchase))
.route("/wallet/quick-topup", web::post().to(WalletController::quick_topup))
.route("/wallet/check-affordability", web::get().to(WalletController::check_affordability))
.route("/wallet/topup-amounts", web::get().to(WalletController::get_quick_topup_amounts))
// Auto top-up API routes
.route("/wallet/auto-topup/configure", web::post().to(WalletController::configure_auto_topup))
.route("/wallet/auto-topup/status", web::get().to(WalletController::get_auto_topup_status))
.route("/wallet/auto-topup/trigger", web::post().to(WalletController::trigger_auto_topup))
.route("/wallet/last-payment-method", web::get().to(WalletController::get_last_payment_method))
// Navbar API routes
.route("/navbar/dropdown-data", web::get().to(WalletController::get_navbar_data))
// Slice rental API routes
// .route("/marketplace/rent-slice", web::post().to(MarketplaceController::rent_slice)) // Deprecated [DISABLED]
// Pool API routes
.route("/pools", web::get().to(PoolController::get_pools))
.route("/pools/{pool_id}", web::get().to(PoolController::get_pool))
.route("/pools/exchange", web::post().to(PoolController::exchange_tokens))
.route("/pools/analytics", web::get().to(PoolController::get_analytics))
// Messaging API routes
.route("/messages/threads", web::get().to(MessagingController::get_threads))
.route("/messages/threads", web::post().to(MessagingController::create_thread))
.route("/messages/threads/{thread_id}/messages", web::get().to(MessagingController::get_messages))
.route("/messages/threads/{thread_id}/messages", web::post().to(MessagingController::send_message_with_path))
.route("/messages/threads/{thread_id}/read", web::put().to(MessagingController::mark_thread_read))
.route("/messages", web::post().to(MessagingController::send_message))
)
// Documentation routes
.route("/docs", web::get().to(DocsController::index))
.route("/docs/getting-started", web::get().to(DocsController::getting_started))
.route("/docs/3nodes", web::get().to(DocsController::three_nodes))
.route("/docs/compute", web::get().to(DocsController::compute))
.route("/docs/gateways", web::get().to(DocsController::gateways))
.route("/docs/applications", web::get().to(DocsController::applications))
.route("/docs/services", web::get().to(DocsController::services))
.route("/docs/credits", web::get().to(DocsController::credits))
.route("/docs/slices", web::get().to(DocsController::slices))
.route("/docs/certification", web::get().to(DocsController::certification))
.route("/docs/api", web::get().to(DocsController::api))
// Dashboard routes (protected by JwtAuth middleware)
.service(
web::scope("/dashboard")
.wrap(JwtAuth) // Apply authentication middleware to all dashboard routes
.route("", web::get().to(DashboardController::index))
.route("/user", web::get().to(DashboardController::user_section))
.route("/farmer", web::get().to(DashboardController::farmer_section))
.route("/app-provider", web::get().to(DashboardController::app_provider_section))
.route("/service-provider", web::get().to(DashboardController::service_provider_section))
// Shopping routes - embedded in dashboard
.route("/cart", web::get().to(DashboardController::cart_section))
.route("/orders", web::get().to(DashboardController::orders_section))
// HIDE: Main pools route - keep for admin/future use
// .route("/pools", web::get().to(DashboardController::pools))
// Keep as hidden admin route
.route("/pools-admin", web::get().to(DashboardController::pools))
.route("/settings", web::get().to(DashboardController::settings))
// Dashboard messaging route
.route("/messages", web::get().to(DashboardController::messages_page))
// Dashboard wallet route
.route("/wallet", web::get().to(WalletController::dashboard_wallet_page))
)
// Public information routes (legal, changelog, roadmap)
.route("/privacy", web::get().to(PublicController::privacy))
.route("/terms", web::get().to(PublicController::terms))
.route("/terms/farmers", web::get().to(PublicController::terms_farmers))
.route("/terms/service-providers", web::get().to(PublicController::terms_service_providers))
.route("/terms/solution-providers", web::get().to(PublicController::terms_solution_providers))
.route("/terms/users", web::get().to(PublicController::terms_users))
.route("/changelog", web::get().to(PublicController::changelog))
.route("/roadmap", web::get().to(PublicController::roadmap));
// Conditionally add authentication routes based on GITEA_CLIENT_ID environment variable
if env::var("GITEA_CLIENT_ID").ok().filter(|s| !s.is_empty()).is_some() {
// Use Gitea OAuth flow
// Create the OAuth configuration and add it to the scope
let oauth_config = web::Data::new(GiteaOAuthConfig::new());
main_scope = main_scope
.app_data(oauth_config) // Add oauth_config data
// Gitea OAuth routes
.route("/login", web::get().to(GiteaAuthController::login)) // Add /login route for gitea
.route("/auth/gitea", web::get().to(GiteaAuthController::login))
.route(
"/auth/gitea/callback",
web::get().to(GiteaAuthController::callback),
);
} else {
// Use standard username/password login
main_scope = main_scope
.route("/login", web::get().to(AuthController::login_page))
.route("/login", web::post().to(AuthController::login))
.route("/register", web::get().to(AuthController::register_page))
.route("/register", web::post().to(AuthController::register));
}
// Add common auth and debug routes (logout is common to both flows)
main_scope = main_scope
.route("/logout", web::get().to(AuthController::logout))
// Debug routes
.route("/debug", web::get().to(DebugController::debug_info));
// Register the main scope service
cfg.service(main_scope);
// Protected routes that require JWT authentication
cfg.service(
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
);
// API routes that require JWT authentication (for external API access)
cfg.service(
web::scope("/api/v1")
.wrap(JwtAuth) // Apply JWT auth for versioned API endpoints
// Future API endpoints would go here
);
}

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

402
src/static/css/styles.css Normal file
View File

@@ -0,0 +1,402 @@
/* Custom styles for ThreeFold Marketplace */
/* Global styles */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-top: 56px; /* Height of fixed navbar */
}
main {
flex: 1;
}
/* Navigation */
.navbar-brand {
font-weight: bold;
height: 56px;
display: flex;
align-items: center;
}
/* Ensure navbar has highest z-index */
.navbar {
z-index: 1050;
}
.navbar-nav .nav-item {
height: 56px;
}
.nav-link {
display: flex;
align-items: center;
height: 100%;
}
/* Cards */
.card {
margin-bottom: 1rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.card-main {
height: 120px;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
transform: translateY(-0.25rem);
}
/* Buttons */
.btn {
border-radius: 0.25rem;
}
/* Improve button rendering inside Bootstrap modals to avoid hover flicker */
.modal .btn {
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
/* Forms */
.form-control:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Footer */
.footer {
margin-top: auto;
}
/* Marketplace styles */
.sidebar {
min-height: calc(100vh - 112px); /* Account for navbar and footer */
background-color: #f8f9fa;
padding-top: 1rem;
position: sticky;
top: 56px; /* Height of the navbar */
}
/* Sidebar toggle button for mobile */
.sidebar-toggle {
display: none;
position: fixed;
top: 76px; /* Return to original position */
left: 15px;
z-index: 1045; /* Higher than sidebar */
padding: 8px 12px;
width: auto;
max-width: 100px; /* Limit maximum width */
background-color: #0d6efd; /* Bootstrap primary color */
color: white;
border: none;
border-radius: 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
font-weight: 500;
font-size: 0.9rem;
text-align: center;
}
.sidebar-toggle:hover, .sidebar-toggle:focus {
background-color: #0b5ed7;
color: white;
outline: none;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
}
.sidebar-toggle .bi {
font-size: 1.2rem;
margin-right: 4px;
}
.sidebar .nav-link {
padding: 0.5rem 1rem;
color: #333;
border-left: 3px solid transparent;
}
.sidebar .nav-link.active {
color: #0d6efd;
font-weight: 600;
border-left-color: #0d6efd;
background-color: rgba(13, 110, 253, 0.05);
}
.sidebar .nav-link:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.dashboard-section {
padding: 1.5rem;
margin-bottom: 1.5rem;
border-radius: 0.25rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.marketplace-item {
height: 100%;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.marketplace-item:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
transform: translateY(-0.25rem);
}
.spec-item {
margin-bottom: 0.5rem;
}
.badge-category {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
margin-right: 0.5rem;
}
.gateway-status-indicator {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
}
.service-item {
height: 100%;
padding: 1.25rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.service-item:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
/* CTA Buttons styles */
.cta-primary {
font-size: 1.2rem;
padding: 0.75rem 2rem;
transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.cta-secondary {
font-size: 1.2rem;
padding: 0.75rem 2rem;
transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
/* Home page styles */
.hero-section {
padding: 5rem 0;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
/* When modal is open, suppress background hover transitions to avoid backdrop repaint flicker */
body.modal-open .card:hover,
body.modal-open .marketplace-item:hover,
body.modal-open .service-item:hover,
body.modal-open .table-hover tbody tr:hover {
transform: none !important;
box-shadow: inherit !important;
background-color: inherit !important;
}
.hero-section h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
.marketplace-preview {
padding: 4rem 0;
background-color: #ffffff;
}
.faq-section {
padding: 4rem 0;
background-color: #f5f7fa;
}
.faq-item {
margin-bottom: 1rem;
}
.faq-question {
font-weight: 600;
cursor: pointer;
padding: 1rem;
background-color: #fff;
border-radius: 0.25rem;
border: 1px solid #dee2e6;
}
.faq-answer {
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0 0 0.25rem 0.25rem;
border: 1px solid #dee2e6;
border-top: none;
}
/* Legal pages styles */
.legal-content {
font-size: 0.9rem;
line-height: 1.6;
}
.legal-content h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.legal-content h3 {
font-size: 1.2rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
/* Media queries for responsive layout */
@media (max-width: 767.98px) {
/* Mobile sidebar approach - completely different approach */
.sidebar {
display: none; /* Hidden by default instead of off-screen */
position: fixed;
left: 0;
top: 66px; /* Back to original position */
width: 80%;
max-width: 280px;
height: auto;
max-height: calc(100vh - 66px); /* Original calculation */
overflow-y: auto; /* Enable scrolling within sidebar */
z-index: 1040;
background-color: #ffffff;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
padding: 7rem 1rem 1.25rem 1rem; /* Substantially more top padding */
border-right: 1px solid #dee2e6;
pointer-events: auto !important; /* Ensure clicks work */
}
/* When sidebar is shown */
.sidebar.show {
display: block;
}
/* Enhanced mobile sidebar styles */
.sidebar .nav-link {
padding: 0.75rem 1rem;
border-radius: 0.375rem;
margin-bottom: 0.25rem;
font-size: 1rem;
}
.sidebar .nav-link:hover {
background-color: rgba(13, 110, 253, 0.1);
}
.sidebar .nav-link.active {
background-color: rgba(13, 110, 253, 0.15);
}
.sidebar .sidebar-heading {
font-size: 0.85rem;
padding: 0.5rem 1rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
position: relative; /* For positioning consistency */
}
/* Position the first heading down from the top */
.sidebar .position-sticky > h5.sidebar-heading:first-of-type {
margin-top: 2rem;
}
/* Add extra space above all sidebar content */
.sidebar .position-sticky {
padding-top: 2rem;
}
.sidebar-toggle {
display: block;
}
.hero-section h1 {
font-size: 2rem;
}
body {
padding-top: 66px; /* Slightly taller navbar on mobile */
}
/* Fix for CTA buttons being too close to logo in mobile view */
.hero-section .d-flex.flex-wrap {
margin-bottom: 2rem; /* Add spacing between CTA buttons and logo */
}
/* Fix spacing for dashboard welcome page */
.row.mb-5.align-items-center .col-md-6:first-child {
margin-bottom: 2rem;
}
.d-grid.gap-3 {
margin-bottom: 1.5rem;
}
/* Add overlay when sidebar is shown */
.sidebar-backdrop {
display: none;
position: fixed;
top: 66px; /* Back to original position */
left: 0; /* Start at left edge */
width: 100%; /* Full width */
height: calc(100vh - 66px); /* Original calculation */
background-color: rgba(0,0,0,0.5);
z-index: 1030; /* Lower than sidebar */
}
/* When backdrop is shown */
.sidebar-backdrop.show {
display: block;
right: 0;
bottom: 0;
z-index: 1035; /* Between sidebar and toggle button */
}
/* Ensure the sidebar can be clicked */
.sidebar.show {
display: block;
z-index: 1040; /* Above backdrop */
pointer-events: auto !important;
}
/* Ensure links in sidebar can be clicked */
.sidebar.show .nav-link {
pointer-events: auto !important;
position: relative;
z-index: 1045;
}
/* Adjust main content when sidebar is toggled */
.main-content-wrapper {
transition: margin-left 0.3s ease;
}
}
/* Ensure Bootstrap modal always appears above custom navbar/sidebar/backdrops */
.modal-backdrop {
z-index: 1070 !important; /* Above navbar (1050) and sidebar overlay (1035/1040) */
}
.modal {
z-index: 1080 !important; /* Above backdrop */
}

71
src/static/debug.html Normal file
View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Page</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
button {
padding: 10px;
margin: 10px 0;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Debug Page</h1>
<h2>Client-Side Cookies</h2>
<pre id="cookies"></pre>
<h2>Debug API Response</h2>
<button id="fetchDebug">Fetch Debug Info</button>
<pre id="debugInfo"></pre>
<script>
// Display client-side cookies
function displayCookies() {
const cookiesDiv = document.getElementById('cookies');
const cookies = document.cookie.split(';').map(cookie => cookie.trim());
if (cookies.length === 0 || (cookies.length === 1 && cookies[0] === '')) {
cookiesDiv.textContent = 'No cookies found';
} else {
const cookieObj = {};
cookies.forEach(cookie => {
const [name, value] = cookie.split('=');
cookieObj[name] = value;
});
cookiesDiv.textContent = JSON.stringify(cookieObj, null, 2);
}
}
// Fetch debug info from API
document.getElementById('fetchDebug').addEventListener('click', async () => {
try {
const response = await fetch('/debug');
const data = await response.json();
document.getElementById('debugInfo').textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById('debugInfo').textContent = `Error: ${error.message}`;
}
});
// Initial display
displayCookies();
// Update cookies display every 2 seconds
setInterval(displayCookies, 2000);
</script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
<svg width="800" height="550" viewBox="0 0 800 550" xmlns="http://www.w3.org/2000/svg">
<!-- Define styles -->
<defs>
<style>
.box { fill: #ffffff; stroke: #4B5563; stroke-width: 2; rx: 8; ry: 8; }
.text { font-family: 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; fill: #1F2937; text-anchor: middle; }
.title-text { font-size: 16px; font-weight: bold; }
.arrow-line { stroke: #6B7280; stroke-width: 2; }
.arrow-head { fill: #6B7280; }
/* .label-text class is no longer used for arrow labels */
.provider-color { fill: #D1FAE5; stroke: #059669; } /* Light Green */
.user-color { fill: #DBEAFE; stroke: #2563EB; } /* Light Blue */
.system-color { fill: #FEF3C7; stroke: #D97706; } /* Light Yellow */
.tfp-color { fill: #E0E7FF; stroke: #4F46E5; } /* Light Indigo for TFP itself */
.marketplace-color { fill: #F3E8FF; stroke: #7E22CE; } /* Light Purple */
</style>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="arrow-head" />
</marker>
</defs>
<!-- Title -->
<text x="400" y="30" class="text title-text" style="font-size: 20px;">ThreeFold Points (TFP) Flow</text>
<!-- Column 1: Providers & Generation -->
<rect x="50" y="70" width="180" height="100" class="box provider-color" />
<text x="140" y="100" class="text title-text">Providers</text>
<text x="140" y="120" class="text">Contribute Resources</text>
<text x="140" y="135" class="text">&amp; Services to Grid</text>
<rect x="50" y="200" width="180" height="70" class="box system-color" />
<text x="140" y="230" class="text title-text">TFP Generation</text>
<text x="140" y="250" class="text">(Minted for Providers)</text>
<rect x="50" y="300" width="180" height="70" class="box tfp-color" />
<text x="140" y="330" class="text title-text">Provider TFP</text>
<text x="140" y="350" class="text">Balance</text>
<!-- Column 2: Marketplace -->
<rect x="310" y="230" width="180" height="100" class="box marketplace-color" />
<text x="400" y="260" class="text title-text">Marketplace</text>
<text x="400" y="280" class="text">Exchange of</text>
<text x="400" y="295" class="text">Services/Resources</text>
<text x="400" y="310" class="text">for TFP</text>
<!-- Column 3: Users & Acquisition -->
<rect x="570" y="70" width="180" height="100" class="box user-color" />
<text x="660" y="115" class="text title-text">Users</text>
<rect x="570" y="200" width="180" height="70" class="box system-color" />
<text x="660" y="225" class="text title-text">TFP Acquisition</text>
<text x="660" y="240" class="text">(Fiat, Swaps,</text>
<text x="660" y="255" class="text">Liquidity Pools)</text>
<rect x="570" y="300" width="180" height="70" class="box tfp-color" />
<text x="660" y="330" class="text title-text">User TFP</text>
<text x="660" y="350" class="text">Balance</text>
<!-- Arrows (No Labels) -->
<!-- Provider to TFP Generation -->
<line x1="140" y1="170" x2="140" y2="200" class="arrow-line" marker-end="url(#arrow)" />
<!-- TFP Generation to Provider Balance -->
<line x1="140" y1="270" x2="140" y2="300" class="arrow-line" marker-end="url(#arrow)" />
<!-- User to TFP Acquisition -->
<line x1="660" y1="170" x2="660" y2="200" class="arrow-line" marker-end="url(#arrow)" />
<!-- TFP Acquisition to User Balance -->
<line x1="660" y1="270" x2="660" y2="300" class="arrow-line" marker-end="url(#arrow)" />
<!-- User Balance to Marketplace -->
<line x1="570" y1="335" x2="490" y2="305" class="arrow-line" marker-end="url(#arrow)" />
<!-- Marketplace to Provider Balance -->
<line x1="310" y1="305" x2="230" y2="335" class="arrow-line" marker-end="url(#arrow)" />
<!-- Circulation Loop (Conceptual) -->
<path d="M 230 370 Q 250 470, 400 470 Q 550 470, 570 370" fill="none" class="arrow-line" stroke-dasharray="5,5" />
<text x="400" y="490" class="text title-text" text-anchor="middle">TFP Circulates in Ecosystem</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm-32 256c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm128 0c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm-64-96c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32z" fill="#609926"/></svg>

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,33 @@
/**
* Authentication forms functionality (login/register)
* Migrated from inline scripts to use apiJson and shared error handlers
*/
document.addEventListener('DOMContentLoaded', function() {
// Check if coming from checkout flow
const urlParams = new URLSearchParams(window.location.search);
const fromCheckout = urlParams.get('checkout') === 'true';
const returnUrl = urlParams.get('return');
if (fromCheckout) {
// Show message about completing checkout after login/registration
const message = document.querySelector('.auth-checkout-message');
if (message) {
message.classList.remove('d-none');
}
}
// Handle form submissions
const authForm = document.querySelector('form[action*="/login"], form[action*="/register"]');
if (authForm) {
authForm.addEventListener('submit', async function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
if (submitBtn) {
setButtonLoading(submitBtn, 'Processing...');
}
// Let the form submit normally for now
// This maintains existing server-side validation and redirect logic
});
}
});

353
src/static/js/base.js Normal file
View File

@@ -0,0 +1,353 @@
/* eslint-disable no-console */
(function () {
'use strict';
// Global fetch 402 interceptor (centralized insufficient funds handling)
(function setupFetch402Interceptor() {
try {
if (!window.fetch || window.__fetch402InterceptorInstalled) return;
const originalFetch = window.fetch.bind(window);
window.fetch = async function (...args) {
const response = await originalFetch(...args);
try {
if (response && response.status === 402 && window.Errors && typeof window.Errors.handleInsufficientFundsResponse === 'function') {
// Throttle duplicate modals fired by multiple concurrent requests
const now = Date.now();
const last = window.__lastInsufficientFundsTs || 0;
if (now - last > 3000) {
window.__lastInsufficientFundsTs = now;
// Use a clone so callers can still read the original response body
const clone = response.clone();
const text = await clone.text();
await window.Errors.handleInsufficientFundsResponse(clone, text);
}
}
} catch (e) {
console.error('Fetch 402 interceptor error:', e);
}
return response;
};
window.__fetch402InterceptorInstalled = true;
} catch (e) {
console.error('Failed to setup fetch 402 interceptor:', e);
}
})();
// Shared API JSON helper: standardized fetch + JSON unwrap + error handling
async function apiJson(input, init = {}) {
const opts = { ...init };
// Normalize headers and set defaults
const headers = new Headers(init && init.headers ? init.headers : {});
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
opts.headers = headers;
// Default credentials to same-origin unless explicitly set
if (!('credentials' in opts)) {
opts.credentials = 'same-origin';
}
// If body is a plain object and not FormData, assume JSON
const isPlainObjectBody = opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData);
if (isPlainObjectBody) {
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
if (headers.get('Content-Type') && headers.get('Content-Type').includes('application/json')) {
opts.body = JSON.stringify(opts.body);
}
}
const res = await fetch(input, opts);
// Best-effort body read. Consumers don't need the raw response body.
let text = '';
try {
text = await res.text();
} catch (_) { /* ignore */ }
let parsed = null;
if (text && text.trim().length) {
try {
parsed = JSON.parse(text);
} catch (_) {
parsed = null; // non-JSON response
}
}
if (res.ok) {
// 204/empty -> null
if (!parsed) return null;
// Unwrap standardized API envelope: { data, success, message, ... }
const data = (parsed && typeof parsed === 'object' && 'data' in parsed) ? (parsed.data ?? parsed) : parsed;
return data;
}
// Not OK -> throw informative error
const message = (parsed && typeof parsed === 'object' && (parsed.message || parsed.error)) || res.statusText || 'Request failed';
const err = new Error(message);
// Attach useful context
err.status = res.status;
if (parsed && typeof parsed === 'object') {
err.errors = parsed.errors;
err.data = parsed.data;
err.metadata = parsed.metadata;
}
err.body = text;
throw err;
}
window.apiJson = apiJson;
// Enhanced API helpers for comprehensive request handling
// FormData helper with consistent error handling
window.apiFormData = async (url, formData, options = {}) => {
const opts = {
...options,
method: options.method || 'POST',
body: formData
};
return apiJson(url, opts);
};
// Text response helper (PDFs, plain text, etc.)
window.apiText = async (url, options = {}) => {
const startTime = performance.now();
const opts = {
...options,
credentials: options.credentials || 'same-origin'
};
try {
const response = await fetch(url, opts);
const duration = performance.now() - startTime;
// Log performance in development
if (window.location.hostname === 'localhost') {
console.log(`🌐 API Text: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`);
}
if (!response.ok) {
const text = await response.text().catch(() => '');
const error = new Error(`${response.status}: ${response.statusText}`);
error.status = response.status;
error.body = text;
throw error;
}
return response.text();
} catch (error) {
const duration = performance.now() - startTime;
if (window.location.hostname === 'localhost') {
console.error(`❌ API Text Failed: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`, error);
}
throw error;
}
};
// Blob helper for binary downloads
window.apiBlob = async (url, options = {}) => {
const startTime = performance.now();
const opts = {
...options,
credentials: options.credentials || 'same-origin'
};
try {
const response = await fetch(url, opts);
const duration = performance.now() - startTime;
if (window.location.hostname === 'localhost') {
console.log(`📁 API Blob: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`);
}
if (!response.ok) {
const error = new Error(`${response.status}: ${response.statusText}`);
error.status = response.status;
throw error;
}
return response.blob();
} catch (error) {
const duration = performance.now() - startTime;
if (window.location.hostname === 'localhost') {
console.error(`❌ API Blob Failed: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`, error);
}
throw error;
}
};
// Request deduplication to prevent double submissions
const pendingRequests = new Map();
window.apiJsonDeduped = async (url, options = {}) => {
const key = `${options.method || 'GET'}:${url}:${JSON.stringify(options.body || {})}`;
if (pendingRequests.has(key)) {
return pendingRequests.get(key);
}
const promise = apiJson(url, options).finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
return promise;
};
// Enhanced apiJson with performance logging and retry logic
const originalApiJson = apiJson;
window.apiJson = async (url, options = {}) => {
const startTime = performance.now();
const method = options.method || 'GET';
const maxRetries = options.retries || (method === 'GET' ? 2 : 0); // Only retry GET requests by default
const retryDelay = options.retryDelay || 1000; // 1 second delay between retries
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await originalApiJson(url, options);
const duration = performance.now() - startTime;
// Log performance in development
if (window.location.hostname === 'localhost') {
const retryInfo = attempt > 0 ? ` (retry ${attempt})` : '';
console.log(`🚀 API: ${method} ${url} (${duration.toFixed(0)}ms)${retryInfo}`);
}
return result;
} catch (error) {
lastError = error;
// Don't retry on client errors (4xx) or authentication issues
if (error.status >= 400 && error.status < 500) {
break;
}
// Don't retry on the last attempt
if (attempt === maxRetries) {
break;
}
// Only retry on network errors or server errors (5xx)
const isRetryable = !error.status || error.status >= 500;
if (!isRetryable) {
break;
}
// Wait before retrying
if (window.location.hostname === 'localhost') {
console.warn(`⚠️ API Retry: ${method} ${url} (attempt ${attempt + 1}/${maxRetries + 1})`);
}
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
}
}
const duration = performance.now() - startTime;
if (window.location.hostname === 'localhost') {
console.error(`❌ API Failed: ${method} ${url} (${duration.toFixed(0)}ms)`, lastError);
}
throw lastError;
};
// Global cart count update function
async function updateCartCount() {
try {
const cartNavItem = document.getElementById('cartNavItem');
const cartCountElement = document.querySelector('.cart-count');
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const itemCount = (cartData && typeof cartData === 'object') ? (parseInt(cartData.item_count) || 0) : 0;
if (cartNavItem) {
if (itemCount > 0) {
// Show cart nav item and update count
cartNavItem.style.display = 'block';
if (cartCountElement) {
cartCountElement.textContent = itemCount;
cartCountElement.style.display = 'inline';
}
} else {
// Hide cart nav item when empty or zero
cartNavItem.style.display = 'none';
}
}
} catch (error) {
console.error('Error updating cart count:', error);
// Hide cart nav item on error
const cartNavItem = document.getElementById('cartNavItem');
if (cartNavItem) {
cartNavItem.style.display = 'none';
}
}
}
window.updateCartCount = updateCartCount;
// Global helper to emit cartUpdated events consistently
window.emitCartUpdated = function (cartCount) {
try {
window.dispatchEvent(new CustomEvent('cartUpdated', {
detail: { cartCount: typeof cartCount === 'number' ? cartCount : undefined }
}));
} catch (e) {
console.error('Failed to dispatch cartUpdated event:', e);
}
};
// Keep navbar in sync with cart updates
window.addEventListener('cartUpdated', function () {
if (typeof updateCartCount === 'function') {
updateCartCount();
}
});
// Navbar dropdown data loader
async function loadNavbarData() {
try {
const data = await window.apiJson('/api/navbar/dropdown-data', { cache: 'no-store' }) || {};
if (data.wallet_balance_formatted) {
// Update navbar balance display
const navbarBalance = document.getElementById('navbar-balance');
const dropdownBalance = document.getElementById('dropdown-balance');
const currencyIndicator = document.getElementById('dropdown-currency-indicator');
if (navbarBalance) {
navbarBalance.textContent = data.wallet_balance_formatted;
}
if (dropdownBalance) {
dropdownBalance.textContent = data.wallet_balance_formatted;
}
if (currencyIndicator && data.display_currency) {
currencyIndicator.textContent = data.display_currency;
}
} else {
// Missing expected fields; apply fallback
const dropdownBalance = document.getElementById('dropdown-balance');
if (dropdownBalance) dropdownBalance.textContent = 'N/A';
}
} catch (error) {
console.error('Failed to load navbar data:', error);
// Fallback to showing basic info
const navbarBalance = document.getElementById('navbar-balance');
const dropdownBalance = document.getElementById('dropdown-balance');
if (navbarBalance) {
navbarBalance.textContent = 'Wallet';
}
if (dropdownBalance) {
dropdownBalance.textContent = 'N/A';
}
}
}
window.loadNavbarData = loadNavbarData;
window.updateNavbarBalance = async function () { await loadNavbarData(); };
// Initializers
document.addEventListener('DOMContentLoaded', function () {
if (typeof updateCartCount === 'function') {
updateCartCount();
}
// Only try to load navbar data if dropdown elements exist
if (document.getElementById('dropdown-balance')) {
loadNavbarData();
}
});
})();

226
src/static/js/buy-now.js Normal file
View File

@@ -0,0 +1,226 @@
// Buy Now functionality with builder pattern and modal system integration
class BuyNowRequestBuilder {
constructor() {
this.request = {};
}
productId(id) {
this.request.product_id = id;
return this;
}
productName(name) {
this.request.product_name = name;
return this;
}
productCategory(category) {
this.request.product_category = category || 'general';
return this;
}
quantity(qty) {
this.request.quantity = qty || 1;
return this;
}
unitPriceUsd(price) {
this.request.unit_price_usd = parseFloat(price);
return this;
}
providerId(id) {
this.request.provider_id = id || 'marketplace';
return this;
}
providerName(name) {
this.request.provider_name = name || 'Project Mycelium';
return this;
}
specifications(specs) {
this.request.specifications = specs;
return this;
}
build() {
// Validate required fields
if (!this.request.product_id) {
throw new Error('Product ID is required');
}
if (!this.request.product_name) {
throw new Error('Product name is required');
}
if (!this.request.unit_price_usd || this.request.unit_price_usd <= 0) {
throw new Error('Valid unit price is required');
}
return { ...this.request };
}
}
class BuyNowManager {
constructor() {
this.initializeEventHandlers();
}
static builder() {
return new BuyNowRequestBuilder();
}
initializeEventHandlers() {
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.buy-now-btn').forEach(button => {
button.addEventListener('click', (e) => this.handleBuyNow(e));
});
});
}
async handleBuyNow(event) {
const button = event.target.closest('.buy-now-btn');
// Disable button during processing
button.disabled = true;
const originalText = button.innerHTML;
button.innerHTML = '<i class="spinner-border spinner-border-sm me-1"></i>Processing...';
try {
// Build purchase request using builder pattern
const purchaseRequest = BuyNowManager.builder()
.productId(button.dataset.productId)
.productName(button.dataset.productName)
.productCategory(button.dataset.category)
.quantity(parseInt(button.dataset.quantity) || 1)
.unitPriceUsd(button.dataset.unitPrice)
.providerId(button.dataset.providerId)
.providerName(button.dataset.providerName)
.specifications(button.dataset.specifications ? JSON.parse(button.dataset.specifications) : null)
.build();
// Check authentication first
const isAuthenticated = await this.checkAuthentication();
if (!isAuthenticated) {
this.showAuthRequired();
return;
}
// Check affordability using total cost (price * quantity)
const totalRequired = (Number(purchaseRequest.unit_price_usd) || 0) * (Number(purchaseRequest.quantity) || 1);
const affordabilityData = await window.apiJson(`/api/wallet/check-affordability?amount=${totalRequired}`);
if (!affordabilityData.can_afford) {
// Show insufficient balance modal
this.showInsufficientBalance(affordabilityData.shortfall_info?.shortfall || purchaseRequest.unit_price_usd);
return;
}
// Proceed with instant purchase
const purchasePayload = await window.apiJson('/api/wallet/instant-purchase', {
method: 'POST',
body: purchaseRequest
});
if (purchasePayload && purchasePayload.success) {
this.showSuccess(`Successfully purchased ${purchaseRequest.product_name}!`);
// Update navbar balance
if (window.loadNavbarData) {
window.loadNavbarData();
}
// Refresh orders page if it exists
if (window.refreshOrders) {
window.refreshOrders();
}
} else {
// Handle canonical error envelope even if 200 OK with success=false
if (window.Errors && typeof window.Errors.isInsufficientFundsEnvelope === 'function' && window.Errors.isInsufficientFundsEnvelope(purchasePayload)) {
try {
const details = window.Errors.extractInsufficientFundsDetails(purchasePayload);
window.Errors.renderInsufficientFunds(details);
return;
} catch (_) { /* fall through to generic error */ }
}
const message = (purchasePayload && purchasePayload.message) || 'An error occurred during purchase';
this.showError('Purchase Failed', message);
}
} catch (error) {
console.error('Buy Now error:', error);
if (error && error.status === 402) {
// Global interceptor will show modal
return;
}
if (String(error && error.message || '').includes('required')) {
this.showError('Invalid Product Data', error.message);
} else {
this.showError('Purchase Failed', error && error.message ? error.message : 'Purchase failed. Please try again.');
}
} finally {
// Re-enable button
button.disabled = false;
button.innerHTML = originalText;
}
}
showSuccess(message) {
if (window.modalSystem) {
window.modalSystem.showSuccess('Purchase Successful', message);
} else if (window.showNotification) {
window.showNotification(message, 'success');
} else {
alert(message);
}
}
showError(title, message) {
if (window.modalSystem) {
window.modalSystem.showError(title, message);
} else if (window.showNotification) {
window.showNotification(message, 'error');
} else {
alert(message);
}
}
async checkAuthentication() {
try {
const result = await window.apiJson('/api/auth/status', { credentials: 'include' });
const isAuthenticated = (result && result.authenticated === true) || false;
return isAuthenticated;
} catch (error) {
console.error('Authentication check failed:', error);
return false;
}
}
showAuthRequired() {
if (window.modalSystem) {
window.modalSystem.showAuthRequired();
} else {
const userChoice = confirm(
'Please log in or register to make purchases. Would you like to go to the dashboard to continue?'
);
if (userChoice) {
window.location.href = '/dashboard';
}
}
}
showInsufficientBalance(shortfall) {
if (window.modalSystem) {
window.modalSystem.showInsufficientBalance(shortfall);
} else {
const userChoice = confirm(
`Insufficient balance. You need $${shortfall.toFixed(2)} more. ` +
`Would you like to go to the wallet to add credits?`
);
if (userChoice) {
window.location.href = '/dashboard/wallet?action=topup';
}
}
}
}
// Initialize Buy Now manager
new BuyNowManager();

View File

@@ -0,0 +1,324 @@
/**
* Cart functionality for marketplace pages
* Migrated from inline scripts to use apiJson and shared error handlers
*/
// Global variable to store product ID for removal
let productIdToRemove = null;
document.addEventListener('DOMContentLoaded', function() {
// Initialize cart functionality
console.log('Cart page loaded');
// Update My Orders visibility when cart page loads
if (typeof updateMyOrdersVisibility === 'function') {
updateMyOrdersVisibility();
}
// Initialize recommended products functionality
initializeRecommendedProducts();
// Add event listeners for quantity buttons
document.querySelectorAll('[data-action="increase"], [data-action="decrease"]').forEach(button => {
button.addEventListener('click', function() {
const productId = this.getAttribute('data-product-id');
const action = this.getAttribute('data-action');
const currentQuantity = parseInt(this.parentElement.querySelector('span').textContent);
if (action === 'increase') {
updateQuantity(productId, currentQuantity + 1);
} else if (action === 'decrease') {
updateQuantity(productId, currentQuantity - 1);
}
});
});
// Add event listeners for remove buttons
document.querySelectorAll('[data-action="remove"]').forEach(button => {
button.addEventListener('click', function() {
const productId = this.getAttribute('data-product-id');
showRemoveItemModal(productId);
});
});
// Add event listener for clear cart button
const clearCartBtn = document.getElementById('clearCartBtn');
if (clearCartBtn) {
clearCartBtn.addEventListener('click', showClearCartModal);
}
// Add event listener for confirm clear cart button in modal
const confirmClearCartBtn = document.getElementById('confirmClearCartBtn');
if (confirmClearCartBtn) {
confirmClearCartBtn.addEventListener('click', clearCart);
}
// Add event listener for confirm remove item button in modal
const confirmRemoveItemBtn = document.getElementById('confirmRemoveItemBtn');
if (confirmRemoveItemBtn) {
confirmRemoveItemBtn.addEventListener('click', confirmRemoveItem);
}
// Add event listener for currency selector
const currencySelector = document.getElementById('currencySelector');
if (currencySelector) {
currencySelector.addEventListener('change', changeCurrency);
}
// Post-reload success toast for cart clear (marketplace view)
try {
if (sessionStorage.getItem('cartCleared') === '1') {
sessionStorage.removeItem('cartCleared');
showSuccessToast('Cart cleared successfully');
}
} catch (_) { /* storage may be blocked */ }
});
// Helper: zero Order Summary values and disable checkout when empty
function setMarketplaceSummaryEmpty() {
try {
const summaryCardBody = document.querySelector('.col-lg-4 .card .card-body');
if (!summaryCardBody) return;
// Update Subtotal and Total values to $0.00
summaryCardBody.querySelectorAll('.d-flex.justify-content-between').forEach(row => {
const spans = row.querySelectorAll('span');
if (spans.length >= 2) {
const label = spans[0].textContent.trim();
if (label.startsWith('Subtotal')) spans[1].textContent = '$0.00';
if (label === 'Total') spans[1].textContent = '$0.00';
}
});
// Disable checkout if present
const checkoutBtn = summaryCardBody.querySelector('.btn.btn-primary.btn-lg');
if (checkoutBtn) {
if (checkoutBtn.tagName === 'BUTTON') {
checkoutBtn.disabled = true;
} else {
checkoutBtn.classList.add('disabled');
checkoutBtn.setAttribute('aria-disabled', 'true');
checkoutBtn.setAttribute('tabindex', '-1');
}
}
} catch (_) { /* noop */ }
}
// Update item quantity using apiJson
async function updateQuantity(productId, newQuantity) {
if (newQuantity < 1) {
removeFromCart(productId);
return;
}
showLoading();
try {
const data = await window.apiJson(`/api/cart/item/${productId}`, {
method: 'PUT',
body: JSON.stringify({
quantity: newQuantity
})
});
// Success - reload page to show updated cart
window.location.reload();
} catch (error) {
handleApiError(error, 'updating quantity');
} finally {
hideLoading();
}
}
// Show clear cart modal
function showClearCartModal() {
const modal = new bootstrap.Modal(document.getElementById('clearCartModal'));
modal.show();
}
// Show remove item modal
function showRemoveItemModal(productId) {
productIdToRemove = productId;
const modal = new bootstrap.Modal(document.getElementById('removeItemModal'));
modal.show();
}
// Confirm remove item (called from modal)
async function confirmRemoveItem() {
if (!productIdToRemove) return;
// Hide the modal first
const modal = bootstrap.Modal.getInstance(document.getElementById('removeItemModal'));
modal.hide();
await removeFromCart(productIdToRemove);
productIdToRemove = null;
}
// Remove item from cart using apiJson
async function removeFromCart(productId) {
showLoading();
try {
await window.apiJson(`/api/cart/item/${productId}`, {
method: 'DELETE'
});
showSuccessToast('Item removed from cart');
// Remove the item from DOM immediately
const cartItem = document.querySelector(`[data-product-id="${productId}"]`);
if (cartItem) {
cartItem.remove();
}
// Notify globally and update navbar cart count
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
// Update cart counts and check if cart is empty
await refreshCartContents();
} catch (error) {
handleApiError(error, 'removing item from cart');
} finally {
hideLoading();
}
}
// Clear entire cart using apiJson
async function clearCart() {
// Hide the modal first
const modal = bootstrap.Modal.getInstance(document.getElementById('clearCartModal'));
modal.hide();
showLoading();
try {
await window.apiJson('/api/cart', { method: 'DELETE' });
// Emit and update counts, then reload to ensure consistent empty state
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(0); }
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
try { sessionStorage.setItem('cartCleared', '1'); } catch (_) { /* storage may be blocked */ }
setTimeout(() => { window.location.reload(); }, 50);
} catch (error) {
handleApiError(error, 'clearing cart');
} finally {
hideLoading();
}
}
// Change display currency using apiJson
async function changeCurrency() {
const currencySelector = document.getElementById('currencySelector');
const selectedCurrency = currencySelector.value;
showLoading();
try {
await window.apiJson('/api/user/currency', {
method: 'POST',
body: JSON.stringify({
currency: selectedCurrency
})
});
// Reload page to show prices in new currency
window.location.reload();
} catch (error) {
handleApiError(error, 'changing currency');
} finally {
hideLoading();
}
}
// Refresh cart contents using apiJson
async function refreshCartContents() {
try {
// Fetch fresh cart data from server
const data = await window.apiJson('/api/cart', {
method: 'GET',
cache: 'no-store'
});
// Check if cart is empty and update UI accordingly
const cartItems = data.items || [];
if (cartItems.length === 0) {
// Show empty cart state
const cartContainer = document.querySelector('.cart-items-container');
if (cartContainer) {
cartContainer.innerHTML = `
<div class="text-center py-5">
<i class="bi bi-cart-x display-1 text-muted"></i>
<h3 class="mt-3">Your cart is empty</h3>
<p class="text-muted">Add some items to get started!</p>
<a href="/marketplace" class="btn btn-primary">Browse Marketplace</a>
</div>
`;
}
setMarketplaceSummaryEmpty();
}
} catch (error) {
console.error('Error refreshing cart contents:', error);
// Don't show error toast for this background operation
}
}
// Add to cart functionality for product pages
async function addToCartFromPage(productId, quantity = 1, buttonElement = null) {
if (buttonElement) {
setButtonLoading(buttonElement, 'Adding...');
}
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
product_id: productId,
quantity: quantity
})
});
if (buttonElement) {
setButtonSuccess(buttonElement, 'Added!');
}
showSuccessToast('Item added to cart');
// Update cart count in navbar
if (typeof window.updateCartCount === 'function') {
window.updateCartCount();
}
} catch (error) {
handleApiError(error, 'adding to cart', buttonElement);
}
}
// Utility functions
function showLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.classList.remove('d-none');
}
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.classList.add('d-none');
}
}
// Initialize recommended products functionality
function initializeRecommendedProducts() {
// Add event listeners for recommended product add-to-cart buttons
document.querySelectorAll('.recommended-product .add-to-cart-btn').forEach(btn => {
btn.addEventListener('click', function() {
const productId = this.dataset.productId;
if (productId) {
addToCartFromPage(productId, 1, this);
}
});
});
}
// Make functions available globally for backward compatibility
window.addToCartFromPage = addToCartFromPage;
window.refreshCartContents = refreshCartContents;

357
src/static/js/cart.js Normal file
View File

@@ -0,0 +1,357 @@
/*
Cart interactions (CSP compliant)
- Parses hydration JSON from #cart-hydration
- Binds all cart-related events (increase/decrease qty, remove, clear, currency change)
- Handles guest checkout modal buttons
- Shows toasts via createToast helpers here
*/
(function () {
'use strict';
let hydration = {
item_count: 0,
redirect_login_url: '/login?checkout=true',
redirect_register_url: '/register?checkout=true',
redirect_after_auth: '/cart'
};
function readHydration() {
try {
const el = document.getElementById('cart-hydration');
if (!el) return;
const parsed = JSON.parse(el.textContent || '{}');
hydration = Object.assign(hydration, parsed || {});
} catch (e) {
console.warn('Failed to parse cart hydration JSON', e);
}
}
function showLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.remove('d-none');
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.add('d-none');
}
function createToast(message, type, icon) {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
toast.style.top = '80px';
toast.style.zIndex = '10000';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="bi ${icon} me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
if (window.bootstrap && window.bootstrap.Toast) {
const bsToast = new window.bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
}
}
function showError(message) { createToast(message, 'danger', 'bi-exclamation-triangle'); }
function showSuccess(message) { createToast(message, 'success', 'bi-check-circle'); }
async function parseResponse(response) {
let json = {};
try { json = await response.json(); } catch (_) {}
const payload = (json && (json.data || json)) || {};
const success = (typeof json.success === 'boolean') ? json.success : (typeof payload.success === 'boolean' ? payload.success : response.ok);
return { json, payload, success };
}
async function updateQuantity(productId, newQuantity) {
if (newQuantity < 1) {
await removeFromCart(productId);
return;
}
showLoading();
try {
await window.apiJson(`/api/cart/item/${encodeURIComponent(productId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ quantity: newQuantity })
});
{
showSuccess('Quantity updated successfully');
setTimeout(() => window.location.reload(), 500);
}
} catch (e) {
showError(e && e.message ? e.message : 'Network error occurred');
} finally {
hideLoading();
}
}
let productIdToRemove = null;
function showClearCartModal() {
const el = document.getElementById('clearCartModal');
if (!el || !window.bootstrap) return;
const modal = new window.bootstrap.Modal(el);
modal.show();
}
function showRemoveItemModal(productId) {
productIdToRemove = productId;
const el = document.getElementById('removeItemModal');
if (!el || !window.bootstrap) return;
const modal = new window.bootstrap.Modal(el);
modal.show();
}
async function confirmRemoveItem() {
if (!productIdToRemove) return;
const el = document.getElementById('removeItemModal');
if (el && window.bootstrap) {
const modal = window.bootstrap.Modal.getInstance(el);
if (modal) modal.hide();
}
await removeFromCart(productIdToRemove);
productIdToRemove = null;
}
async function removeFromCart(productId) {
showLoading();
try {
await window.apiJson(`/api/cart/item/${encodeURIComponent(productId)}`, {
method: 'DELETE'
});
{
showSuccess('Item removed from cart');
setTimeout(() => window.location.reload(), 500);
}
} catch (e) {
showError(e && e.message ? e.message : 'Network error occurred');
} finally {
hideLoading();
}
}
async function clearCart() {
const clearEl = document.getElementById('clearCartModal');
if (clearEl && window.bootstrap) {
const modal = window.bootstrap.Modal.getInstance(clearEl);
if (modal) modal.hide();
}
showLoading();
try {
await window.apiJson('/api/cart', { method: 'DELETE' });
{
showSuccess('Cart cleared successfully');
setTimeout(() => window.location.reload(), 500);
}
} catch (e) {
showError(e && e.message ? e.message : 'Network error occurred');
} finally {
hideLoading();
}
}
async function changeCurrency() {
const currencySelector = document.getElementById('currencySelector');
if (!currencySelector) return;
const selectedCurrency = currencySelector.value;
showLoading();
try {
await window.apiJson('/api/user/currency', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ currency: selectedCurrency })
});
{
showSuccess('Currency updated');
setTimeout(() => window.location.reload(), 500);
}
} catch (e) {
showError(e && e.message ? e.message : 'Network error occurred');
} finally {
hideLoading();
}
}
function saveForLater() {
showSuccess('Cart saved for later');
}
function shareCart() {
const shareData = {
title: 'My ThreeFold Cart',
text: 'Check out my ThreeFold marketplace cart',
url: window.location.href
};
if (navigator.share) {
navigator.share(shareData).catch(() => {});
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(window.location.href)
.then(() => showSuccess('Cart link copied to clipboard'))
.catch(() => {});
}
}
// Refresh cart contents after small delay (used by recommended add)
function refreshCartContents() {
setTimeout(() => { window.location.reload(); }, 500);
}
// Initialize recommended products add-to-cart buttons
function initializeRecommendedProducts() {
document.querySelectorAll('.add-recommended-btn').forEach(button => {
button.addEventListener('click', function () {
const productId = this.getAttribute('data-product-id');
const productName = this.getAttribute('data-product-name') || 'Product';
const productCategory = this.getAttribute('data-product-category') || '';
addRecommendedToCart(productId, productName, productCategory, this);
});
});
}
async function addRecommendedToCart(productId, productName, productCategory, buttonEl) {
if (!productId || !buttonEl) return;
const original = buttonEl.innerHTML;
buttonEl.disabled = true;
buttonEl.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Adding...';
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ product_id: productId, quantity: 1, specifications: {} })
});
buttonEl.innerHTML = '<i class="bi bi-check me-1"></i>Added!';
buttonEl.classList.remove('btn-primary', 'btn-success', 'btn-info', 'btn-warning');
buttonEl.classList.add('btn-success');
createToast(`${productName} added to cart!`, 'success', 'bi-check-circle');
if (typeof window.updateCartCount === 'function') window.updateCartCount();
try { if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated(undefined); } catch (_) {}
updateMyOrdersVisibility();
refreshCartContents();
setTimeout(() => {
buttonEl.innerHTML = original;
buttonEl.disabled = false;
buttonEl.classList.remove('btn-success');
if (productCategory === 'compute') buttonEl.classList.add('btn-primary');
else if (productCategory === 'storage') buttonEl.classList.add('btn-success');
else if (productCategory === 'gateways') buttonEl.classList.add('btn-info');
else if (productCategory === 'applications') buttonEl.classList.add('btn-warning');
else buttonEl.classList.add('btn-primary');
}, 2000);
} catch (e) {
console.error('Error adding recommended product:', e);
if (e && e.status === 402) {
return;
}
buttonEl.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
buttonEl.classList.add('btn-danger');
createToast(`Failed to add ${productName} to cart. Please try again.`, 'danger', 'bi-exclamation-triangle');
setTimeout(() => {
buttonEl.innerHTML = original;
buttonEl.disabled = false;
buttonEl.classList.remove('btn-danger');
if (productCategory === 'compute') buttonEl.classList.add('btn-primary');
else if (productCategory === 'storage') buttonEl.classList.add('btn-success');
else if (productCategory === 'gateways') buttonEl.classList.add('btn-info');
else if (productCategory === 'applications') buttonEl.classList.add('btn-warning');
else buttonEl.classList.add('btn-primary');
}, 3000);
}
}
// Toggle visibility of My Orders link based on API
async function updateMyOrdersVisibility() {
const myOrdersLink = document.getElementById('myOrdersLink');
if (!myOrdersLink) return;
try {
const data = await window.apiJson('/api/orders');
const hasOrders = !!(data && Array.isArray(data.orders) && data.orders.length > 0);
myOrdersLink.style.display = hasOrders ? 'inline-block' : 'none';
} catch (e) {
myOrdersLink.style.display = 'none';
}
}
function bindEvents() {
// Quantity controls
document.querySelectorAll('[data-action="increase"], [data-action="decrease"]').forEach(button => {
button.addEventListener('click', function () {
const productId = this.getAttribute('data-product-id');
const action = this.getAttribute('data-action');
const qtySpan = this.parentElement.querySelector('span');
const currentQuantity = parseInt(qtySpan && qtySpan.textContent, 10) || 0;
if (action === 'increase') updateQuantity(productId, currentQuantity + 1);
else if (action === 'decrease') updateQuantity(productId, currentQuantity - 1);
});
});
// Remove buttons
document.querySelectorAll('[data-action="remove"]').forEach(button => {
button.addEventListener('click', function () {
const productId = this.getAttribute('data-product-id');
showRemoveItemModal(productId);
});
});
// Clear cart
const clearCartBtn = document.getElementById('clearCartBtn');
if (clearCartBtn) clearCartBtn.addEventListener('click', showClearCartModal);
const confirmClearCartBtn = document.getElementById('confirmClearCartBtn');
if (confirmClearCartBtn) confirmClearCartBtn.addEventListener('click', clearCart);
const confirmRemoveItemBtn = document.getElementById('confirmRemoveItemBtn');
if (confirmRemoveItemBtn) confirmRemoveItemBtn.addEventListener('click', confirmRemoveItem);
// Currency selector
const currencySelector = document.getElementById('currencySelector');
if (currencySelector) currencySelector.addEventListener('change', changeCurrency);
// Extra actions
document.querySelectorAll('[data-action="save-for-later"]').forEach(btn => btn.addEventListener('click', saveForLater));
document.querySelectorAll('[data-action="share-cart"]').forEach(btn => btn.addEventListener('click', shareCart));
// Guest checkout modal buttons
const loginBtn = document.getElementById('guestLoginBtn');
const registerBtn = document.getElementById('guestRegisterBtn');
if (loginBtn) {
loginBtn.addEventListener('click', function () {
try { sessionStorage.setItem('redirectAfterLogin', hydration.redirect_after_auth || '/cart'); } catch (_) {}
window.location.href = hydration.redirect_login_url || '/login?checkout=true';
});
}
if (registerBtn) {
registerBtn.addEventListener('click', function () {
try { sessionStorage.setItem('redirectAfterRegister', hydration.redirect_after_auth || '/cart'); } catch (_) {}
window.location.href = hydration.redirect_register_url || '/register?checkout=true';
});
}
// Checkout CTA (guest) fallback: open modal if button present
const checkoutBtn = document.getElementById('checkoutBtn');
if (checkoutBtn) {
checkoutBtn.addEventListener('click', function () {
const el = document.getElementById('guestCheckoutModal');
if (el && window.bootstrap) {
const modal = new window.bootstrap.Modal(el);
modal.show();
}
});
}
}
document.addEventListener('DOMContentLoaded', function () {
readHydration();
bindEvents();
initializeRecommendedProducts();
updateMyOrdersVisibility();
});
})();

162
src/static/js/checkout.js Normal file
View File

@@ -0,0 +1,162 @@
/* eslint-disable no-console */
(function () {
'use strict';
// Hydration loader
function getHydration() {
try {
const el = document.getElementById('checkout-hydration');
if (!el) return {};
const text = el.textContent || el.innerText || '';
if (!text.trim()) return {};
const parsed = JSON.parse(text);
return parsed && parsed.data ? parsed.data : parsed; // tolerate ResponseBuilder-style or raw
} catch (e) {
console.warn('Failed to parse checkout hydration JSON:', e);
return {};
}
}
// Price formatting helpers (client-side polish only; server already formats)
function formatPrice(priceText) {
const match = String(priceText).match(/(\d+\.?\d*)\s*([A-Z]{3})/);
if (match) {
const number = parseFloat(match[1]);
const currency = match[2];
const formatted = isFinite(number) ? number.toFixed(2) : match[1];
return `${formatted} ${currency}`;
}
return null;
}
function formatPriceDisplays() {
const subtotalElement = document.getElementById('subtotal-display');
if (subtotalElement) {
const formatted = formatPrice(subtotalElement.textContent);
if (formatted) subtotalElement.textContent = formatted;
}
const totalElement = document.getElementById('total-display');
if (totalElement) {
const formatted = formatPrice(totalElement.textContent);
if (formatted) totalElement.textContent = formatted;
}
const priceElements = document.querySelectorAll('.fw-bold.text-primary');
priceElements.forEach((el) => {
if (el.id !== 'total-display') {
const formatted = formatPrice(el.textContent);
if (formatted) el.textContent = formatted;
}
});
}
// UI helpers
function showLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.remove('d-none');
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.add('d-none');
}
function createToast(message, type, icon) {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
toast.style.top = '80px';
toast.style.zIndex = '10000';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="bi ${icon} me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
try {
// Bootstrap 5 toast
// eslint-disable-next-line no-undef
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
} catch (_) {
// Fallback
setTimeout(() => toast.remove(), 4000);
}
}
function showError(message) { createToast(message, 'danger', 'bi-exclamation-triangle'); }
function showSuccess(message) { createToast(message, 'success', 'bi-check-circle'); }
// Core action
async function processPayment(userCurrency) {
showLoading();
try {
const body = {
payment_method: {
method_type: 'wallet',
details: { source: 'usd_credits' },
},
currency: userCurrency || 'USD',
cart_items: [], // server constructs order from session cart; keep for forward-compat
};
const data = await window.apiJson('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (data) {
showSuccess('Order placed successfully!');
if (typeof window.loadNavbarData === 'function') { window.loadNavbarData(); }
if (typeof window.refreshOrders === 'function') { window.refreshOrders(); }
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
// Try to clear server-side cart (best-effort)
try { await window.apiJson('/api/cart', { method: 'DELETE' }); } catch (_) {}
const orderId = data && (data.order_id || data.id);
const confirmation = data && (data.confirmation_number || data.confirmation);
setTimeout(() => {
if (orderId) {
const url = confirmation
? `/orders/${orderId}/confirmation?confirmation=${encodeURIComponent(confirmation)}`
: `/orders/${orderId}/confirmation`;
window.location.href = url;
} else {
showError('Order created but no order ID returned by server.');
}
}, 2000);
}
} catch (e) {
if (e && e.status === 402) {
// Let global interceptor handle insufficient funds UI
return;
}
showError(e && e.message ? e.message : 'Network error occurred. Please try again.');
} finally {
hideLoading();
}
}
// Expose for debugging if needed
window.checkoutProcessPayment = processPayment;
// Init
document.addEventListener('DOMContentLoaded', function () {
const hydration = getHydration();
const userCurrency = hydration && hydration.user_currency ? hydration.user_currency : 'USD';
formatPriceDisplays();
const btn = document.getElementById('complete-order-btn');
if (btn) {
btn.addEventListener('click', function () {
processPayment(userCurrency);
});
}
});
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,678 @@
/**
* Dashboard Messages Page - Full-page messaging interface
* Follows Project Mycelium design patterns and CSP compliance
*/
class DashboardMessaging {
constructor() {
this.currentThread = null;
this.threads = [];
this.unreadCount = 0;
this.userEmail = null;
this.pollInterval = null;
this.isInitialized = false;
this.init();
}
async init() {
if (this.isInitialized) return;
try {
// Get user email from hydration data
this.getUserEmail();
// Set up event listeners
this.setupEventListeners();
// Load conversations
await this.loadConversations();
// Check for URL parameters to auto-open conversation
await this.handleUrlParameters();
// Start polling for updates
this.startPolling();
this.isInitialized = true;
console.log('📨 Dashboard messaging initialized for user:', this.userEmail);
} catch (error) {
console.error('Failed to initialize dashboard messaging:', error);
this.showError('Failed to initialize messaging system');
}
}
getUserEmail() {
// Get from hydration data
const hydrationData = document.getElementById('messages-hydration');
if (hydrationData) {
try {
const data = JSON.parse(hydrationData.textContent);
if (data.user_email) {
this.userEmail = data.user_email;
window.currentUserEmail = data.user_email;
return;
}
} catch (e) {
console.error('Error parsing messages hydration data:', e);
}
}
// Fallback to global messaging system detection
if (window.messagingSystem && window.messagingSystem.getCurrentUserEmail) {
this.userEmail = window.messagingSystem.getCurrentUserEmail();
}
if (!this.userEmail) {
console.warn('Could not determine user email for dashboard messaging');
}
}
setupEventListeners() {
// Refresh button
document.getElementById('refreshMessagesBtn')?.addEventListener('click', () => {
this.loadConversations();
});
// Send message button
document.getElementById('sendMessageBtn')?.addEventListener('click', () => {
this.sendMessage();
});
// Message input - Enter key
document.getElementById('messageInput')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
// Character count
document.getElementById('messageInput')?.addEventListener('input', (e) => {
const count = e.target.value.length;
document.getElementById('messageCharCount').textContent = count;
// Update button state
const sendBtn = document.getElementById('sendMessageBtn');
if (sendBtn) {
sendBtn.disabled = count === 0 || count > 1000;
}
});
// Mark as read button
document.getElementById('markAsReadBtn')?.addEventListener('click', () => {
if (this.currentThread) {
this.markThreadAsRead(this.currentThread.thread_id);
}
});
}
async loadConversations() {
const loadingEl = document.getElementById('conversationsLoading');
const emptyEl = document.getElementById('conversationsEmpty');
const listEl = document.getElementById('conversationsList');
// Show loading state
loadingEl?.classList.remove('d-none');
emptyEl?.classList.add('d-none');
try {
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
this.threads = data.threads || [];
this.unreadCount = data.unread_count || 0;
console.log('📨 Loaded conversations:', this.threads.length, 'unread:', this.unreadCount);
// Update UI
this.renderConversationsList();
this.updateUnreadBadges();
} catch (error) {
console.error('Error loading conversations:', error);
this.showError('Failed to load conversations');
} finally {
loadingEl?.classList.add('d-none');
}
}
renderConversationsList() {
const listEl = document.getElementById('conversationsList');
const emptyEl = document.getElementById('conversationsEmpty');
const countEl = document.getElementById('totalConversationsCount');
if (!listEl) return;
// Update count
if (countEl) {
countEl.textContent = this.threads.length;
}
if (this.threads.length === 0) {
listEl.innerHTML = '';
emptyEl?.classList.remove('d-none');
return;
}
emptyEl?.classList.add('d-none');
// Render conversations
listEl.innerHTML = this.threads.map(thread => {
const isUnread = thread.unread_count > 0;
const lastMessage = thread.last_message || {};
const timeAgo = this.formatTimeAgo(lastMessage.timestamp);
return `
<div class="list-group-item list-group-item-action ${isUnread ? 'border-start border-primary border-3' : ''}"
data-thread-id="${thread.thread_id}"
style="cursor: pointer;">
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<h6 class="mb-0 ${isUnread ? 'fw-bold' : ''}">${this.escapeHtml(thread.subject || 'Conversation')}</h6>
${isUnread ? `<span class="badge bg-danger rounded-pill">${thread.unread_count}</span>` : ''}
</div>
<p class="mb-1 text-muted small">
<i class="bi bi-person me-1"></i>${this.escapeHtml(thread.recipient_email)}
</p>
${lastMessage.content ? `
<p class="mb-1 small text-truncate" style="max-width: 200px;">
${this.escapeHtml(lastMessage.content)}
</p>
` : ''}
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
<i class="bi bi-tag me-1"></i>${thread.context_type.replace('_', ' ')}
</small>
${timeAgo ? `<small class="text-muted">${timeAgo}</small>` : ''}
</div>
</div>
</div>
</div>
`;
}).join('');
// Add click handlers
listEl.querySelectorAll('[data-thread-id]').forEach(item => {
item.addEventListener('click', () => {
const threadId = item.dataset.threadId;
this.selectConversation(threadId);
});
});
}
async selectConversation(threadId) {
const thread = this.threads.find(t => t.thread_id === threadId);
if (!thread) return;
this.currentThread = thread;
// Update UI state
this.updateConversationHeader();
this.showMessageInterface();
// Load messages
await this.loadMessages(threadId);
// Mark as read if it has unread messages
if (thread.unread_count > 0) {
await this.markThreadAsRead(threadId);
}
// Update active state in list
document.querySelectorAll('#conversationsList .list-group-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-thread-id="${threadId}"]`)?.classList.add('active');
}
updateConversationHeader() {
const headerEl = document.getElementById('conversationHeader');
const titleEl = document.getElementById('conversationTitle');
const participantEl = document.getElementById('conversationParticipant');
const contextEl = document.getElementById('conversationContext');
if (!this.currentThread || !headerEl) return;
headerEl.classList.remove('d-none');
if (titleEl) {
titleEl.textContent = this.currentThread.subject || 'Conversation';
}
if (participantEl) {
participantEl.textContent = `with ${this.currentThread.recipient_email}`;
}
if (contextEl) {
contextEl.textContent = this.currentThread.context_type.replace('_', ' ');
contextEl.className = `badge bg-${this.getContextColor(this.currentThread.context_type)}`;
}
}
showMessageInterface() {
// Hide welcome, show message interface
document.getElementById('messagesWelcome')?.classList.add('d-none');
document.getElementById('messagesContainer')?.classList.remove('d-none');
document.getElementById('messageInputContainer')?.classList.remove('d-none');
// Enable input
const input = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendMessageBtn');
if (input) {
input.disabled = false;
input.focus();
}
if (sendBtn) {
sendBtn.disabled = input?.value.trim().length === 0;
}
}
async loadMessages(threadId) {
const container = document.getElementById('messagesContainer');
if (!container) return;
try {
const data = await window.apiJson(`/api/messages/threads/${threadId}/messages`, { cache: 'no-store' });
const messages = data.messages || [];
this.renderMessages(messages);
} catch (error) {
console.error('Error loading messages:', error);
this.showError('Failed to load messages');
}
}
renderMessages(messages) {
const container = document.getElementById('messagesContainer');
if (!container) return;
if (messages.length === 0) {
container.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi bi-chat-dots fs-1 mb-3"></i>
<p>No messages yet. Start the conversation!</p>
</div>
`;
return;
}
container.innerHTML = messages.map(message => {
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
const messageTime = new Date(message.timestamp).toLocaleString();
const senderName = isOwnMessage ? 'You' : message.sender_email;
return `
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
<div class="message-wrapper" style="max-width: 70%;">
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${this.escapeHtml(senderName)}</div>` : ''}
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm"
style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
<div class="message-content">${this.escapeHtml(message.content)}</div>
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
</div>
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${this.escapeHtml(senderName)}</div>` : ''}
</div>
</div>
`;
}).join('');
// Scroll to bottom
container.scrollTop = container.scrollHeight;
}
async sendMessage() {
const input = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendMessageBtn');
if (!input || !this.currentThread) return;
const content = input.value.trim();
if (!content) return;
// Disable input during send
input.disabled = true;
sendBtn.disabled = true;
sendBtn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
try {
const messageData = {
thread_id: this.currentThread.thread_id,
content: content,
message_type: 'text'
};
const response = await window.apiJson('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messageData)
});
// Add message to UI immediately
this.addMessageToUI(response.message);
// Clear input
input.value = '';
document.getElementById('messageCharCount').textContent = '0';
// Refresh conversations list to update last message
await this.loadConversations();
} catch (error) {
console.error('Error sending message:', error);
this.showError('Failed to send message');
} finally {
// Re-enable input
input.disabled = false;
sendBtn.disabled = false;
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
input.focus();
}
}
addMessageToUI(message) {
const container = document.getElementById('messagesContainer');
if (!container) return;
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
const messageTime = new Date(message.timestamp).toLocaleString();
const senderName = isOwnMessage ? 'You' : message.sender_email;
const messageHTML = `
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
<div class="message-wrapper" style="max-width: 70%;">
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${this.escapeHtml(senderName)}</div>` : ''}
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm"
style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
<div class="message-content">${this.escapeHtml(message.content)}</div>
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
</div>
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${this.escapeHtml(senderName)}</div>` : ''}
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', messageHTML);
container.scrollTop = container.scrollHeight;
}
async markThreadAsRead(threadId) {
try {
await window.apiJson(`/api/messages/threads/${threadId}/read`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
}
});
// Update local state
const thread = this.threads.find(t => t.thread_id === threadId);
if (thread && thread.unread_count > 0) {
const readCount = thread.unread_count;
this.unreadCount -= readCount;
thread.unread_count = 0;
// Update UI
this.updateUnreadBadges();
this.renderConversationsList();
// Notify global notification system
if (window.notificationSystem) {
window.notificationSystem.markAsRead(readCount);
}
// Dispatch custom event
document.dispatchEvent(new CustomEvent('messageRead', {
detail: { threadId, count: readCount }
}));
}
} catch (error) {
console.error('Error marking thread as read:', error);
}
}
startPolling() {
if (this.pollInterval) return;
this.pollInterval = setInterval(async () => {
try {
const previousUnreadCount = this.unreadCount;
await this.loadConversations();
// Check if we received new messages
if (this.unreadCount > previousUnreadCount) {
// Dispatch event for notification system
document.dispatchEvent(new CustomEvent('messageReceived', {
detail: {
count: this.unreadCount - previousUnreadCount,
senderEmail: 'another user'
}
}));
}
// If we have a current thread open, refresh its messages
if (this.currentThread) {
await this.loadMessages(this.currentThread.thread_id);
}
} catch (error) {
console.error('Error polling for messages:', error);
}
}, 15000); // Poll every 15 seconds
}
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
updateUnreadBadges() {
// Update global notification badges
const badges = document.querySelectorAll('.message-badge, .unread-messages-badge, .navbar-message-badge');
badges.forEach(badge => {
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
});
}
// Utility methods
formatTimeAgo(timestamp) {
if (!timestamp) return '';
const now = new Date();
const messageTime = new Date(timestamp);
const diffMs = now - messageTime;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return messageTime.toLocaleDateString();
}
getContextColor(contextType) {
const colors = {
'service_booking': 'primary',
'slice_rental': 'success',
'app_deployment': 'info',
'general': 'secondary',
'support': 'warning'
};
return colors[contextType] || 'secondary';
}
async handleUrlParameters() {
const urlParams = new URLSearchParams(window.location.search);
const recipient = urlParams.get('recipient');
const context = urlParams.get('context');
const subject = urlParams.get('subject');
const bookingId = urlParams.get('booking_id');
if (recipient) {
console.log('📨 Auto-opening conversation with:', recipient);
console.log('📨 Available threads:', this.threads.map(t => ({
id: t.thread_id,
recipient: t.recipient_email,
subject: t.subject,
context_id: t.context_id,
context_type: t.context_type
})));
// Find existing thread with this recipient AND booking ID (if provided)
let existingThread;
if (bookingId) {
console.log('📨 Looking for thread with booking ID:', bookingId);
console.log('📨 Searching threads for recipient:', recipient, 'and context_id:', bookingId);
// For service bookings, look for thread with matching booking ID
existingThread = this.threads.find(thread => {
const matches = thread.recipient_email === recipient && thread.context_id === bookingId;
console.log('📨 Thread check:', {
thread_id: thread.thread_id,
recipient_match: thread.recipient_email === recipient,
context_id_match: thread.context_id === bookingId,
thread_context_id: thread.context_id,
target_booking_id: bookingId,
overall_match: matches
});
return matches;
});
} else {
// For general messages, find any thread with recipient
existingThread = this.threads.find(thread =>
thread.recipient_email === recipient
);
}
console.log('📨 Found existing thread:', existingThread);
if (existingThread) {
// Open existing conversation
console.log('📨 Opening existing conversation:', existingThread.thread_id);
await this.selectConversation(existingThread.thread_id);
} else {
// Start new conversation
console.log('📨 Starting new conversation with:', recipient, 'for booking:', bookingId);
await this.startNewConversation(recipient, context, subject, bookingId);
}
// Clean up URL parameters
window.history.replaceState({}, document.title, '/dashboard/messages');
}
}
async startNewConversation(recipient, context = 'general', subject = '', bookingId = null) {
try {
// Create a new thread first
const requestData = {
recipient_email: recipient,
context_type: context,
context_id: bookingId,
subject: subject || `Service Booking #${bookingId || 'General'}`
};
console.log('📨 Creating new thread with data:', requestData);
const response = await apiJson('/api/messages/threads', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
if (response.success && response.data) {
// Reload conversations to show the new thread
await this.loadConversations();
// Select the new thread by matching both recipient and context
const newThread = this.threads.find(thread =>
thread.recipient_email === recipient &&
thread.context_id === bookingId
);
if (newThread) {
await this.selectConversation(newThread.thread_id);
}
}
} catch (error) {
console.error('Error starting new conversation:', error);
this.showError('Failed to start conversation with provider');
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showSuccess(message) {
const toast = document.getElementById('successToast');
const body = document.getElementById('successToastBody');
if (toast && body) {
body.textContent = message;
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
}
}
showError(message) {
const toast = document.getElementById('errorToast');
const body = document.getElementById('errorToastBody');
if (toast && body) {
body.textContent = message;
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
}
}
formatTimeAgo(timestamp) {
if (!timestamp) return '';
const now = new Date();
const messageTime = new Date(timestamp);
const diffMs = now - messageTime;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return messageTime.toLocaleDateString();
}
destroy() {
this.stopPolling();
this.isInitialized = false;
}
}
// Initialize dashboard messaging when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
window.dashboardMessaging = new DashboardMessaging();
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.dashboardMessaging) {
window.dashboardMessaging.destroy();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
/**
* Dashboard settings page functionality
* Handles currency preferences, profile updates, password changes, notifications, and account deletion
*/
document.addEventListener('DOMContentLoaded', function() {
// Load notification preferences on page load
loadNotificationPreferences();
// Currency preference form
const currencyForm = document.getElementById('currencyForm');
if (currencyForm) {
currencyForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const currency = formData.get('display_currency');
const submitBtn = this.querySelector('button[type="submit"]');
try {
window.setButtonLoading(submitBtn, 'Updating...');
await window.apiJson('/api/user/currency', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ currency: currency })
});
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
showNotification('Currency preference updated successfully', 'success');
// Reload page to reflect currency changes
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
console.error('Error updating currency:', error);
showNotification('Error updating currency preference: ' + error.message, 'error');
window.resetButton(submitBtn);
}
});
}
// Profile update form
const profileForm = document.getElementById('profileForm');
if (profileForm) {
profileForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
try {
window.setButtonLoading(submitBtn, 'Updating...');
const result = await window.apiJson('/api/dashboard/settings/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams(formData)
});
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
showNotification(result.message || 'Profile updated successfully', 'success');
} catch (error) {
console.error('Error updating profile:', error);
showNotification('Error updating profile: ' + error.message, 'error');
window.resetButton(submitBtn);
}
});
}
// Password change form
const passwordForm = document.getElementById('passwordForm');
if (passwordForm) {
passwordForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
try {
window.setButtonLoading(submitBtn, 'Updating...');
const result = await window.apiJson('/api/dashboard/settings/password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams(formData)
});
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
showNotification(result.message || 'Password updated successfully', 'success');
this.reset(); // Clear form
} catch (error) {
console.error('Error updating password:', error);
showNotification('Error updating password: ' + error.message, 'error');
window.resetButton(submitBtn);
}
});
}
// Notifications form
const notificationsForm = document.getElementById('notificationsForm');
if (notificationsForm) {
notificationsForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
// Convert FormData to object with proper boolean values
const data = {
email_security_alerts: formData.get('email_security_alerts') === 'on',
email_billing_alerts: formData.get('email_billing_alerts') === 'on',
email_system_alerts: formData.get('email_system_alerts') === 'on',
email_newsletter: formData.get('email_newsletter') === 'on',
dashboard_alerts: formData.get('dashboard_alerts') === 'on',
dashboard_updates: formData.get('dashboard_updates') === 'on'
};
try {
window.setButtonLoading(submitBtn, 'Updating...');
const result = await window.apiJson('/api/dashboard/settings/notifications', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams(data)
});
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
showNotification(result.message || 'Notification preferences updated successfully', 'success');
saveNotificationPreferences(data);
} catch (error) {
console.error('Error updating notifications:', error);
showNotification('Error updating notification preferences: ' + error.message, 'error');
window.resetButton(submitBtn);
}
});
}
// Account deletion functionality
const deleteAccountForm = document.getElementById('deleteAccountForm');
if (deleteAccountForm) {
deleteAccountForm.addEventListener('submit', async function(e) {
e.preventDefault();
const confirmationInput = document.getElementById('deleteConfirmation');
const confirmation = confirmationInput.value;
const passwordInput = document.getElementById('deletePassword');
const passwordFeedback = document.getElementById('deletePasswordFeedback');
const password = passwordInput.value;
// reset previous error state
passwordInput.classList.remove('is-invalid');
// Validate checkbox and confirmation text
const checkbox = document.getElementById('deleteCheck');
if (!checkbox.checked) {
showNotification('Please confirm that you understand this action cannot be undone', 'warning');
return;
}
if (confirmation !== 'DELETE') {
showNotification('Please type DELETE exactly to confirm', 'warning');
return;
}
// Ensure password is provided
if (!password || password.trim() === '') {
passwordInput.classList.add('is-invalid');
if (passwordFeedback) passwordFeedback.textContent = 'Enter your current password.';
passwordInput.focus();
showNotification('Enter your current password.', 'error');
return;
}
// Pre-verify password with backend before showing final confirmation
try {
const verifyResult = await window.apiJson('/api/dashboard/settings/verify-password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams({ password })
});
// Password verification successful - apiJson throws on error, so if we get here it's valid
} catch (err) {
// Handle password verification failure
const msg = err.message || 'The password you entered is incorrect. Please try again.';
passwordInput.classList.add('is-invalid');
if (passwordFeedback) passwordFeedback.textContent = msg;
passwordInput.focus();
showNotification('Error verifying password: ' + msg, 'error');
return;
}
// Show custom confirmation modal instead of browser popup
if (!await showDeleteConfirmationModal()) {
return;
}
const data = {
confirmation: confirmation,
password: password
};
try {
console.log('[DeleteAccount] Payload about to send:', data);
const result = await window.apiJson('/api/dashboard/settings/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams(data)
});
console.log('[DeleteAccount] Response JSON:', result);
// Success
showNotification(result.message || 'Account deleted successfully', 'success');
// Redirect after a brief delay
setTimeout(() => {
window.location.href = '/';
}, 2000);
} catch (error) {
console.error('Error deleting account:', error);
// Handle password-related errors inline
if (error.message && error.message.toLowerCase().includes('password')) {
passwordInput.classList.add('is-invalid');
if (passwordFeedback) {
passwordFeedback.textContent = error.message;
}
passwordInput.focus();
}
showNotification('Error deleting account: ' + error.message, 'error');
}
});
// Clear inline error when user edits password
const pwd = document.getElementById('deletePassword');
const pwdFeedback = document.getElementById('deletePasswordFeedback');
if (pwd) {
pwd.addEventListener('input', () => {
pwd.classList.remove('is-invalid');
if (pwdFeedback) pwdFeedback.textContent = '';
});
}
}
});
// Notification function
function showNotification(message, type = 'info') {
// Remove existing notifications
const existingNotifications = document.querySelectorAll('.settings-notification');
existingNotifications.forEach(notification => notification.remove());
// Create new notification
const notification = document.createElement('div');
notification.className = `alert alert-${getBootstrapAlertClass(type)} alert-dismissible fade show settings-notification`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
max-width: 500px;
`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
function getBootstrapAlertClass(type) {
switch (type) {
case 'success': return 'success';
case 'error': return 'danger';
case 'warning': return 'warning';
case 'info':
default: return 'info';
}
}
// Function to load existing notification preferences
async function loadNotificationPreferences() {
try {
// For now, we'll use localStorage to persist preferences across sessions
// In a real app, this would come from the server
const preferences = {
email_security_alerts: localStorage.getItem('email_security_alerts') !== 'false',
email_billing_alerts: localStorage.getItem('email_billing_alerts') !== 'false',
email_system_alerts: localStorage.getItem('email_system_alerts') !== 'false',
email_newsletter: localStorage.getItem('email_newsletter') === 'true',
dashboard_alerts: localStorage.getItem('dashboard_alerts') !== 'false',
dashboard_updates: localStorage.getItem('dashboard_updates') !== 'false'
};
// Apply preferences to form elements
const emailSecurityAlerts = document.getElementById('emailSecurityAlerts');
const emailBillingAlerts = document.getElementById('emailBillingAlerts');
const emailSystemAlerts = document.getElementById('emailSystemAlerts');
const emailNewsletter = document.getElementById('emailNewsletter');
const dashboardAlerts = document.getElementById('dashboardAlerts');
const dashboardUpdates = document.getElementById('dashboardUpdates');
if (emailSecurityAlerts) emailSecurityAlerts.checked = preferences.email_security_alerts;
if (emailBillingAlerts) emailBillingAlerts.checked = preferences.email_billing_alerts;
if (emailSystemAlerts) emailSystemAlerts.checked = preferences.email_system_alerts;
if (emailNewsletter) emailNewsletter.checked = preferences.email_newsletter;
if (dashboardAlerts) dashboardAlerts.checked = preferences.dashboard_alerts;
if (dashboardUpdates) dashboardUpdates.checked = preferences.dashboard_updates;
console.log('Loaded notification preferences:', preferences);
} catch (error) {
console.error('Error loading notification preferences:', error);
}
}
// Function to save notification preferences to localStorage
function saveNotificationPreferences(data) {
try {
localStorage.setItem('email_security_alerts', data.email_security_alerts);
localStorage.setItem('email_billing_alerts', data.email_billing_alerts);
localStorage.setItem('email_system_alerts', data.email_system_alerts);
localStorage.setItem('email_newsletter', data.email_newsletter);
localStorage.setItem('dashboard_alerts', data.dashboard_alerts);
localStorage.setItem('dashboard_updates', data.dashboard_updates);
console.log('Saved notification preferences to localStorage');
} catch (error) {
console.error('Error saving notification preferences:', error);
}
}
// Custom delete confirmation modal functionality
function showDeleteConfirmationModal() {
return new Promise((resolve) => {
const modal = new bootstrap.Modal(document.getElementById('deleteConfirmationModal'));
const finalConfirmationInput = document.getElementById('finalConfirmationInput');
const finalDeleteButton = document.getElementById('finalDeleteButton');
const countdownTimer = document.getElementById('countdownTimer');
const deleteButtonText = document.getElementById('deleteButtonText');
const deleteButtonSpinner = document.getElementById('deleteButtonSpinner');
let countdownInterval;
let countdownActive = false;
// Reset modal state
finalConfirmationInput.value = '';
finalDeleteButton.disabled = true;
deleteButtonText.textContent = 'Confirm Deletion';
deleteButtonSpinner.classList.add('d-none');
countdownTimer.textContent = '10';
// Handle input validation
finalConfirmationInput.addEventListener('input', function() {
const isValid = this.value.toUpperCase() === 'I UNDERSTAND';
finalDeleteButton.disabled = !isValid;
if (isValid && !countdownActive) {
startCountdown();
} else if (!isValid && countdownActive) {
stopCountdown();
}
});
// Handle final delete button click
finalDeleteButton.addEventListener('click', function() {
if (finalConfirmationInput.value.toUpperCase() === 'I UNDERSTAND') {
// Show loading state
deleteButtonText.textContent = 'Deleting Account...';
deleteButtonSpinner.classList.remove('d-none');
finalDeleteButton.disabled = true;
modal.hide();
resolve(true);
}
});
// Handle modal close
document.getElementById('deleteConfirmationModal').addEventListener('hidden.bs.modal', function() {
stopCountdown();
if (deleteButtonText.textContent === 'Deleting Account...') {
// Don't resolve if deletion is in progress
return;
}
resolve(false);
});
function startCountdown() {
countdownActive = true;
let timeLeft = 10;
countdownInterval = setInterval(() => {
timeLeft--;
countdownTimer.textContent = timeLeft;
if (timeLeft <= 0) {
stopCountdown();
finalDeleteButton.disabled = false;
countdownTimer.textContent = '0';
countdownTimer.parentElement.innerHTML = '<span class="text-success fw-bold">Ready to proceed</span>';
}
}, 1000);
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
countdownActive = false;
countdownTimer.textContent = '10';
countdownTimer.parentElement.innerHTML = 'Deletion will proceed in <span id="countdownTimer" class="fw-bold text-danger">10</span> seconds after confirmation';
}
modal.show();
});
}

View File

@@ -0,0 +1,533 @@
/* eslint-disable no-console */
(function () {
'use strict';
// Prevent duplicate execution if script is included more than once
if (window.__dashboardSSHKeysScriptLoaded) {
console.debug('dashboard-ssh-keys.js already loaded; skipping init');
return;
}
window.__dashboardSSHKeysScriptLoaded = true;
// Safe JSON parsing utility
function safeParseJSON(text, fallback) {
try {
return JSON.parse(text);
} catch (_) {
return fallback;
}
}
// Get SSH keys data from hydration
function getSSHKeysData() {
const el = document.getElementById('ssh-keys-data');
if (!el) return [];
return safeParseJSON(el.textContent || el.innerText || '[]', []);
}
// Format date for display
function formatDate(dateString) {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (_) {
return 'Invalid date';
}
}
// Format SSH key type for display
function formatKeyType(keyType) {
const typeMap = {
'ssh-ed25519': 'Ed25519',
'ssh-rsa': 'RSA',
'ecdsa-sha2-nistp256': 'ECDSA P-256',
'ecdsa-sha2-nistp384': 'ECDSA P-384',
'ecdsa-sha2-nistp521': 'ECDSA P-521'
};
return typeMap[keyType] || keyType;
}
// Get security level for key type
function getSecurityLevel(keyType) {
const securityMap = {
'ssh-ed25519': 'High',
'ecdsa-sha2-nistp256': 'High',
'ecdsa-sha2-nistp384': 'Very High',
'ecdsa-sha2-nistp521': 'Very High',
'ssh-rsa': 'Medium to High'
};
return securityMap[keyType] || 'Unknown';
}
// Show loading state
function showLoading(element, text = 'Loading...') {
const spinner = element.querySelector('.spinner-border');
const textElement = element.querySelector('[id$="Text"]');
if (spinner) spinner.classList.remove('d-none');
if (textElement) textElement.textContent = text;
element.disabled = true;
}
// Hide loading state
function hideLoading(element, originalText) {
const spinner = element.querySelector('.spinner-border');
const textElement = element.querySelector('[id$="Text"]');
if (spinner) spinner.classList.add('d-none');
if (textElement) textElement.textContent = originalText;
element.disabled = false;
}
// Show notification
function showNotification(message, type = 'success') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `
<i class="bi bi-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
// Validate SSH key format
function validateSSHKey(publicKey) {
if (!publicKey || !publicKey.trim()) {
return { valid: false, error: 'SSH key cannot be empty' };
}
const trimmedKey = publicKey.trim();
const parts = trimmedKey.split(/\s+/);
if (parts.length < 2) {
return { valid: false, error: 'Invalid SSH key format. Expected format: "type base64-key [comment]"' };
}
const keyType = parts[0];
const validTypes = ['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'];
if (!validTypes.includes(keyType)) {
return { valid: false, error: 'Unsupported key type. Please use Ed25519, ECDSA, or RSA keys.' };
}
// Basic base64 validation
const keyData = parts[1];
if (!/^[A-Za-z0-9+/]+=*$/.test(keyData)) {
return { valid: false, error: 'Invalid key encoding. Please check your key format.' };
}
return {
valid: true,
keyType: formatKeyType(keyType),
securityLevel: getSecurityLevel(keyType)
};
}
// Render SSH keys list
function renderSSHKeys(sshKeys) {
const container = document.getElementById('sshKeysList');
const noKeysMessage = document.getElementById('noSSHKeysMessage');
const template = document.getElementById('sshKeyTemplate');
if (!container || !template) return;
// Clear existing content except no keys message
const existingItems = container.querySelectorAll('.ssh-key-item');
existingItems.forEach(item => item.remove());
if (!sshKeys || sshKeys.length === 0) {
noKeysMessage.classList.remove('d-none');
return;
}
noKeysMessage.classList.add('d-none');
sshKeys.forEach(sshKey => {
const keyElement = template.cloneNode(true);
keyElement.classList.remove('d-none');
keyElement.id = `ssh-key-${sshKey.id}`;
// Set data-key-id on the actual ssh-key-item div (not the wrapper)
const sshKeyItem = keyElement.querySelector('.ssh-key-item');
if (sshKeyItem) {
sshKeyItem.dataset.keyId = sshKey.id;
console.log('🔧 DEBUG: Set data-key-id on ssh-key-item:', sshKey.id, sshKeyItem);
} else {
console.error('❌ ERROR: Could not find .ssh-key-item in template!');
}
// Populate key information
keyElement.querySelector('.ssh-key-name').textContent = sshKey.name;
keyElement.querySelector('.ssh-key-type').textContent = formatKeyType(sshKey.key_type);
keyElement.querySelector('.ssh-key-fingerprint').textContent = sshKey.fingerprint;
keyElement.querySelector('.ssh-key-created').textContent = `Added: ${formatDate(sshKey.created_at)}`;
keyElement.querySelector('.ssh-key-last-used').textContent = `Last used: ${formatDate(sshKey.last_used)}`;
// Show/hide default badge
const defaultBadge = keyElement.querySelector('.ssh-key-default');
if (sshKey.is_default) {
defaultBadge.classList.remove('d-none');
}
// Update button states
const setDefaultBtn = keyElement.querySelector('.set-default-btn');
if (sshKey.is_default) {
setDefaultBtn.textContent = 'Default';
setDefaultBtn.disabled = true;
setDefaultBtn.classList.add('btn-success');
setDefaultBtn.classList.remove('btn-outline-primary');
}
container.appendChild(keyElement);
});
// Attach event listeners to new elements
attachKeyEventListeners();
}
// Load SSH keys from server
async function loadSSHKeys() {
try {
const data = await window.apiJson('/api/dashboard/ssh-keys');
renderSSHKeys((data && data.ssh_keys) || []);
} catch (error) {
console.error('Error loading SSH keys:', error);
showNotification('Failed to load SSH keys. Please refresh the page.', 'danger');
}
}
// Add SSH key
async function addSSHKey(formData) {
try {
await window.apiJson('/api/dashboard/ssh-keys', {
method: 'POST',
body: {
name: formData.get('name'),
public_key: formData.get('public_key'),
is_default: formData.get('is_default') === 'on'
}
});
showNotification('SSH key added successfully!', 'success');
// Reload SSH keys
await loadSSHKeys();
return true;
} catch (error) {
console.error('Error adding SSH key:', error);
showNotification(error.message || 'Failed to add SSH key', 'danger');
return false;
}
}
// Delete SSH key
async function deleteSSHKey(keyId) {
try {
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}`, {
method: 'DELETE'
});
showNotification('SSH key deleted successfully!', 'success');
// Reload SSH keys
await loadSSHKeys();
return true;
} catch (error) {
console.error('Error deleting SSH key:', error);
showNotification(error.message || 'Failed to delete SSH key', 'danger');
return false;
}
}
// Set default SSH key
async function setDefaultSSHKey(keyId) {
try {
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}/set-default`, {
method: 'POST'
});
showNotification('Default SSH key updated successfully!', 'success');
// Reload SSH keys
await loadSSHKeys();
return true;
} catch (error) {
console.error('Error setting default SSH key:', error);
showNotification(error.message || 'Failed to set default SSH key', 'danger');
return false;
}
}
// Update SSH key
async function updateSSHKey(keyId, name, isDefault) {
try {
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}`, {
method: 'PUT',
body: {
name: name,
is_default: isDefault
}
});
showNotification('SSH key updated successfully!', 'success');
// Reload SSH keys
await loadSSHKeys();
return true;
} catch (error) {
console.error('Error updating SSH key:', error);
showNotification(error.message || 'Failed to update SSH key', 'danger');
return false;
}
}
// Attach event listeners to SSH key items
function attachKeyEventListeners() {
// Set default buttons
document.querySelectorAll('.set-default-btn').forEach(btn => {
if (!btn.disabled) {
btn.addEventListener('click', async function() {
const keyItem = this.closest('.ssh-key-item');
const keyId = keyItem?.dataset?.keyId;
// Debug logging
console.log('Set Default clicked:', { keyItem, keyId });
if (!keyId) {
console.error('No key ID found for set default operation');
showNotification('Error: Could not identify SSH key to set as default', 'danger');
return;
}
showLoading(this, 'Setting...');
const success = await setDefaultSSHKey(keyId);
if (!success) {
hideLoading(this, 'Set Default');
}
});
}
});
// Edit buttons
document.querySelectorAll('.edit-ssh-key-btn').forEach(btn => {
btn.addEventListener('click', function() {
const keyItem = this.closest('.ssh-key-item');
const keyId = keyItem?.dataset?.keyId;
const keyName = keyItem?.querySelector('.ssh-key-name')?.textContent;
const isDefault = keyItem?.querySelector('.ssh-key-default') && !keyItem.querySelector('.ssh-key-default').classList.contains('d-none');
// Debug logging
console.log('Edit clicked:', { keyItem, keyId, keyName, isDefault });
if (!keyId) {
console.error('No key ID found for edit operation');
showNotification('Error: Could not identify SSH key to edit', 'danger');
return;
}
// Populate edit modal
const modal = document.getElementById('editSSHKeyModal');
if (modal) {
document.getElementById('editSSHKeyId').value = keyId;
document.getElementById('editSSHKeyName').value = keyName || '';
document.getElementById('editSetAsDefault').checked = isDefault || false;
new bootstrap.Modal(modal).show();
}
});
});
// Delete buttons
document.querySelectorAll('.delete-ssh-key-btn').forEach(btn => {
btn.addEventListener('click', function() {
const keyItem = this.closest('.ssh-key-item');
const keyId = keyItem?.dataset?.keyId;
const keyName = keyItem?.querySelector('.ssh-key-name')?.textContent;
// Debug logging
console.log('Delete clicked:', { keyItem, keyId, keyName });
if (!keyId) {
console.error('No key ID found for delete operation');
showNotification('Error: Could not identify SSH key to delete', 'danger');
return;
}
// Show delete confirmation modal
const modal = document.getElementById('deleteSSHKeyModal');
if (modal) {
document.getElementById('deleteSSHKeyId').value = keyId;
document.getElementById('deleteSSHKeyName').textContent = keyName || 'Unknown Key';
new bootstrap.Modal(modal).show();
}
});
});
}
// Initialize SSH key management
function initSSHKeyManagement() {
// Add SSH key form
const addForm = document.getElementById('addSSHKeyForm');
if (addForm) {
addForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('addSSHKeySubmit');
const formData = new FormData(this);
// Validate SSH key first
const validation = validateSSHKey(formData.get('public_key'));
const feedbackEl = document.getElementById('keyValidationFeedback');
if (!validation.valid) {
feedbackEl.classList.remove('d-none');
document.getElementById('keyValidationSuccess').classList.add('d-none');
document.getElementById('keyValidationError').classList.remove('d-none');
document.getElementById('keyValidationErrorText').textContent = validation.error;
return;
}
feedbackEl.classList.add('d-none');
showLoading(submitBtn, 'Adding...');
const success = await addSSHKey(formData);
if (success) {
// Close modal and reset form
const modal = bootstrap.Modal.getInstance(document.getElementById('addSSHKeyModal'));
if (modal) modal.hide();
this.reset();
}
hideLoading(submitBtn, 'Add SSH Key');
});
}
// Add SSH key button
const addBtn = document.getElementById('addSSHKeyBtn');
if (addBtn) {
addBtn.addEventListener('click', function() {
const modal = new bootstrap.Modal(document.getElementById('addSSHKeyModal'));
modal.show();
});
}
// Edit SSH key form
const editForm = document.getElementById('editSSHKeyForm');
if (editForm) {
editForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('editSSHKeySubmit');
const formData = new FormData(this);
const keyId = formData.get('keyId');
const name = formData.get('name');
const isDefault = formData.get('is_default') === 'on';
// Debug logging
console.log('Edit form submit:', { keyId, name, isDefault });
if (!keyId || keyId.trim() === '') {
console.error('Edit form submitted without valid key ID');
showNotification('Error: No SSH key ID provided for update', 'danger');
return;
}
showLoading(submitBtn, 'Updating...');
const success = await updateSSHKey(keyId, name, isDefault);
if (success) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editSSHKeyModal'));
if (modal) modal.hide();
}
hideLoading(submitBtn, 'Update SSH Key');
});
}
// Delete SSH key form
const deleteForm = document.getElementById('deleteSSHKeyForm');
if (deleteForm) {
deleteForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('deleteSSHKeySubmit');
const keyId = document.getElementById('deleteSSHKeyId').value;
// Debug logging
console.log('Delete form submit:', { keyId });
if (!keyId || keyId.trim() === '') {
console.error('Delete form submitted without valid key ID');
showNotification('Error: No SSH key ID provided for deletion', 'danger');
return;
}
showLoading(submitBtn, 'Deleting...');
const success = await deleteSSHKey(keyId);
if (success) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteSSHKeyModal'));
if (modal) modal.hide();
}
hideLoading(submitBtn, 'Delete SSH Key');
});
}
// SSH key validation on input
const publicKeyInput = document.getElementById('sshPublicKey');
if (publicKeyInput) {
publicKeyInput.addEventListener('input', function() {
const validation = validateSSHKey(this.value);
const feedbackEl = document.getElementById('keyValidationFeedback');
if (this.value.trim() === '') {
feedbackEl.classList.add('d-none');
return;
}
feedbackEl.classList.remove('d-none');
if (validation.valid) {
document.getElementById('keyValidationSuccess').classList.remove('d-none');
document.getElementById('keyValidationError').classList.add('d-none');
document.getElementById('keyValidationSuccessText').textContent =
`Valid ${validation.keyType} key detected! Security level: ${validation.securityLevel}`;
} else {
document.getElementById('keyValidationSuccess').classList.add('d-none');
document.getElementById('keyValidationError').classList.remove('d-none');
document.getElementById('keyValidationErrorText').textContent = validation.error;
}
});
}
// Load SSH keys on page load
loadSSHKeys();
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSSHKeyManagement);
} else {
initSSHKeyManagement();
}
})();

File diff suppressed because it is too large Load Diff

189
src/static/js/dashboard.js Normal file
View File

@@ -0,0 +1,189 @@
/* eslint-disable no-console */
(function () {
'use strict';
// Prevent duplicate execution if script is included more than once
if (window.__dashboardMainScriptLoaded) {
console.debug('dashboard.js already loaded; skipping init');
return;
}
window.__dashboardMainScriptLoaded = true;
function safeParseJSON(text, fallback) {
try { return JSON.parse(text); } catch (_) { return fallback; }
}
function getHydratedData() {
const el = document.getElementById('dashboard-chart-data');
if (!el) return null;
return safeParseJSON(el.textContent || el.innerText || '{}', null);
}
function defaultChartData(displayCurrency) {
return {
displayCurrency: displayCurrency || 'USD',
resourceUtilization: { cpu: 0, memory: 0, storage: 0, network: 0 },
creditsUsageTrend: [0, 0, 0, 0, 0, 0],
userActivity: { deployments: [0, 0, 0, 0, 0, 0], resourceReservations: [0, 0, 0, 0, 0, 0] },
deploymentDistribution: {
regions: [], nodes: [], slices: [], apps: [], gateways: []
}
};
}
async function updateDashboardWalletBalance() {
try {
const data = await window.apiJson('/api/navbar/dropdown-data', { cache: 'no-store' }) || {};
const balanceEl = document.getElementById('dashboardWalletBalance');
if (balanceEl && data && data.wallet_balance_formatted) {
balanceEl.textContent = data.wallet_balance_formatted;
}
const codeEl = document.getElementById('dashboardCurrencyCode');
if (codeEl && data && data.display_currency) {
codeEl.textContent = data.display_currency;
}
} catch (_) {
// silent
}
}
function initCharts() {
if (typeof Chart === 'undefined') return; // Chart.js not loaded
// Global defaults
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.responsive = true;
Chart.defaults.maintainAspectRatio = false;
const hydrated = getHydratedData();
const data = hydrated || defaultChartData('USD');
// Resource Utilization Overview Chart
const resCtxEl = document.getElementById('resourceUtilizationOverviewChart');
if (resCtxEl) {
const ctx = resCtxEl.getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: ['CPU', 'Memory', 'Storage', 'Network'],
datasets: [{
label: 'Current Usage (%)',
data: [
data.resourceUtilization.cpu,
data.resourceUtilization.memory,
data.resourceUtilization.storage,
data.resourceUtilization.network
],
backgroundColor: [
'rgba(0, 123, 255, 0.7)',
'rgba(40, 167, 69, 0.7)',
'rgba(255, 193, 7, 0.7)',
'rgba(23, 162, 184, 0.7)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Resource Utilization Overview' } },
scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: 'Utilization %' } } }
}
});
}
// Credits Usage Overview Chart
const creditsCtxEl = document.getElementById('creditsUsageOverviewChart');
if (creditsCtxEl) {
const ctx = creditsCtxEl.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Monthly Credits Usage',
data: data.creditsUsageTrend,
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
borderWidth: 2,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Credits Monthly Usage Trend' } },
scales: { y: { beginAtZero: true, title: { display: true, text: `Credits (${data.displayCurrency || 'USD'})` } } }
}
});
}
// User Activity Chart
const userActivityEl = document.getElementById('userActivityChart');
if (userActivityEl) {
const ctx = userActivityEl.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5', 'Week 6'],
datasets: [{
label: 'Deployments',
data: data.userActivity.deployments,
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.0)',
borderWidth: 2,
tension: 0.3
}, {
label: 'Resource Reservations',
data: data.userActivity.resourceReservations,
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.0)',
borderWidth: 2,
tension: 0.3
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' }, title: { display: true, text: 'User Activity' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } }
}
});
}
// Deployment Distribution Chart
const distEl = document.getElementById('deploymentDistributionChart');
if (distEl) {
const ctx = distEl.getContext('2d');
const dd = data.deploymentDistribution || { regions: [], nodes: [], slices: [], apps: [], gateways: [] };
const labels = (dd.regions && dd.regions.length) ? dd.regions : ['No Deployments'];
const valOrZero = arr => (Array.isArray(arr) && arr.length ? arr : [0]);
new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Nodes', data: valOrZero(dd.nodes), backgroundColor: '#007bff', borderWidth: 1 },
{ label: 'Slices', data: valOrZero(dd.slices), backgroundColor: '#28a745', borderWidth: 1 },
{ label: 'Apps', data: valOrZero(dd.apps), backgroundColor: '#ffc107', borderWidth: 1 },
{ label: 'Gateways', data: valOrZero(dd.gateways), backgroundColor: '#17a2b8', borderWidth: 1 }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' }, title: { display: true, text: 'Deployment Distribution by Region' } },
scales: {
x: { stacked: true, title: { display: true, text: 'Regions' } },
y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Number of Deployments' } }
}
}
});
}
}
document.addEventListener('DOMContentLoaded', function () {
initCharts();
updateDashboardWalletBalance();
});
})();

View File

@@ -0,0 +1,605 @@
(function () {
'use strict';
// Read hydration data safely
function readHydration(id) {
try {
const el = document.getElementById(id);
if (!el) return {};
const txt = el.textContent || el.innerText || '';
if (!txt.trim()) return {};
return JSON.parse(txt);
} catch (e) {
return {};
}
}
const hyd = readHydration('hydration-dashboard-cart');
const currencySymbol = (hyd && hyd.currency_symbol) || '$';
const displayCurrency = (hyd && hyd.display_currency) || 'USD';
const showToast = (window.showToast) ? window.showToast : function (msg, type) {
// Fallback: log to console in case toast helper isn't available
const prefix = type === 'error' ? '[error]' : '[info]';
try { console.log(prefix, msg); } catch (_) {}
};
// Central 402 handler wrapper
async function handle402(response, preReadText) {
// Rely on global fetch interceptor in base.js to render the modal
// This function now only signals the caller to stop normal error flow
if (!response || response.status !== 402) return false;
return true;
}
const getCartItems = window.getCartItems || function () { return []; };
// Suppress cart load error toast in specific flows (e.g., right after clear)
window._suppressCartLoadToast = false;
document.addEventListener('DOMContentLoaded', function () {
// Initial loads
loadCartItems();
loadWalletBalance();
// Listen for cart updates
window.addEventListener('cartUpdated', function () {
loadCartItems();
updateCartSummary();
});
// Post-reload success toast for cart clear (logged-in)
try {
if (sessionStorage.getItem('cartCleared') === '1') {
sessionStorage.removeItem('cartCleared');
showToast('Cart cleared', 'success');
}
} catch (_) { /* storage may be unavailable */ }
});
// Event delegation for all clickable actions
document.addEventListener('click', function (e) {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.getAttribute('data-action');
if (!action) return;
switch (action) {
case 'increase-qty':
e.preventDefault();
increaseQuantity(actionEl);
break;
case 'decrease-qty':
e.preventDefault();
decreaseQuantity(actionEl);
break;
case 'remove-item':
e.preventDefault();
removeCartItem(actionEl);
break;
case 'save-for-later':
e.preventDefault();
saveCartForLater();
break;
case 'share-cart':
e.preventDefault();
shareCart();
break;
case 'proceed-checkout':
e.preventDefault();
proceedToCheckout();
break;
case 'confirm-clear-cart':
e.preventDefault();
clearCartConfirm();
break;
default:
break;
}
}, false);
async function loadCartItems() {
try {
// Fetch cart data from server API instead of localStorage
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const cartItems = (cartData && cartData.items) ? cartData.items : [];
const container = document.getElementById('cartItemsContainer');
const emptyMessage = document.getElementById('emptyCartMessage');
if (cartItems.length === 0) {
emptyMessage.style.display = 'block';
container.innerHTML = '';
container.appendChild(emptyMessage);
const checkoutBtn = document.getElementById('checkoutBtn');
const clearCartBtn = document.getElementById('clearCartBtn');
if (checkoutBtn) checkoutBtn.disabled = true;
if (clearCartBtn) clearCartBtn.disabled = true;
// Reset summary to zero when cart is empty
const subtotalEl = document.getElementById('cartSubtotal');
const totalEl = document.getElementById('cartTotal');
const deployEl = document.getElementById('cartDeployTime');
if (subtotalEl) subtotalEl.textContent = `${currencySymbol}0.00`;
if (totalEl) totalEl.textContent = `${currencySymbol}0.00`;
if (deployEl) deployEl.textContent = '0 minutes';
return;
}
emptyMessage.style.display = 'none';
container.innerHTML = '';
cartItems.forEach(item => {
const itemElement = createCartItemElement(item);
container.appendChild(itemElement);
});
const checkoutBtn = document.getElementById('checkoutBtn');
const clearCartBtn = document.getElementById('clearCartBtn');
if (checkoutBtn) checkoutBtn.disabled = false;
if (clearCartBtn) clearCartBtn.disabled = false;
// Update cart summary with server data
updateCartSummary(cartData);
} catch (error) {
console.error('Error loading cart items:', error);
// Only show error toast for actual server errors (and not when suppressed)
if (!window._suppressCartLoadToast) {
if (error.message && !error.message.includes('404') && !error.message.includes('empty')) {
showToast('Failed to load cart items', 'error');
}
}
// Fallback to empty state (this is normal when cart is empty)
const container = document.getElementById('cartItemsContainer');
const emptyMessage = document.getElementById('emptyCartMessage');
emptyMessage.style.display = 'block';
container.innerHTML = '';
container.appendChild(emptyMessage);
const checkoutBtn = document.getElementById('checkoutBtn');
const clearCartBtn = document.getElementById('clearCartBtn');
if (checkoutBtn) checkoutBtn.disabled = true;
if (clearCartBtn) clearCartBtn.disabled = true;
} finally {
// Always reset suppression flag after attempt
window._suppressCartLoadToast = false;
}
}
function createCartItemElement(item) {
const template = document.getElementById('cartItemTemplate');
const element = template.content.cloneNode(true);
const container = element.querySelector('.cart-item');
container.setAttribute('data-item-id', item.product_id);
// Use correct field names from cart API response
element.querySelector('.service-name').textContent = item.product_name || 'Unknown Service';
element.querySelector('.service-specs').textContent = formatSpecs(item.specifications);
element.querySelector('.service-price').textContent = item.total_price || '0.00 USD';
// Set the quantity display
element.querySelector('.quantity-display').textContent = item.quantity || 1;
// Show provider name without quantity (quantity is now in controls)
element.querySelector('.added-time').textContent = item.provider_name || 'Provider';
return element;
}
function formatSpecs(specs) {
if (!specs || Object.keys(specs).length === 0) {
return 'Standard configuration';
}
const specParts = [];
if (specs.cpu) specParts.push(`${specs.cpu} CPU`);
if (specs.memory) specParts.push(`${specs.memory}GB RAM`);
if (specs.storage) specParts.push(`${specs.storage}GB Storage`);
if (specs.bandwidth) specParts.push(`${specs.bandwidth} Bandwidth`);
if (specParts.length === 0) {
for (const [key, value] of Object.entries(specs)) {
if (value !== null && value !== undefined) {
specParts.push(`${key}: ${value}`);
}
}
}
return specParts.length > 0 ? specParts.join(' • ') : 'Standard configuration';
}
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
return date.toLocaleDateString();
}
function updateCartSummary(cartData) {
// Use server cart data if provided, otherwise fetch from localStorage as fallback
let cartItems, subtotal, total;
if (cartData) {
cartItems = cartData.items || [];
subtotal = cartData.subtotal || '0.00';
total = cartData.total || '0.00';
} else {
cartItems = getCartItems();
subtotal = cartItems.reduce((sum, item) => sum + (item.price_usd || item.price_tfc || 0), 0);
total = subtotal;
}
const deployTime = cartItems.length * 2; // Estimate 2 minutes per service
const subtotalValue = typeof subtotal === 'string' ? parseFloat(subtotal.replace(/[^0-9.-]/g, '')) : subtotal;
const totalValue = typeof total === 'string' ? parseFloat(total.replace(/[^0-9.-]/g, '')) : total;
const subtotalEl = document.getElementById('cartSubtotal');
const totalEl = document.getElementById('cartTotal');
const deployEl = document.getElementById('cartDeployTime');
if (subtotalEl) subtotalEl.textContent = `${currencySymbol}${subtotalValue.toFixed(2)}`;
if (totalEl) totalEl.textContent = `${currencySymbol}${totalValue.toFixed(2)}`;
if (deployEl) deployEl.textContent = `${deployTime} minutes`;
// Update balance indicator with USD amount
updateBalanceIndicator(totalValue);
// Update checkout button state based on cart contents
const checkoutBtn = document.getElementById('checkoutBtn');
const clearCartBtn = document.getElementById('clearCartBtn');
if (cartItems.length === 0 || totalValue <= 0) {
if (checkoutBtn) checkoutBtn.disabled = true;
if (clearCartBtn) clearCartBtn.disabled = true;
} else {
if (checkoutBtn) checkoutBtn.disabled = false;
if (clearCartBtn) clearCartBtn.disabled = false;
}
// Load current wallet balance and update display
loadWalletBalance();
}
function updateBalanceIndicator(totalCost) {
const balanceIndicator = document.getElementById('balanceIndicator');
const checkoutBtn = document.getElementById('checkoutBtn');
if (!balanceIndicator) return;
if (totalCost === 0) {
balanceIndicator.innerHTML = '';
if (checkoutBtn) checkoutBtn.disabled = true;
return;
}
const numericTotal = Number(totalCost);
if (!Number.isFinite(numericTotal) || numericTotal <= 0) {
balanceIndicator.innerHTML = '';
if (checkoutBtn) checkoutBtn.disabled = true;
return;
}
(async () => {
try {
const payload = await window.apiJson(`/api/wallet/check-affordability?amount=${numericTotal.toFixed(2)}`, { method: 'GET' });
if (payload && payload.can_afford) {
balanceIndicator.innerHTML = `
<div class="alert alert-success py-2 mb-0">
<i class="bi bi-check-circle me-1"></i>
<small>Sufficient ${displayCurrency} credits for checkout</small>
</div>
`;
if (checkoutBtn) checkoutBtn.disabled = false;
} else {
const shortfall = (payload && payload.shortfall != null
? Number(payload.shortfall)
: (payload && payload.shortfall_info && Number(payload.shortfall_info.shortfall))
) || 0;
balanceIndicator.innerHTML = `
<div class="alert alert-warning py-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<small>Insufficient ${displayCurrency} credits. Need ${currencySymbol}${shortfall.toFixed(2)} more.</small>
<br><a href="/dashboard/wallet" class="btn btn-sm btn-outline-primary mt-1">
<i class="bi bi-plus-circle me-1"></i>Top Up Wallet
</a>
</div>
`;
if (checkoutBtn) checkoutBtn.disabled = true;
}
} catch (error) {
// Let global 402 handler display modal; suppress extra UI noise here
if (error && error.status === 402) {
return;
}
console.error('Error checking affordability:', error);
balanceIndicator.innerHTML = `
<div class="alert alert-secondary py-2 mb-0">
<i class="bi bi-info-circle me-1"></i>
<small>Unable to verify balance. Please try again.</small>
</div>
`;
if (checkoutBtn) checkoutBtn.disabled = true;
}
})();
}
// Load current wallet balance from API
function loadWalletBalance() {
(async () => {
try {
const payload = await window.apiJson('/api/wallet/balance', { method: 'GET' });
const balance = parseFloat(payload.balance) || parseFloat(payload.new_balance) || 0;
const balEl = document.getElementById('userBalance');
if (balEl) balEl.textContent = `${currencySymbol}${balance.toFixed(2)}`;
} catch (error) {
console.error('Error loading wallet balance:', error);
// Keep existing balance display on error
}
})();
}
async function removeCartItem(button) {
const cartItem = button.closest('.cart-item');
if (!cartItem) return;
const itemId = cartItem.getAttribute('data-item-id');
// Show loading state
button.disabled = true;
button.innerHTML = '<i class="bi bi-hourglass-split"></i>';
try {
// Use server API to remove item
await window.apiJson(`/api/cart/item/${itemId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
// Remove item from DOM immediately for better UX
cartItem.style.transition = 'opacity 0.3s ease';
cartItem.style.opacity = '0';
setTimeout(() => {
cartItem.remove();
// Update cart summary after removal
updateCartSummary();
// Re-check affordability based on current displayed total
const totalEl = document.getElementById('cartTotal');
const currentTotal = totalEl ? parseFloat(totalEl.textContent.replace(/[^0-9.-]/g, '')) : 0;
updateBalanceIndicator(currentTotal);
}, 300);
showToast('Item removed from cart', 'success');
// Update navbar cart count immediately
if (window.updateCartCount) {
window.updateCartCount();
}
// Update cart summary after DOM changes
updateCartSummary();
// Check if cart is now empty and handle UI accordingly
const remainingItems = document.querySelectorAll('.cart-item');
if (remainingItems.length === 0) {
const container = document.getElementById('cartItemsContainer');
const emptyMessage = document.getElementById('emptyCartMessage');
if (emptyMessage) {
emptyMessage.style.display = 'block';
container.innerHTML = '';
container.appendChild(emptyMessage);
const checkoutBtn = document.getElementById('checkoutBtn');
const clearCartBtn = document.getElementById('clearCartBtn');
if (checkoutBtn) checkoutBtn.disabled = true;
if (clearCartBtn) clearCartBtn.disabled = true;
}
}
} catch (error) {
console.error('Error removing cart item:', error);
if (error && error.status === 402) {
// Reset button state if blocked by insufficient funds
button.disabled = false;
button.innerHTML = '<i class="bi bi-trash"></i>';
return;
}
showToast(`Failed to remove item: ${error.message}`, 'error');
// Reset button state on error
button.disabled = false;
button.innerHTML = '<i class="bi bi-trash"></i>';
}
}
// Increase quantity of cart item
async function increaseQuantity(button) {
const cartItem = button.closest('.cart-item');
if (!cartItem) return;
const itemId = cartItem.getAttribute('data-item-id');
const quantityDisplay = cartItem.querySelector('.quantity-display');
const currentQuantity = parseInt(quantityDisplay.textContent);
const newQuantity = currentQuantity + 1;
await updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem);
}
// Decrease quantity of cart item
async function decreaseQuantity(button) {
const cartItem = button.closest('.cart-item');
if (!cartItem) return;
const itemId = cartItem.getAttribute('data-item-id');
const quantityDisplay = cartItem.querySelector('.quantity-display');
const currentQuantity = parseInt(quantityDisplay.textContent);
if (currentQuantity <= 1) {
// If quantity is 1 or less, remove the item instead
const removeBtn = cartItem.querySelector('[data-action="remove-item"]');
if (removeBtn) removeCartItem(removeBtn);
return;
}
const newQuantity = currentQuantity - 1;
await updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem);
}
// Update cart item quantity via API
async function updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem) {
// Show loading state
const originalQuantity = quantityDisplay.textContent;
quantityDisplay.textContent = '...';
try {
await window.apiJson(`/api/cart/item/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity: newQuantity })
});
// Update quantity display
quantityDisplay.textContent = newQuantity;
// Calculate and update the new total price for this item
const priceElement = cartItem.querySelector('.service-price');
const currentTotalText = priceElement.textContent;
const currentTotal = parseFloat(currentTotalText.replace(/[^0-9.-]/g, ''));
const oldQuantity = parseInt(originalQuantity);
const unitPrice = currentTotal / oldQuantity;
const newTotalPrice = unitPrice * newQuantity;
// Update the price display with new total
priceElement.textContent = `${currencySymbol}${newTotalPrice.toFixed(2)}`;
// Fetch fresh cart data and update summary
try {
const freshCartData = await window.apiJson('/api/cart', { cache: 'no-store' });
if (freshCartData) updateCartSummary(freshCartData);
} catch (_) { /* ignore refresh error */ }
// Update navbar count
if (window.updateCartCount) {
window.updateCartCount();
}
showToast(`Quantity updated to ${newQuantity}`, 'success');
} catch (error) {
console.error('Error updating quantity:', error);
if (error && error.status === 402) {
// Restore original quantity if blocked
quantityDisplay.textContent = originalQuantity;
return;
}
// Restore original quantity on error
quantityDisplay.textContent = originalQuantity;
showToast(`Failed to update quantity: ${error.message}`, 'error');
}
}
async function proceedToCheckout() {
try {
// Check server cart state before proceeding
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const cartItems = cartData.items || [];
if (cartItems.length === 0) {
showToast('Cart is empty', 'error');
return;
}
// Navigate to checkout
window.location.href = '/checkout';
} catch (error) {
console.error('Error checking cart before checkout:', error);
showToast('Failed to proceed to checkout', 'error');
}
}
async function clearCartConfirm() {
// Hide modal if open
const modalEl = document.getElementById('clearCartModal');
if (modalEl && window.bootstrap && typeof window.bootstrap.Modal?.getInstance === 'function') {
const modalInstance = window.bootstrap.Modal.getInstance(modalEl);
if (modalInstance) modalInstance.hide();
}
try {
// Use server API to clear cart
await window.apiJson('/api/cart', { method: 'DELETE' });
// Emit event and update navbar first, then reload page to ensure fresh state
window._suppressCartLoadToast = true;
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(0); }
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
try { sessionStorage.setItem('cartCleared', '1'); } catch (_) {}
setTimeout(() => { window.location.reload(); }, 50);
} catch (error) {
console.error('Error clearing cart:', error);
if (error && error.status === 402) {
return;
}
showToast('Failed to clear cart', 'error');
}
}
async function saveCartForLater() {
try {
// Get cart data from server
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const cartItems = cartData.items || [];
if (cartItems.length === 0) {
showToast('Cart is empty', 'error');
return;
}
// Save to localStorage with timestamp for later retrieval
localStorage.setItem('saved_cart_' + Date.now(), JSON.stringify(cartItems));
showToast('Cart saved for later', 'success');
} catch (error) {
console.error('Error saving cart for later:', error);
showToast('Failed to save cart', 'error');
}
}
async function shareCart() {
try {
// Get cart data from server
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const cartItems = cartData.items || [];
if (cartItems.length === 0) {
showToast('Cart is empty', 'error');
return;
}
// Create shareable cart data using server response format
const shareData = {
items: cartItems.map(item => ({
product_id: item.product_id,
name: item.name,
price: item.price,
quantity: item.quantity
})),
subtotal: cartData.subtotal,
total: cartData.total,
currency: cartData.currency,
created: new Date().toISOString()
};
// Copy to clipboard
navigator.clipboard.writeText(JSON.stringify(shareData, null, 2)).then(() => {
showToast('Cart data copied to clipboard', 'success');
}).catch(() => {
showToast('Failed to copy cart data', 'error');
});
} catch (error) {
console.error('Error sharing cart:', error);
showToast('Failed to share cart', 'error');
}
}
})();

View File

@@ -0,0 +1,224 @@
(function () {
'use strict';
function onReady(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
else fn();
}
function setupSidebar() {
const sidebarToggleBtn = document.getElementById('sidebarToggleBtn');
const sidebar = document.getElementById('sidebar');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
if (!sidebarToggleBtn || !sidebar || !sidebarBackdrop) return;
// Ensure clean state on page load
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
// Toggle sidebar visibility
sidebarToggleBtn.addEventListener('click', function (event) {
event.stopPropagation();
event.preventDefault();
// Toggle visibility
sidebar.classList.toggle('show');
sidebarBackdrop.classList.toggle('show');
// Set aria-expanded for accessibility
const isExpanded = sidebar.classList.contains('show');
sidebarToggleBtn.setAttribute('aria-expanded', String(isExpanded));
});
// Close sidebar when clicking on backdrop
sidebarBackdrop.addEventListener('click', function (event) {
event.stopPropagation();
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
});
// Close sidebar when clicking on any link inside it
const sidebarLinks = sidebar.querySelectorAll('a.nav-link');
sidebarLinks.forEach(link => {
link.addEventListener('click', function () {
// Let the link work, then close
setTimeout(function () {
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
}, 100);
});
});
// Ensure links are clickable
sidebar.addEventListener('click', function (event) {
event.stopPropagation();
});
}
function initializeCartIntegration() {
if (typeof window.updateCartCount !== 'function') {
// define if missing
window.updateCartCount = updateCartCount;
}
// initial
updateCartCount();
// Update cart count every 30 seconds
setInterval(updateCartCount, 30000);
// Listen for cart updates from other tabs/windows
window.addEventListener('storage', function (e) {
if (e.key === 'cart_items') {
updateCartCount();
}
});
// Listen for custom cart update events
window.addEventListener('cartUpdated', function () {
updateCartCount();
});
}
async function updateCartCount() {
try {
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' }) || {};
const cartCount = parseInt(cartData.item_count) || 0;
// Update sidebar cart counter
const cartBadge = document.getElementById('cartItemCount');
if (cartBadge) {
if (cartCount > 0) {
cartBadge.textContent = String(cartCount);
cartBadge.style.display = 'flex';
} else {
cartBadge.style.display = 'none';
}
}
// Update main navbar cart counter (from base.html)
const navbarCartCount = document.querySelector('.cart-count');
const navbarCartItem = document.getElementById('cartNavItem');
if (navbarCartCount && navbarCartItem) {
if (cartCount > 0) {
navbarCartCount.textContent = String(cartCount);
navbarCartCount.style.display = 'inline';
navbarCartItem.style.display = 'block';
} else {
navbarCartCount.style.display = 'none';
navbarCartItem.style.display = 'none';
}
}
} catch (error) {
// Hide counts on error
const navbarCartCount = document.querySelector('.cart-count');
const navbarCartItem = document.getElementById('cartNavItem');
if (navbarCartCount && navbarCartItem) {
navbarCartCount.style.display = 'none';
navbarCartItem.style.display = 'none';
}
// Keep console error minimal
// console.error('Error updating dashboard cart count:', error);
}
}
// Expose minimal cart helpers used across dashboard (legacy localStorage-based)
window.addToCart = function (serviceId, serviceName, price, specs) {
try {
const cartItems = JSON.parse(localStorage.getItem('cart_items') || '[]');
const existingItem = cartItems.find(item => item.service_id === serviceId);
if (existingItem) {
window.showToast('Item already in cart', 'info');
return false;
}
const newItem = {
id: 'cart_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
service_id: serviceId,
service_name: serviceName,
price_tfc: price,
specs: specs,
added_at: new Date().toISOString()
};
cartItems.push(newItem);
localStorage.setItem('cart_items', JSON.stringify(cartItems));
updateCartCount();
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
window.showToast('Added to cart successfully', 'success');
return true;
} catch (error) {
window.showToast('Failed to add to cart', 'error');
return false;
}
};
window.removeFromCart = function (itemId) {
try {
const cartItems = JSON.parse(localStorage.getItem('cart_items') || '[]');
const updatedItems = cartItems.filter(item => item.id !== itemId);
localStorage.setItem('cart_items', JSON.stringify(updatedItems));
updateCartCount();
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
window.showToast('Removed from cart', 'success');
return true;
} catch (error) {
window.showToast('Failed to remove from cart', 'error');
return false;
}
};
window.getCartItems = function () {
try {
return JSON.parse(localStorage.getItem('cart_items') || '[]');
} catch (error) {
return [];
}
};
window.clearCart = function () {
try {
localStorage.removeItem('cart_items');
updateCartCount();
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
window.showToast('Cart cleared', 'success');
return true;
} catch (error) {
window.showToast('Failed to clear cart', 'error');
return false;
}
};
// Toast helper
window.showToast = function (message, type = 'info') {
let toastContainer = document.getElementById('toastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toastContainer';
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
document.body.appendChild(toastContainer);
}
const toast = document.createElement('div');
const bgColor = type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007bff';
toast.style.cssText = 'background-color: ' + bgColor + '; color: white; padding: 12px 16px; border-radius: 6px; margin-bottom: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); opacity: 0; transform: translateX(100%); transition: all 0.3s ease;';
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(function () {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
}, 100);
setTimeout(function () {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 300);
}, 3000);
};
onReady(function () {
setupSidebar();
initializeCartIntegration();
});
})();

View File

@@ -0,0 +1,362 @@
// Dashboard Orders Page - CSP-compliant external script
(function () {
'use strict';
let HYDRATION = { currency_symbol: '$', display_currency: 'USD' };
function parseHydration() {
try {
const el = document.getElementById('orders-hydration');
if (!el) return;
const json = el.textContent || el.innerText || '{}';
HYDRATION = Object.assign(HYDRATION, JSON.parse(json));
} catch (e) {
console.warn('Failed to parse orders hydration JSON:', e);
}
}
function formatCurrencyAmount(amount) {
const sym = HYDRATION.currency_symbol || '';
const num = typeof amount === 'number' ? amount : parseFloat(amount) || 0;
if (/^[A-Z]{2,}$/.test(sym)) {
return `${num.toFixed(2)} ${sym}`;
}
return `${sym}${num.toFixed(2)}`;
}
async function loadOrders() {
try {
const payload = await window.apiJson('/api/orders', { method: 'GET' });
const orders = payload.orders || [];
const totalSpentFormatted = payload.total_spent || null;
displayOrders(orders);
updateOrderStats(orders, totalSpentFormatted);
} catch (err) {
console.error('Failed to load orders:', err);
displayOrders([]);
updateOrderStats([]);
const ordersContainer = document.getElementById('ordersContainer');
if (ordersContainer) {
ordersContainer.innerHTML = (
'<div class="alert alert-warning" role="alert">' +
'<i class="bi bi-exclamation-triangle me-2"></i>' +
'Unable to load orders. Please refresh the page or try again later.' +
'</div>'
);
}
}
}
function displayOrders(orders) {
const container = document.getElementById('ordersContainer');
const noOrdersMessage = document.getElementById('noOrdersMessage');
if (!container || !noOrdersMessage) return;
if (orders.length === 0) {
noOrdersMessage.style.display = 'block';
container.innerHTML = '';
container.appendChild(noOrdersMessage);
return;
}
noOrdersMessage.style.display = 'none';
container.innerHTML = '';
orders.forEach((order) => {
const node = createOrderElement(order);
container.appendChild(node);
});
}
function createOrderElement(order) {
const template = document.getElementById('orderItemTemplate');
const fragment = template.content.cloneNode(true);
const container = fragment.querySelector('.order-item');
container.setAttribute('data-order-id', order.order_id);
if (order.created_at) container.setAttribute('data-created-at', order.created_at);
const idEl = fragment.querySelector('.order-id');
const statusEl = fragment.querySelector('.order-status');
const servicesEl = fragment.querySelector('.order-services');
const dateEl = fragment.querySelector('.order-date');
const totalEl = fragment.querySelector('.order-total');
if (idEl) idEl.textContent = order.order_id;
if (statusEl) {
statusEl.textContent = (order.status || '').toString().toUpperCase();
statusEl.className = `badge order-status ms-2 bg-${getStatusColor(order.status)}`;
}
if (servicesEl) servicesEl.textContent = `${(order.items || []).length} item(s): ${(order.items || []).map(s => s.product_name).join(', ')}`;
if (dateEl) dateEl.textContent = formatOrderDate(new Date(order.created_at));
if (totalEl) totalEl.textContent = order.total;
return fragment;
}
function getStatusColor(status) {
const colors = {
pending: 'warning',
processing: 'info',
confirmed: 'success',
completed: 'success',
deployed: 'success',
active: 'success',
failed: 'danger',
cancelled: 'secondary',
};
const key = (status || '').toString().toLowerCase();
return colors[key] || 'secondary';
}
function formatOrderDate(date) {
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
return date.toLocaleDateString();
}
function updateOrderStats(orders, totalSpentFormatted) {
const totalOrders = orders.length;
const totalOrdersEl = document.getElementById('totalOrders');
const totalSpentEl = document.getElementById('totalSpent');
if (totalOrdersEl) totalOrdersEl.textContent = totalOrders;
if (totalSpentEl) {
if (totalSpentFormatted) {
totalSpentEl.textContent = totalSpentFormatted;
} else {
const sum = orders.reduce((acc, o) => {
const n = parseFloat((o.total || '').replace(/[^0-9.-]+/g, '')) || 0;
return acc + n;
}, 0);
totalSpentEl.textContent = formatCurrencyAmount(sum);
}
}
}
async function updateCartBadge() {
try {
const badge = document.getElementById('cartBadge');
if (!badge) return;
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
const count = parseInt(cartData.item_count) || 0;
if (count > 0) {
badge.textContent = count;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
} catch (e) {
console.warn('Failed updating cart badge:', e);
}
}
function filterOrders() {
const status = (document.getElementById('orderStatus')?.value || '').toLowerCase();
const period = parseInt(document.getElementById('orderPeriod')?.value || '');
const search = (document.getElementById('orderSearch')?.value || '').toLowerCase();
const items = document.querySelectorAll('.order-item');
const now = new Date();
let anyVisible = false;
items.forEach((item) => {
let show = true;
if (status) {
const statusBadge = item.querySelector('.order-status');
const text = statusBadge ? statusBadge.textContent.toLowerCase() : '';
if (!text.includes(status)) show = false;
}
if (show && period) {
const ts = item.getAttribute('data-created-at');
if (ts) {
const created = new Date(ts);
const cutoff = new Date(now.getTime() - period * 24 * 60 * 60 * 1000);
if (created < cutoff) show = false;
}
}
if (show && search) {
const idText = item.querySelector('.order-id')?.textContent?.toLowerCase() || '';
const servicesText = item.querySelector('.order-services')?.textContent?.toLowerCase() || '';
if (!idText.includes(search) && !servicesText.includes(search)) show = false;
}
item.style.display = show ? '' : 'none';
if (show) anyVisible = true;
});
const container = document.getElementById('ordersContainer');
const noOrdersMessage = document.getElementById('noOrdersMessage');
if (container && noOrdersMessage) {
if (!anyVisible) {
noOrdersMessage.style.display = 'block';
if (!container.contains(noOrdersMessage)) container.appendChild(noOrdersMessage);
} else {
noOrdersMessage.style.display = 'none';
}
}
if (typeof window.showToast === 'function') {
window.showToast('Filters applied', 'info');
}
}
function onContainerClick(e) {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.getAttribute('data-action');
if (action === 'toggle-details') {
e.preventDefault();
const button = actionEl;
const orderItem = button.closest('.order-item');
if (!orderItem) return;
const details = orderItem.querySelector('.order-details');
if (!details) return;
const isHidden = details.style.display === 'none' || details.style.display === '';
if (isHidden) {
details.style.display = 'block';
button.innerHTML = '<i class="bi bi-eye-slash"></i> Hide';
if (!details.dataset.loaded) {
populateOrderDetails(details, orderItem.getAttribute('data-order-id'));
details.dataset.loaded = 'true';
}
} else {
details.style.display = 'none';
button.innerHTML = '<i class="bi bi-eye"></i> Details';
}
return;
}
if (action === 'view-invoice') {
e.preventDefault();
const orderItem = actionEl.closest('.order-item');
const orderId = orderItem?.getAttribute('data-order-id');
if (!orderId) {
if (typeof window.showToast === 'function') window.showToast('Missing order id', 'danger');
return;
}
window.open(`/orders/${encodeURIComponent(orderId)}/invoice`, '_blank', 'noopener');
return;
}
if (action === 'contact-support') {
e.preventDefault();
const SUPPORT_URL = 'https://threefoldfaq.crisp.help/en/';
window.open(SUPPORT_URL, '_blank');
return;
}
}
function populateOrderDetails(detailsElement, orderId) {
// Placeholder content; replace with real data when API is ready
const servicesList = detailsElement.querySelector('.order-services-list');
const deploymentStatus = detailsElement.querySelector('.deployment-status');
if (servicesList) {
servicesList.innerHTML = (
'<div class="list-group list-group-flush">' +
'<div class="list-group-item d-flex justify-content-between">' +
'<span>Ubuntu 22.04 VM (2 CPU, 4GB RAM)</span>' +
'<span class="text-success">Active</span>' +
'</div>' +
'<div class="list-group-item d-flex justify-content-between">' +
'<span>Storage Volume (100GB SSD)</span>' +
'<span class="text-success">Active</span>' +
'</div>' +
'</div>'
);
}
if (deploymentStatus) {
deploymentStatus.innerHTML = (
'<div class="deployment-info">' +
'<div class="d-flex justify-content-between mb-2">' +
'<span>Deployment ID:</span>' +
'<code>DEP-2001</code>' +
'</div>' +
'<div class="d-flex justify-content-between">' +
'<span>Uptime:</span>' +
'<span class="text-success">15 days</span>' +
'</div>' +
'</div>'
);
}
}
function bindFilters() {
const status = document.getElementById('orderStatus');
const period = document.getElementById('orderPeriod');
const search = document.getElementById('orderSearch');
if (status) status.addEventListener('change', filterOrders);
if (period) period.addEventListener('change', filterOrders);
if (search) search.addEventListener('keyup', filterOrders);
}
function bindInvoiceFromModal() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-action="view-invoice-from-modal"]');
if (!btn) return;
e.preventDefault();
// Try to infer order id from any selected/visible order
const visible = document.querySelector('.order-item');
const orderId = visible?.getAttribute('data-order-id') || document.querySelector('.order-id')?.textContent?.trim();
if (!orderId) {
if (typeof window.showToast === 'function') window.showToast('Missing order id', 'danger');
return;
}
window.open(`/orders/${encodeURIComponent(orderId)}/invoice`, '_blank', 'noopener');
});
}
function initialize() {
parseHydration();
loadOrders();
updateCartBadge();
setInterval(updateCartBadge, 30000);
const container = document.getElementById('ordersContainer');
if (container) container.addEventListener('click', onContainerClick);
bindFilters();
bindInvoiceFromModal();
// Expose a public method for post-purchase refresh
window.refreshOrders = loadOrders;
// Fallback toast if not provided by layout
if (typeof window.showToast !== 'function') {
window.showToast = function (message, type) {
try {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type || 'info'} border-0 position-fixed end-0 m-3`;
toast.style.top = '80px';
toast.style.zIndex = '10000';
toast.innerHTML = '<div class="d-flex">' +
'<div class="toast-body">' +
`<i class="bi bi-info-circle me-2"></i>${message}` +
'</div>' +
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>' +
'</div>';
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
} catch (_) {
console.log(`[${type || 'info'}] ${message}`);
}
};
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();

View File

@@ -0,0 +1,484 @@
/* Dashboard Pools Page JS - CSP compliant (no inline handlers) */
(function () {
'use strict';
// Toast helpers
function showSuccessToast(message) {
const body = document.getElementById('successToastBody');
if (body) body.textContent = message;
const toastEl = document.getElementById('successToast');
if (toastEl && window.bootstrap) {
const toast = bootstrap.Toast.getOrCreateInstance(toastEl);
toast.show();
}
}
function showErrorToast(message) {
const body = document.getElementById('errorToastBody');
if (body) body.textContent = message;
const toastEl = document.getElementById('errorToast');
if (toastEl && window.bootstrap) {
const toast = bootstrap.Toast.getOrCreateInstance(toastEl);
toast.show();
}
}
// Hydration
function readHydration() {
try {
const el = document.getElementById('pools-hydration');
if (!el) return {};
return JSON.parse(el.textContent || '{}');
} catch (e) {
console.warn('Failed to parse pools hydration JSON', e);
return {};
}
}
// Charts
let charts = {};
function initCharts() {
if (!window.Chart) return; // Chart.js not loaded
// Global defaults
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.responsive = true;
Chart.defaults.maintainAspectRatio = false;
const priceHistoryCtx = document.getElementById('creditsPriceHistoryChart');
const liquidityCtx = document.getElementById('liquidityPoolDistributionChart');
const volumeCtx = document.getElementById('exchangeVolumeChart');
const stakingCtx = document.getElementById('stakingDistributionChart');
if (priceHistoryCtx) {
charts.priceHistory = new Chart(priceHistoryCtx.getContext('2d'), {
type: 'line',
data: {
labels: ['Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [
{
label: 'Credits-EUR Rate',
data: [0.82, 0.84, 0.83, 0.85, 0.85, 0.86, 0.85],
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
borderWidth: 2,
tension: 0.3,
fill: true,
},
{
label: 'Credits-TFT Rate',
data: [4.8, 4.9, 5.0, 5.1, 5.0, 4.9, 5.0],
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
borderWidth: 2,
tension: 0.3,
fill: true,
},
],
},
options: {
plugins: {
legend: { position: 'top' },
},
scales: {
y: { beginAtZero: false, title: { display: true, text: 'Exchange Rate' } },
},
},
});
}
if (liquidityCtx) {
charts.liquidity = new Chart(liquidityCtx.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Credits-Fiat Pool', 'Credits-TFT Pool', 'Credits-PEAQ Pool'],
datasets: [
{
data: [1250000, 250000, 100000],
backgroundColor: ['#007bff', '#28a745', '#17a2b8'],
borderWidth: 1,
},
],
},
options: {
plugins: {
legend: { position: 'right', labels: { boxWidth: 12 } },
},
},
});
}
if (volumeCtx) {
charts.volume = new Chart(volumeCtx.getContext('2d'), {
type: 'bar',
data: {
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
datasets: [
{ label: 'Credits-Fiat', data: [2500, 3200, 2800, 3500], backgroundColor: '#007bff', borderWidth: 1 },
{ label: 'Credits-TFT', data: [1500, 1800, 2200, 2000], backgroundColor: '#28a745', borderWidth: 1 },
{ label: 'Credits-PEAQ', data: [800, 1000, 1200, 900], backgroundColor: '#17a2b8', borderWidth: 1 },
],
},
options: {
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Volume (USD)' }, stacked: true },
x: { stacked: true },
},
},
});
}
if (stakingCtx) {
charts.staking = new Chart(stakingCtx.getContext('2d'), {
type: 'pie',
data: {
labels: ['$10-50', '$51-100', '$101-500', '$501+'],
datasets: [
{ data: [450, 280, 150, 75], backgroundColor: ['#007bff', '#28a745', '#ffc107', '#dc3545'], borderWidth: 1 },
],
},
options: {
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
},
});
}
}
async function loadPoolData() {
try {
const pools = await window.apiJson('/api/pools', { cache: 'no-store' });
(Array.isArray(pools) ? pools : []).forEach(updatePoolCard);
} catch (e) {
console.warn('Failed to load /api/pools', e);
}
}
function updatePoolCard(pool) {
const card = document.querySelector(`[data-pool-id="${pool.id}"]`);
if (!card) return;
const rateEl = card.querySelector('.exchange-rate');
const liqEl = card.querySelector('.liquidity');
if (rateEl) rateEl.textContent = `1 ${pool.token_a} = ${pool.exchange_rate} ${pool.token_b}`;
if (liqEl) liqEl.textContent = `${(pool.liquidity || 0).toLocaleString()} ${pool.token_a}`;
}
async function loadAnalyticsData() {
try {
const analytics = await window.apiJson('/api/pools/analytics', { cache: 'no-store' });
if (analytics) updateChartsWithRealData(analytics);
} catch (e) {
console.warn('Failed to load /api/pools/analytics', e);
}
}
function updateChartsWithRealData(analytics) {
if (analytics.price_history && charts.priceHistory) {
const labels = analytics.price_history.map((p) => new Date(p.timestamp).toLocaleDateString());
const prices = analytics.price_history.map((p) => p.price);
charts.priceHistory.data.labels = labels;
charts.priceHistory.data.datasets[0].data = prices;
charts.priceHistory.update();
}
if (analytics.liquidity_distribution && charts.liquidity) {
const labels = Object.keys(analytics.liquidity_distribution);
const data = Object.values(analytics.liquidity_distribution);
charts.liquidity.data.labels = labels;
charts.liquidity.data.datasets[0].data = data;
charts.liquidity.update();
}
if (analytics.staking_distribution && charts.staking) {
const labels = Object.keys(analytics.staking_distribution);
const data = Object.values(analytics.staking_distribution);
charts.staking.data.labels = labels;
charts.staking.data.datasets[0].data = data;
charts.staking.update();
}
}
// Calculators
function setupCalculators() {
// Buy with TFT
const tfpAmountTFT = document.getElementById('tfpAmountTFT');
if (tfpAmountTFT) {
tfpAmountTFT.addEventListener('input', () => {
const amount = parseFloat(tfpAmountTFT.value) || 0;
const tftCost = amount * 0.5; // 1 TFC = 0.5 TFT
const modal = document.getElementById('buyTFCWithTFTModal');
if (modal) {
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
// rows[0] -> Amount, rows[1] -> Cost
if (rows[0]) rows[0].textContent = `${amount} TFC`;
if (rows[1]) rows[1].textContent = `${tftCost.toFixed(1)} TFT`;
}
});
}
// Sell for TFT
const sellTfpAmountTFT = document.getElementById('sellTfpAmountTFT');
if (sellTfpAmountTFT) {
sellTfpAmountTFT.addEventListener('input', () => {
const amount = parseFloat(sellTfpAmountTFT.value) || 0;
const tftReceive = amount * 0.5; // 1 TFC = 0.5 TFT
const modal = document.getElementById('sellTFCForTFTModal');
if (modal) {
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
if (rows[0]) rows[0].textContent = `${amount} TFC`;
if (rows[1]) rows[1].textContent = `${tftReceive.toFixed(1)} TFT`;
}
});
}
// Buy with PEAQ
const tfpAmountPEAQ = document.getElementById('tfpAmountPEAQ');
if (tfpAmountPEAQ) {
tfpAmountPEAQ.addEventListener('input', () => {
const amount = parseFloat(tfpAmountPEAQ.value) || 0;
const peaqCost = amount * 2.0; // 1 TFC = 2 PEAQ
const modal = document.getElementById('buyTFCWithPEAQModal');
if (modal) {
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
if (rows[0]) rows[0].textContent = `${amount} TFC`;
if (rows[1]) rows[1].textContent = `${peaqCost.toFixed(0)} PEAQ`;
}
});
}
// Sell for PEAQ
const sellTfpAmountPEAQ = document.getElementById('sellTfpAmountPEAQ');
if (sellTfpAmountPEAQ) {
sellTfpAmountPEAQ.addEventListener('input', () => {
const amount = parseFloat(sellTfpAmountPEAQ.value) || 0;
const peaqReceive = amount * 2.0; // 1 TFC = 2 PEAQ
const modal = document.getElementById('sellTFCForPEAQModal');
if (modal) {
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
if (rows[0]) rows[0].textContent = `${amount} TFC`;
if (rows[1]) rows[1].textContent = `${peaqReceive.toFixed(0)} PEAQ`;
}
});
}
// Optional: Sell Credits for fiat quick calculator using simple FX placeholders
const sellCreditsAmount = document.getElementById('sellCreditsAmount');
const receiveCurrency = document.getElementById('receiveCurrency');
if (sellCreditsAmount && receiveCurrency) {
const updateReceive = () => {
const amount = parseFloat(sellCreditsAmount.value) || 0;
const ccy = receiveCurrency.value;
let rate = 1.0; // 1 Credit = 1 USD base
if (ccy === 'EUR') rate = 0.9; // placeholder
if (ccy === 'GBP') rate = 0.8; // placeholder
const receive = amount * rate;
const modal = document.getElementById('sellCreditsModal');
if (modal) {
// Find the last text-end in alert -> corresponds to You receive
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
if (rows[1]) rows[1].textContent = `${receive.toFixed(2)} ${ccy}`;
}
};
sellCreditsAmount.addEventListener('input', updateReceive);
receiveCurrency.addEventListener('change', updateReceive);
}
}
// Event delegation for actions
async function handleAction(action) {
switch (action) {
case 'confirm-buy-credits-fiat': {
const amount = parseFloat(document.getElementById('creditsAmount')?.value);
const paymentMethod = document.getElementById('paymentMethod')?.value;
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/buy-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, payment_method: paymentMethod }),
});
showSuccessToast('Credits purchase successful');
const modal = document.getElementById('buyCreditsModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
if (document.getElementById('creditsAmount')) document.getElementById('creditsAmount').value = '';
} catch (e) {
if (e && e.status === 402) {
// Insufficient funds handled globally via 402 interceptor (opens credit modal)
} else {
showErrorToast(e?.message || 'Purchase failed');
}
}
break;
}
case 'confirm-sell-credits-fiat': {
const amount = parseFloat(document.getElementById('sellCreditsAmount')?.value);
const currency = document.getElementById('receiveCurrency')?.value;
const payout_method = document.getElementById('payoutMethod')?.value;
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/sell-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, currency, payout_method }),
});
showSuccessToast('Credits sale successful');
const modal = document.getElementById('sellCreditsModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
if (document.getElementById('sellCreditsAmount')) document.getElementById('sellCreditsAmount').value = '';
} catch (e) {
if (e && e.status === 402) {
// Handled globally via credit modal
} else {
showErrorToast(e?.message || 'Sale failed');
}
}
break;
}
case 'confirm-stake-credits': {
const amount = parseFloat(document.getElementById('stakeAmount')?.value);
const duration_months = parseInt(document.getElementById('stakeDuration')?.value, 10);
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/pools/stake', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, duration_months }),
});
showSuccessToast(`Successfully staked $${amount} for ${duration_months} months`);
const modal = document.getElementById('stakeCreditsModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
if (document.getElementById('stakeAmount')) document.getElementById('stakeAmount').value = '';
} catch (e) {
if (e && e.status === 402) {
// Handled globally
} else {
showErrorToast(e?.message || 'Staking failed');
}
}
break;
}
case 'confirm-buy-tfc-tft': {
const amount = parseFloat(document.getElementById('tfpAmountTFT')?.value);
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/buy-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, payment_method: 'TFT' }),
});
showSuccessToast(`Purchased ${amount} TFC with TFT`);
const modal = document.getElementById('buyTFCWithTFTModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
} catch (e) {
if (e && e.status === 402) {
// Handled globally
} else {
showErrorToast(e?.message || 'Purchase failed');
}
}
break;
}
case 'confirm-sell-tfc-tft': {
const amount = parseFloat(document.getElementById('sellTfpAmountTFT')?.value);
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/sell-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, currency: 'TFT', payout_method: 'blockchain' }),
});
showSuccessToast(`Sold ${amount} TFC for TFT`);
const modal = document.getElementById('sellTFCForTFTModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
} catch (e) {
if (e && e.status === 402) {
// Handled globally
} else {
showErrorToast(e?.message || 'Sale failed');
}
}
break;
}
case 'confirm-buy-tfc-peaq': {
const amount = parseFloat(document.getElementById('tfpAmountPEAQ')?.value);
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/buy-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, payment_method: 'PEAQ' }),
});
showSuccessToast(`Purchased ${amount} TFC with PEAQ`);
const modal = document.getElementById('buyTFCWithPEAQModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
} catch (e) {
if (e && e.status === 402) {
// Handled globally
} else {
showErrorToast(e?.message || 'Purchase failed');
}
}
break;
}
case 'confirm-sell-tfc-peaq': {
const amount = parseFloat(document.getElementById('sellTfpAmountPEAQ')?.value);
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
try {
await window.apiJson('/api/wallet/sell-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, currency: 'PEAQ', payout_method: 'blockchain' }),
});
showSuccessToast(`Sold ${amount} TFC for PEAQ`);
const modal = document.getElementById('sellTFCForPEAQModal');
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
} catch (e) {
if (e && e.status === 402) {
// Handled globally
} else {
showErrorToast(e?.message || 'Sale failed');
}
}
break;
}
default:
break;
}
}
function setupEventDelegation() {
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
if (!action) return;
handleAction(action);
});
}
// Init
document.addEventListener('DOMContentLoaded', () => {
const hydration = readHydration();
if (hydration.charts) initCharts();
// data loads (best-effort)
loadPoolData();
loadAnalyticsData();
setupCalculators();
setupEventDelegation();
});
})();

View File

@@ -0,0 +1,488 @@
(function () {
'use strict';
// Read hydration data safely
function readHydration(id) {
try {
const el = document.getElementById(id);
if (!el) return {};
const txt = el.textContent || el.innerText || '';
if (!txt.trim()) return {};
return JSON.parse(txt);
} catch (e) {
return {};
}
}
const hyd = readHydration('wallet-hydration');
const currencySymbol = (hyd && hyd.currency_symbol) || '$';
const displayCurrency = (hyd && hyd.display_currency) || 'USD';
function showSuccessToast(message) {
try {
const body = document.getElementById('successToastBody');
if (!body) return;
body.textContent = message;
const toastEl = document.getElementById('successToast');
if (!toastEl || !window.bootstrap || !window.bootstrap.Toast) return;
const toast = new bootstrap.Toast(toastEl);
toast.show();
} catch (_) {}
}
function showErrorToast(message) {
try {
const body = document.getElementById('errorToastBody');
if (!body) return;
body.textContent = message;
const toastEl = document.getElementById('errorToast');
if (!toastEl || !window.bootstrap || !window.bootstrap.Toast) return;
const toast = new bootstrap.Toast(toastEl);
toast.show();
} catch (_) {}
}
function formatPaymentMethod(paymentMethodId) {
const paymentMethods = {
credit_card: 'Credit Card',
debit_card: 'Debit Card',
paypal: 'PayPal',
bank_transfer: 'Bank Transfer',
apple_pay: 'Apple Pay',
google_pay: 'Google Pay',
stripe: 'Stripe',
square: 'Square'
};
return paymentMethods[paymentMethodId] || paymentMethodId || '';
}
document.addEventListener('DOMContentLoaded', function () {
// Input: update total cost
const creditsAmount = document.getElementById('creditsAmount');
if (creditsAmount) {
creditsAmount.addEventListener('input', function () {
const amount = parseFloat(this.value) || 0;
const totalCost = amount.toFixed(2);
const totalEl = document.getElementById('totalCost');
if (totalEl) totalEl.textContent = totalCost;
});
}
// Payment method label update
const paymentMethodSelect = document.getElementById('paymentMethod');
if (paymentMethodSelect) {
paymentMethodSelect.addEventListener('change', function () {
const selectedValue = this.value;
if (selectedValue) {
const placeholderOption = this.querySelector('option[value=""]');
if (placeholderOption) placeholderOption.style.display = 'none';
updatePaymentMethodLabel(selectedValue);
} else {
const label = document.querySelector('label[for="paymentMethod"]');
if (label) label.textContent = 'Payment Method';
const placeholderOption = this.querySelector('option[value=""]');
if (placeholderOption) placeholderOption.style.display = '';
}
});
}
// When buy credits modal opens, load last payment method
const buyCreditsModal = document.getElementById('buyCreditsModal');
if (buyCreditsModal) {
buyCreditsModal.addEventListener('show.bs.modal', function () {
loadLastPaymentMethod();
});
}
// Auto top-up toggle visibility
const autoToggle = document.getElementById('autoTopUpEnabled');
if (autoToggle) {
autoToggle.addEventListener('change', function () {
const settingsDiv = document.getElementById('autoTopUpSettings');
if (settingsDiv) settingsDiv.style.display = this.checked ? 'block' : 'none';
});
}
// Initial load
loadAutoTopUpStatus();
});
// Event delegation for wallet actions
document.addEventListener('click', function (e) {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.getAttribute('data-action');
if (!action) return;
switch (action) {
case 'refresh-wallet':
e.preventDefault();
refreshWalletData();
break;
case 'buy-credits':
e.preventDefault();
buyCredits();
break;
case 'transfer-credits':
e.preventDefault();
transferCredits();
break;
case 'save-auto-topup-settings':
e.preventDefault();
saveAutoTopUpSettings();
break;
default:
break;
}
}, false);
function updatePaymentMethodLabel(paymentMethodValue) {
const label = document.querySelector('label[for="paymentMethod"]');
if (label && paymentMethodValue) {
const paymentMethodName = formatPaymentMethod(paymentMethodValue);
label.textContent = `Payment Method: ${paymentMethodName}`;
}
}
async function buyCredits() {
const amount = document.getElementById('creditsAmount')?.value;
const paymentMethod = document.getElementById('paymentMethod')?.value;
if (!amount || !paymentMethod) {
showErrorToast('Please fill in all required fields');
return;
}
try {
await apiJson('/api/wallet/buy-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: parseFloat(amount), payment_method: paymentMethod })
});
{
showSuccessToast('Credits purchase successful!');
// Close modal
const modalEl = document.getElementById('buyCreditsModal');
if (modalEl && window.bootstrap?.Modal) {
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modal.hide();
}
// Reset form
const form = document.getElementById('buyCreditsForm');
if (form) form.reset();
const totalEl = document.getElementById('totalCost');
if (totalEl) totalEl.textContent = '0.00';
// Update UI
await refreshTransactionsTable();
await refreshWalletData();
// Ensure server-side values are reflected (best-effort)
// location.reload(); // optional: uncomment if required by UX
}
} catch (error) {
showErrorToast('Error processing purchase: ' + (error?.message || 'Unknown error'));
}
}
async function transferCredits() {
const toUser = document.getElementById('recipientEmail')?.value;
const amount = document.getElementById('transferAmount')?.value;
const note = document.getElementById('transferNote')?.value;
if (!toUser || !amount) {
showErrorToast('Please fill in all required fields');
return;
}
try {
await apiJson('/api/wallet/transfer-credits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ to_user: toUser, amount: parseFloat(amount), note: note || null })
});
{
showSuccessToast('Transfer successful!');
// Close modal
const modalEl = document.getElementById('transferCreditsModal');
if (modalEl && window.bootstrap?.Modal) {
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modal.hide();
}
// Reset form
const form = document.getElementById('transferCreditsForm');
if (form) form.reset();
// Update UI
await refreshTransactionsTable();
await refreshWalletData();
}
} catch (error) {
showErrorToast('Error processing transfer: ' + (error?.message || 'Unknown error'));
}
}
async function refreshWalletData() {
try {
const data = await apiJson('/api/wallet/info', { cache: 'no-store' });
if (data && typeof data.balance === 'number') {
const bal = Number(data.balance);
const balEl = document.getElementById('wallet-balance');
const usdEl = document.getElementById('usd-equivalent');
const availEl = document.getElementById('availableBalance');
if (balEl) balEl.textContent = `${currencySymbol}${bal.toFixed(2)}`;
if (usdEl) usdEl.textContent = `${currencySymbol}${bal.toFixed(2)}`;
if (availEl) availEl.textContent = bal.toFixed(2);
}
await refreshTransactionsTable();
} catch (error) {
console.error('Error refreshing wallet data:', error);
}
}
async function refreshTransactionsTable() {
try {
let transactions = await apiJson('/api/wallet/transactions', { cache: 'no-store' });
const tbody = document.getElementById('transactions-tbody');
if (!tbody) return;
if (Array.isArray(transactions) && transactions.length > 0) {
tbody.innerHTML = transactions.map(transaction => {
const tt = transaction.transaction_type || {};
const isPositive = !!(tt.CreditsPurchase || tt.Earning || tt.Unstake);
const typeLabel = tt.CreditsPurchase ? 'Credits Purchase' :
tt.CreditsSale ? 'Credits Sale' :
tt.Rental ? 'Rental' :
(tt.Purchase || tt.InstantPurchase) ? 'Purchase' :
(tt.CreditsTransfer || tt.Transfer) ? 'Credits Transfer' :
tt.Earning ? 'Earning' :
tt.Exchange ? 'Exchange' :
tt.Stake ? 'Stake' :
tt.Unstake ? 'Unstake' : 'Unknown';
const typeBadge = tt.CreditsPurchase ? 'bg-success' :
tt.CreditsSale ? 'bg-danger' :
tt.Rental ? 'bg-primary' :
(tt.Purchase || tt.InstantPurchase) ? 'bg-danger' :
(tt.CreditsTransfer || tt.Transfer) ? 'bg-info' :
tt.Earning ? 'bg-success' :
tt.Exchange ? 'bg-secondary' :
tt.Stake ? 'bg-primary' :
tt.Unstake ? 'bg-warning' : 'bg-light text-dark';
const statusBadge = transaction.status === 'Completed' ? 'bg-success' :
transaction.status === 'Pending' ? 'bg-warning' : 'bg-danger';
const displayDate = transaction.formatted_timestamp || new Date(transaction.timestamp).toLocaleString('en-US', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
});
return `
<tr>
<td>${displayDate}</td>
<td><span class="badge ${typeBadge}">${typeLabel}</span></td>
<td>
<span class="${isPositive ? 'text-success' : 'text-danger'}">
${isPositive ? '+' : '-'}${Math.abs(Number(transaction.amount) || 0).toFixed(2)}
</span>
</td>
<td><span class="badge ${statusBadge}">${transaction.status}</span></td>
<td>${transaction.id}</td>
</tr>
`;
}).join('');
} else {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No transactions yet</td></tr>';
}
} catch (error) {
console.error('Error refreshing transactions:', error);
}
}
async function loadLastPaymentMethod() {
try {
const data = await apiJson('/api/wallet/last-payment-method', { cache: 'no-store' });
if (data && (data.success === true || data.last_payment_method)) {
const select = document.getElementById('paymentMethod');
if (select && data.last_payment_method) {
select.value = data.last_payment_method;
select.dispatchEvent(new Event('change'));
}
}
} catch (error) {
// Non-critical
}
}
async function loadAutoTopUpStatus() {
try {
const data = await apiJson('/api/wallet/auto-topup/status', { cache: 'no-store' });
const statusBadge = document.getElementById('autoTopUpStatus');
const contentDiv = document.getElementById('autoTopUpContent');
if (!statusBadge || !contentDiv) return;
if (data.enabled && data.settings) {
statusBadge.textContent = 'Enabled';
statusBadge.className = 'badge bg-success';
contentDiv.innerHTML = `
<div class="row">
<div class="col-md-2">
<div class="text-center">
<div class="h4 text-success mb-1">$${data.settings.threshold_amount_usd}</div>
<small class="text-muted">Threshold</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center">
<div class="h4 text-primary mb-1">$${data.settings.topup_amount_usd}</div>
<small class="text-muted">Top-Up Amount</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 text-info mb-1">${formatPaymentMethod(data.settings.payment_method_id)}</div>
<small class="text-muted">Payment Method</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center">
<div class="h4 text-muted mb-1">${(data.settings.daily_limit_usd !== null && data.settings.daily_limit_usd !== undefined) ? ('$' + data.settings.daily_limit_usd) : 'No limit'}</div>
<small class="text-muted">Daily Limit</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 text-muted mb-1">${(data.settings.monthly_limit_usd !== null && data.settings.monthly_limit_usd !== undefined) ? ('$' + data.settings.monthly_limit_usd) : 'No limit'}</div>
<small class="text-muted">Monthly Limit</small>
</div>
</div>
</div>
`;
// Prefill form values
const enabledEl = document.getElementById('autoTopUpEnabled');
const thresholdEl = document.getElementById('thresholdAmount');
const topupEl = document.getElementById('topUpAmount');
const pmEl = document.getElementById('autoTopUpPaymentMethod');
const dailyEl = document.getElementById('dailyLimit');
const monthlyEl = document.getElementById('monthlyLimit');
const settingsDiv = document.getElementById('autoTopUpSettings');
if (enabledEl) enabledEl.checked = true;
if (thresholdEl) thresholdEl.value = data.settings.threshold_amount_usd;
if (topupEl) topupEl.value = data.settings.topup_amount_usd;
if (pmEl) pmEl.value = data.settings.payment_method_id;
if (dailyEl) dailyEl.value = data.settings.daily_limit_usd || 0;
if (monthlyEl) monthlyEl.value = data.settings.monthly_limit_usd || 0;
if (settingsDiv) settingsDiv.style.display = 'block';
} else {
statusBadge.textContent = 'Disabled';
statusBadge.className = 'badge bg-secondary';
contentDiv.innerHTML = `
<div class="text-center text-muted">
<i class="bi bi-lightning-charge fs-1 mb-3"></i>
<p>Auto Top-Up is currently disabled. Configure your preferences to enable it.</p>
</div>
`;
}
} catch (error) {
console.error('Error loading auto top-up status:', error);
const statusBadge = document.getElementById('autoTopUpStatus');
const contentDiv = document.getElementById('autoTopUpContent');
if (statusBadge) {
statusBadge.textContent = 'Error';
statusBadge.className = 'badge bg-danger';
}
if (contentDiv) {
contentDiv.innerHTML = `
<div class="text-center text-danger">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Error loading auto top-up settings. Please try again.</p>
</div>
`;
}
}
}
async function saveAutoTopUpSettings() {
const enabled = !!document.getElementById('autoTopUpEnabled')?.checked;
if (!enabled) {
// Disable auto top-up
try {
await apiJson('/api/wallet/auto-topup/configure', {
method: 'POST',
body: {
enabled: false,
threshold_amount: 0,
topup_amount: 0,
payment_method_id: '',
daily_limit: null,
monthly_limit: null
}
});
{
showSuccessToast('Auto Top-Up disabled successfully!');
const modalEl = document.getElementById('configureAutoTopUpModal');
if (modalEl && window.bootstrap?.Modal) {
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modal.hide();
}
loadAutoTopUpStatus();
}
} catch (error) {
showErrorToast('Error updating settings: ' + (error?.message || 'Unknown error'));
}
return;
}
const thresholdAmount = document.getElementById('thresholdAmount')?.value;
const topUpAmount = document.getElementById('topUpAmount')?.value;
const paymentMethod = document.getElementById('autoTopUpPaymentMethod')?.value;
const dailyLimit = document.getElementById('dailyLimit')?.value;
const monthlyLimit = document.getElementById('monthlyLimit')?.value;
if (!thresholdAmount || !topUpAmount || !paymentMethod) {
showErrorToast('Please fill in all required fields');
return;
}
if (parseFloat(thresholdAmount) >= parseFloat(topUpAmount)) {
showErrorToast('Top-up amount must be greater than threshold amount');
return;
}
try {
await apiJson('/api/wallet/auto-topup/configure', {
method: 'POST',
body: {
enabled: true,
threshold_amount: parseFloat(thresholdAmount),
topup_amount: parseFloat(topUpAmount),
payment_method_id: paymentMethod,
daily_limit: dailyLimit ? parseFloat(dailyLimit) : null,
monthly_limit: monthlyLimit ? parseFloat(monthlyLimit) : null
}
});
{
showSuccessToast('Auto Top-Up settings saved successfully!');
const modalEl = document.getElementById('configureAutoTopUpModal');
if (modalEl && window.bootstrap?.Modal) {
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modal.hide();
}
loadAutoTopUpStatus();
}
} catch (error) {
showErrorToast('Error saving settings: ' + (error?.message || 'Unknown error'));
}
}
})();

View File

@@ -0,0 +1,364 @@
// Demo Workflow JavaScript
// This file provides a comprehensive demo of the ThreeFold Dashboard functionality
class DemoWorkflow {
constructor() {
this.currentStep = 0;
this.steps = [
{
title: "Welcome to ThreeFold Dashboard Demo",
description: "This demo will showcase the complete interactive functionality of the ThreeFold ecosystem.",
action: () => this.showWelcome()
},
{
title: "App Provider: Register New Application",
description: "Let's start by registering a new application as an App Provider.",
action: () => this.demoAppRegistration()
},
{
title: "Service Provider: Create New Service",
description: "Now let's create a new service offering as a Service Provider.",
action: () => this.demoServiceCreation()
},
{
title: "Marketplace Integration",
description: "See how your apps and services automatically appear in the marketplace.",
action: () => this.demoMarketplaceIntegration()
},
{
title: "User: Deploy Application",
description: "As a user, let's deploy an application from the marketplace.",
action: () => this.demoAppDeployment()
},
{
title: "Farmer: Node Management",
description: "Manage your farming nodes and monitor capacity.",
action: () => this.demoNodeManagement()
},
{
title: "Cross-Dashboard Integration",
description: "See how actions in one dashboard affect others in real-time.",
action: () => this.demoCrossIntegration()
},
{
title: "Demo Complete",
description: "You've seen the complete ThreeFold ecosystem in action!",
action: () => this.showCompletion()
}
];
this.initializeDemo();
}
initializeDemo() {
this.createDemoUI();
this.bindEvents();
}
createDemoUI() {
// Create demo control panel
const demoPanel = document.createElement('div');
demoPanel.id = 'demo-panel';
demoPanel.className = 'demo-panel';
demoPanel.style.cssText = `
position: fixed;
bottom: 20px;
left: 20px;
background: white;
border: 2px solid #007bff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 400px;
font-family: 'Poppins', sans-serif;
`;
demoPanel.innerHTML = `
<div class="demo-header">
<h5 class="mb-2">🚀 ThreeFold Demo</h5>
<div class="progress mb-3" style="height: 6px;">
<div class="progress-bar bg-primary" id="demo-progress" role="progressbar" style="width: 0%"></div>
</div>
</div>
<div class="demo-content">
<h6 id="demo-title">Welcome to ThreeFold Dashboard Demo</h6>
<p id="demo-description" class="text-muted small">This demo will showcase the complete interactive functionality of the ThreeFold ecosystem.</p>
</div>
<div class="demo-controls mt-3">
<button class="btn btn-primary btn-sm me-2" id="demo-next">Start Demo</button>
<button class="btn btn-outline-secondary btn-sm me-2" id="demo-prev" disabled>Previous</button>
<button class="btn btn-outline-danger btn-sm" id="demo-close">Close</button>
</div>
`;
document.body.appendChild(demoPanel);
}
bindEvents() {
document.getElementById('demo-next').addEventListener('click', () => this.nextStep());
document.getElementById('demo-prev').addEventListener('click', () => this.prevStep());
document.getElementById('demo-close').addEventListener('click', () => this.closeDemo());
}
nextStep() {
if (this.currentStep < this.steps.length - 1) {
this.currentStep++;
this.executeStep();
}
}
prevStep() {
if (this.currentStep > 0) {
this.currentStep--;
this.executeStep();
}
}
executeStep() {
const step = this.steps[this.currentStep];
// Update UI
document.getElementById('demo-title').textContent = step.title;
document.getElementById('demo-description').textContent = step.description;
// Update progress
const progress = ((this.currentStep + 1) / this.steps.length) * 100;
document.getElementById('demo-progress').style.width = `${progress}%`;
// Update buttons
document.getElementById('demo-prev').disabled = this.currentStep === 0;
const nextBtn = document.getElementById('demo-next');
if (this.currentStep === this.steps.length - 1) {
nextBtn.textContent = 'Finish';
} else {
nextBtn.textContent = 'Next';
}
// Execute step action
step.action();
}
showWelcome() {
showNotification('Welcome to the ThreeFold Dashboard Demo! 🎉', 'info');
}
demoAppRegistration() {
showNotification('Demo: Navigating to App Provider dashboard...', 'info');
setTimeout(() => {
if (window.location.pathname !== '/dashboard/app-provider') {
showNotification('Please navigate to the App Provider dashboard to continue the demo', 'warning');
return;
}
// Simulate clicking the register app button
const registerBtn = document.querySelector('[data-bs-target="#registerAppModal"]');
if (registerBtn) {
registerBtn.click();
setTimeout(() => {
// Fill in demo data
this.fillAppRegistrationForm();
}, 500);
}
}, 1000);
}
fillAppRegistrationForm() {
const formData = {
appName: 'Demo Secure Chat App',
appDesc: 'A decentralized, end-to-end encrypted chat application built for the ThreeFold Grid',
appCategory: 'communication',
appType: 'container',
appRepo: 'https://github.com/demo/secure-chat',
minCPU: '2',
minRAM: '4',
minStorage: '10',
pricingType: 'subscription',
priceAmount: '15'
};
Object.entries(formData).forEach(([key, value]) => {
const element = document.getElementById(key);
if (element) {
element.value = value;
element.dispatchEvent(new Event('change'));
}
});
// Check self-healing
const selfHealingCheckbox = document.getElementById('selfHealing');
if (selfHealingCheckbox) {
selfHealingCheckbox.checked = true;
}
showNotification('Demo form filled! Click "Register Application" to continue.', 'success');
}
demoServiceCreation() {
showNotification('Demo: Navigating to Service Provider dashboard...', 'info');
setTimeout(() => {
if (window.location.pathname !== '/dashboard/service-provider') {
showNotification('Please navigate to the Service Provider dashboard to continue the demo', 'warning');
return;
}
// Simulate clicking the create service button
const createBtn = document.querySelector('[data-bs-target="#createServiceModal"]');
if (createBtn) {
createBtn.click();
setTimeout(() => {
// Fill in demo data
this.fillServiceCreationForm();
}, 500);
}
}, 1000);
}
fillServiceCreationForm() {
const formData = {
serviceName: 'Demo ThreeFold Migration Service',
serviceDesc: 'Professional migration service to help businesses move their workloads to the ThreeFold Grid with zero downtime',
serviceCategory: 'migration',
serviceDelivery: 'hybrid',
pricingType: 'hourly',
priceAmount: '85',
serviceExperience: 'expert',
availableHours: '30',
responseTime: '4',
serviceSkills: 'Docker, Kubernetes, ThreeFold Grid, Cloud Migration, DevOps'
};
Object.entries(formData).forEach(([key, value]) => {
const element = document.getElementById(key);
if (element) {
element.value = value;
element.dispatchEvent(new Event('change'));
}
});
showNotification('Demo form filled! Click "Create Service" to continue.', 'success');
}
demoMarketplaceIntegration() {
showNotification('Demo: Your apps and services are now available in the marketplace!', 'success');
setTimeout(() => {
showNotification('Navigate to /marketplace/applications or /marketplace/services to see your listings', 'info');
}, 2000);
}
demoAppDeployment() {
showNotification('Demo: Simulating app deployment from marketplace...', 'info');
// Simulate marketplace purchase
const purchaseEvent = new CustomEvent('marketplacePurchase', {
detail: {
name: 'Demo Secure Chat App',
provider_name: 'Demo Provider',
price: 15
}
});
document.dispatchEvent(purchaseEvent);
}
demoNodeManagement() {
showNotification('Demo: Simulating farmer node management...', 'info');
setTimeout(() => {
// Simulate node status change
const nodeEvent = new CustomEvent('nodeStatusChange', {
detail: {
node: { id: 'TF-DEMO-001' },
oldStatus: 'Online',
newStatus: 'Maintenance'
}
});
document.dispatchEvent(nodeEvent);
}, 1000);
}
demoCrossIntegration() {
showNotification('Demo: Showing cross-dashboard integration...', 'info');
setTimeout(() => {
// Simulate deployment status change
const deploymentEvent = new CustomEvent('deploymentStatusChange', {
detail: {
deployment: { app_name: 'Demo Secure Chat App' },
oldStatus: 'Deploying',
newStatus: 'Active'
}
});
document.dispatchEvent(deploymentEvent);
}, 1000);
setTimeout(() => {
// Simulate new client request
const clientEvent = new CustomEvent('newClientRequest', {
detail: {
client_name: 'Demo Client Corp'
}
});
document.dispatchEvent(clientEvent);
}, 2000);
}
showCompletion() {
showNotification('🎉 Demo completed! You\'ve experienced the full ThreeFold ecosystem.', 'success');
setTimeout(() => {
this.closeDemo();
}, 3000);
}
closeDemo() {
const demoPanel = document.getElementById('demo-panel');
if (demoPanel) {
demoPanel.remove();
}
showNotification('Demo closed. Explore the dashboards on your own!', 'info');
}
}
// Auto-start demo if URL parameter is present
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('demo') === 'true') {
setTimeout(() => {
new DemoWorkflow();
}, 2000); // Wait for page to fully load
}
});
// Add demo starter button to all dashboard pages (currently hidden)
document.addEventListener('DOMContentLoaded', function() {
// Demo button is temporarily hidden
// Uncomment the code below to re-enable the Start Demo button
/*
if (window.location.pathname.includes('/dashboard/')) {
const demoButton = document.createElement('button');
demoButton.className = 'btn btn-outline-primary btn-sm demo-starter';
demoButton.innerHTML = '🚀 Start Demo';
demoButton.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9998;
`;
demoButton.addEventListener('click', () => {
new DemoWorkflow();
});
document.body.appendChild(demoButton);
}
*/
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = DemoWorkflow;
}

View File

@@ -0,0 +1,40 @@
/**
* Marketplace category pages functionality (applications, gateways, three_nodes)
* Migrated from inline scripts to use apiJson and shared error handlers
*/
document.addEventListener('DOMContentLoaded', function() {
// Add to cart functionality for all category pages
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const productId = this.dataset.productId;
if (!productId) {
showErrorToast('Product ID not found');
return;
}
setButtonLoading(this, 'Adding...');
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
product_id: productId,
quantity: 1
})
});
setButtonSuccess(this, 'Added!');
showSuccessToast('Item added to cart');
// Update cart count in navbar
if (typeof window.updateCartCount === 'function') {
window.updateCartCount();
}
} catch (error) {
handleApiError(error, 'adding to cart', this);
}
});
});
});

View File

@@ -0,0 +1,89 @@
(function(){
'use strict';
function showAuthenticationModal(message){
const html=`<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="authModalLabel"><i class="bi bi-lock me-2"></i>Authentication Required</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body text-center"><div class="mb-3"><i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i></div><p class="mb-3">${message}</p><div class="d-grid gap-2 d-md-flex justify-content-md-center"><a href="/login" class="btn btn-primary me-md-2"><i class="bi bi-box-arrow-in-right me-2"></i>Log In</a><a href="/register" class="btn btn-outline-primary"><i class="bi bi-person-plus me-2"></i>Register</a></div></div></div></div></div>`;
document.getElementById('authModal')?.remove();
document.body.insertAdjacentHTML('beforeend', html);
new bootstrap.Modal(document.getElementById('authModal')).show();
document.getElementById('authModal').addEventListener('hidden.bs.modal', function(){ this.remove(); });
}
function formatLocationDisplays(){
document.querySelectorAll('.node-location').forEach(el=>{
const loc=el.getAttribute('data-location');
if(!loc) return; const parts=loc.split(',').map(s=>s.trim());
el.textContent=(parts.length>=2 && parts[0]==='Unknown')?parts[1]:loc;
});
}
document.addEventListener('DOMContentLoaded', function(){
formatLocationDisplays();
document.querySelectorAll('.rent-product-btn').forEach(btn=>{
btn.addEventListener('click', async function(){
const id=this.dataset.productId, name=this.dataset.productName, price=this.dataset.price;
if(!confirm(`Rent "${name}" for $${price} per month?`)) return;
const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Processing...'; this.disabled=true;
try{
await window.apiJson(`/api/products/${id}/rent`, {
method:'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id:id, duration:'monthly' })
});
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Rented!';
this.classList.replace('btn-success','btn-info');
if (typeof window.showToast === 'function') { window.showToast(`Successfully rented "${name}"!`, 'success'); }
else { alert(`Successfully rented "${name}"!`); }
setTimeout(()=>{window.location.href='/dashboard';},1000);
}catch(e){
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
this.classList.replace('btn-success','btn-danger');
if (typeof window.showToast === 'function') { window.showToast(`Rental failed: ${e.message || 'Unknown error'}`, 'error'); }
else { alert(`Rental failed: ${e.message || 'Unknown error'}`); }
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-success'); this.disabled=false;},3000);
}
});
});
document.querySelectorAll('.buy-product-btn').forEach(btn=>{
btn.addEventListener('click', async function(){
const id=this.dataset.productId, name=this.dataset.productName, price=this.dataset.price;
if(!confirm(`Purchase "${name}" for $${price}?`)) return;
const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Processing...'; this.disabled=true;
try{
await window.apiJson(`/api/products/${id}/purchase`, { method:'POST' });
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Purchased!';
this.classList.replace('btn-primary','btn-info');
if (typeof window.showToast === 'function') { window.showToast(`Successfully purchased "${name}"!`, 'success'); }
else { alert(`Successfully purchased "${name}"!`); }
setTimeout(()=>{window.location.href='/dashboard';},1000);
}catch(e){
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
this.classList.replace('btn-primary','btn-danger');
if (typeof window.showToast === 'function') { window.showToast(`Purchase failed: ${e.message || 'Unknown error'}`, 'error'); }
else { alert(`Purchase failed: ${e.message || 'Unknown error'}`); }
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-primary'); this.disabled=false;},3000);
}
});
});
document.querySelectorAll('.add-to-cart-btn').forEach(btn=>{
btn.addEventListener('click', function(){
const id=this.dataset.productId; const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Adding...'; this.disabled=true;
window.apiJson('/api/cart/add', { method:'POST', body:{ product_id:id, quantity:1 } })
.then(()=>{
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Added!';
this.classList.replace('btn-primary','btn-success');
try { if (typeof window.updateCartCount === 'function') window.updateCartCount(); } catch(_){}
try { if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated(); } catch(_){}
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-success','btn-primary'); this.disabled=false;},2000);
})
.catch(e=>{
if (e && e.status === 401){ showAuthenticationModal(e.message || 'Make sure to register or log in to continue'); this.innerHTML=orig; this.disabled=false; return; }
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
console.error('Error adding to cart:',e);
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
this.classList.replace('btn-primary','btn-danger');
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-primary'); this.disabled=false;},2000);
});
});
});
});
})();

View File

@@ -0,0 +1,565 @@
// Marketplace Integration JavaScript
// This file handles the integration between dashboards and marketplace functionality
class MarketplaceIntegration {
constructor() {
this.initializeIntegration();
}
initializeIntegration() {
// Initialize marketplace integration features
this.setupAppProviderIntegration();
this.setupServiceProviderIntegration();
this.setupUserIntegration();
this.setupNotificationSystem();
}
// App Provider Integration
setupAppProviderIntegration() {
// Sync published apps to marketplace
this.syncAppsToMarketplace();
// Handle app publishing workflow
this.setupAppPublishingWorkflow();
}
syncAppsToMarketplace() {
// Get apps from session storage (simulated persistence)
const userApps = JSON.parse(sessionStorage.getItem('userApps') || '[]');
const marketplaceApps = JSON.parse(sessionStorage.getItem('marketplaceApps') || '[]');
// Sync new apps to marketplace
userApps.forEach(app => {
const existingApp = marketplaceApps.find(mApp => mApp.source_app_id === app.id);
if (!existingApp && app.status === 'Active') {
const marketplaceApp = this.convertAppToMarketplaceFormat(app);
marketplaceApps.push(marketplaceApp);
}
});
sessionStorage.setItem('marketplaceApps', JSON.stringify(marketplaceApps));
}
convertAppToMarketplaceFormat(app) {
const currentUser = userDB.getCurrentUser();
return {
id: 'mp-' + app.id,
source_app_id: app.id,
name: app.name,
description: app.description,
category: app.category,
provider_id: currentUser.id,
provider_name: currentUser.display_name,
provider_username: currentUser.username,
price: app.monthly_revenue || 50,
rating: app.rating || 0,
deployments: app.deployments || 0,
status: 'Available',
created_at: app.created_at || new Date().toISOString(),
featured: false,
tags: [app.category, 'self-healing', 'sovereign'],
attributes: {
cpu_cores: { value: 2, unit: 'cores' },
memory_gb: { value: 4, unit: 'GB' },
storage_gb: { value: 20, unit: 'GB' }
}
};
}
setupAppPublishingWorkflow() {
// Listen for app registration events
document.addEventListener('appRegistered', (event) => {
const app = event.detail;
this.publishAppToMarketplace(app);
});
}
publishAppToMarketplace(app) {
showNotification(`Publishing ${app.name} to marketplace...`, 'info');
setTimeout(() => {
// Add to marketplace
const marketplaceApps = JSON.parse(sessionStorage.getItem('marketplaceApps') || '[]');
const marketplaceApp = this.convertAppToMarketplaceFormat(app);
marketplaceApps.push(marketplaceApp);
sessionStorage.setItem('marketplaceApps', JSON.stringify(marketplaceApps));
showNotification(`${app.name} is now available in the marketplace!`, 'success');
}, 2000);
}
// Service Provider Integration
setupServiceProviderIntegration() {
// Sync services to marketplace
this.syncServicesToMarketplace();
// Handle service publishing workflow
this.setupServicePublishingWorkflow();
}
async syncServicesToMarketplace() {
// Get services from API and existing marketplace services
let userServices = [];
let marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
// Fetch user services from API
try {
const data = await window.apiJson('/api/dashboard/services', { cache: 'no-store' });
if (data && Array.isArray(data.services)) {
userServices = data.services;
}
} catch (error) {
console.error('Error fetching user services:', error);
userServices = []; // Fallback to empty array
}
// Sync new services to marketplace
userServices.forEach(service => {
const existingService = marketplaceServices.find(mService => mService.source_service_id === service.id);
if (!existingService && service.status === 'Active') {
const marketplaceService = this.convertServiceToMarketplaceFormat(service);
marketplaceServices.push(marketplaceService);
}
});
sessionStorage.setItem('marketplaceServices', JSON.stringify(marketplaceServices));
}
convertServiceToMarketplaceFormat(service) {
const currentUser = userDB.getCurrentUser();
return {
id: 'ms-' + service.id,
source_service_id: service.id,
name: service.name,
description: service.description,
category: service.category,
provider_id: currentUser.id,
provider_name: currentUser.display_name,
provider_username: currentUser.username,
price_per_hour: service.price_per_hour || 50,
rating: service.rating || 0,
clients: service.clients || 0,
status: 'Available',
created_at: service.created_at || new Date().toISOString(),
featured: false,
tags: [service.category, 'professional', 'threefold'],
delivery_method: 'remote',
response_time: '24 hours'
};
}
setupServicePublishingWorkflow() {
// Listen for service creation events
document.addEventListener('serviceCreated', (event) => {
const service = event.detail;
this.publishServiceToMarketplace(service);
});
}
publishServiceToMarketplace(service) {
showNotification(`Publishing ${service.name} to marketplace...`, 'info');
setTimeout(() => {
// Add to marketplace
const marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
const marketplaceService = this.convertServiceToMarketplaceFormat(service);
marketplaceServices.push(marketplaceService);
sessionStorage.setItem('marketplaceServices', JSON.stringify(marketplaceServices));
showNotification(`${service.name} is now available in the marketplace!`, 'success');
}, 2000);
}
// Enhanced service conversion for marketplace
convertServiceToMarketplaceFormat(service) {
return {
id: `marketplace-service-${service.id}`,
source_service_id: service.id,
name: service.name,
description: service.description,
category: service.category || 'Professional Services',
provider_name: 'Service Provider', // This would come from user data in real implementation
price_per_hour: service.price_per_hour || 0,
pricing_type: service.pricing_type || 'hourly',
experience_level: service.experience_level || 'intermediate',
response_time: service.response_time || 24,
skills: service.skills || [],
rating: service.rating || 0,
status: service.status || 'Active',
availability: service.status === 'Active' ? 'Available' : 'Unavailable',
created_at: service.created_at || new Date().toISOString(),
featured: false,
metadata: {
location: 'Remote',
rating: service.rating || 0,
review_count: 0,
tags: service.skills || []
}
};
}
// User Integration
setupUserIntegration() {
// Handle marketplace purchases
this.setupPurchaseWorkflow();
// Handle deployment tracking
this.setupDeploymentTracking();
// Listen for new service creation
this.setupServiceCreationListener();
}
setupServiceCreationListener() {
// Listen for service creation events from service provider dashboard
document.addEventListener('serviceCreated', (event) => {
const service = event.detail;
console.log('New service created:', service);
// Automatically publish to marketplace
setTimeout(() => {
this.publishServiceToMarketplace(service);
}, 500);
});
}
setupPurchaseWorkflow() {
// Listen for marketplace purchases
document.addEventListener('marketplacePurchase', (event) => {
const purchase = event.detail;
this.handleMarketplacePurchase(purchase);
});
}
handleMarketplacePurchase(purchase) {
showNotification(`Processing purchase of ${purchase.name}...`, 'info');
setTimeout(() => {
// Add to user's deployments
const userDeployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
const deployment = {
id: 'dep-' + Date.now(),
app_name: purchase.name,
status: 'Deploying',
deployed_at: new Date().toISOString(),
provider: purchase.provider_name,
cost: purchase.price,
region: 'Auto-selected'
};
userDeployments.push(deployment);
sessionStorage.setItem('userDeployments', JSON.stringify(userDeployments));
showNotification(`${purchase.name} deployment started!`, 'success');
// Simulate deployment completion
setTimeout(() => {
deployment.status = 'Active';
sessionStorage.setItem('userDeployments', JSON.stringify(userDeployments));
showNotification(`${purchase.name} is now active!`, 'success');
}, 5000);
}, 2000);
}
setupDeploymentTracking() {
// Track deployment status changes
this.monitorDeployments();
}
monitorDeployments() {
// Simulate real-time deployment monitoring
setInterval(() => {
const deployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
deployments.forEach(deployment => {
if (deployment.status === 'Deploying') {
// Simulate deployment progress
const random = Math.random();
if (random > 0.8) {
deployment.status = 'Active';
sessionStorage.setItem('userDeployments', JSON.stringify(deployments));
showNotification(`${deployment.app_name} is now active!`, 'success');
}
}
});
}, 10000); // Check every 10 seconds
}
// Cross-Dashboard Data Sharing
shareDataBetweenDashboards() {
// Share app provider data with user dashboard
const userApps = JSON.parse(sessionStorage.getItem('userApps') || '[]');
const userDeployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
// Update deployment counts for apps
userApps.forEach(app => {
const appDeployments = userDeployments.filter(dep => dep.app_name === app.name);
app.deployments = appDeployments.length;
});
sessionStorage.setItem('userApps', JSON.stringify(userApps));
}
// Notification System Integration
setupNotificationSystem() {
// Setup cross-dashboard notifications
this.setupCrossDashboardNotifications();
}
setupCrossDashboardNotifications() {
// Listen for various events and show notifications
document.addEventListener('deploymentStatusChange', (event) => {
const { deployment, oldStatus, newStatus } = event.detail;
showNotification(`${deployment.app_name} status changed from ${oldStatus} to ${newStatus}`, 'info');
});
document.addEventListener('newClientRequest', (event) => {
const request = event.detail;
showNotification(`New service request from ${request.client_name}`, 'info');
});
document.addEventListener('nodeStatusChange', (event) => {
const { node, oldStatus, newStatus } = event.detail;
const statusType = newStatus === 'Online' ? 'success' :
newStatus === 'Offline' ? 'error' : 'warning';
showNotification(`Node ${node.id} is now ${newStatus}`, statusType);
});
}
// Utility Functions
generateMockData() {
// Generate mock marketplace data if none exists
if (!sessionStorage.getItem('marketplaceApps')) {
// Get real users from user database
const sarahUser = userDB.getUser('user-002'); // Sarah Chen - App Provider
const mockApps = [
{
id: 'mp-mock-1',
source_app_id: 'app-mock-1',
name: 'Secure File Storage',
description: 'Decentralized file storage with end-to-end encryption',
category: 'Storage',
provider_id: sarahUser.id,
provider_name: sarahUser.display_name,
provider_username: sarahUser.username,
price: 25,
rating: 4.5,
deployments: 150,
status: 'Available',
featured: true,
tags: ['storage', 'encryption', 'decentralized'],
created_at: '2024-03-01T10:00:00Z',
attributes: {
cpu_cores: { value: 2, unit: 'cores' },
memory_gb: { value: 4, unit: 'GB' },
storage_gb: { value: 20, unit: 'GB' }
}
},
{
id: 'mp-mock-2',
source_app_id: 'app-mock-2',
name: 'Team Collaboration Hub',
description: 'Self-hosted team collaboration platform',
category: 'Productivity',
provider_id: sarahUser.id,
provider_name: sarahUser.display_name,
provider_username: sarahUser.username,
price: 40,
rating: 4.8,
deployments: 89,
status: 'Available',
featured: false,
tags: ['collaboration', 'productivity', 'team'],
created_at: '2024-03-15T14:30:00Z',
attributes: {
cpu_cores: { value: 4, unit: 'cores' },
memory_gb: { value: 8, unit: 'GB' },
storage_gb: { value: 50, unit: 'GB' }
}
}
];
sessionStorage.setItem('marketplaceApps', JSON.stringify(mockApps));
}
if (!sessionStorage.getItem('marketplaceServices')) {
// Get real users from user database
const mikeUser = userDB.getUser('user-003'); // Mike Rodriguez - Service Provider
const emmaUser = userDB.getUser('user-004'); // Emma Wilson - Service Provider
const mockServices = [
{
id: 'ms-mock-1',
source_service_id: 'service-mock-1',
name: 'ThreeFold Migration Service',
description: 'Professional migration from cloud providers to ThreeFold Grid',
category: 'Migration',
provider_id: mikeUser.id,
provider_name: mikeUser.display_name,
provider_username: mikeUser.username,
price_per_hour: 75,
rating: 4.9,
clients: 25,
status: 'Available',
featured: true,
tags: ['migration', 'cloud', 'professional'],
created_at: '2024-02-15T09:00:00Z',
delivery_method: 'remote',
response_time: '24 hours'
},
{
id: 'ms-mock-2',
source_service_id: 'service-mock-2',
name: 'Security Audit & Hardening',
description: 'Comprehensive security assessment and hardening services',
category: 'Security',
provider_id: emmaUser.id,
provider_name: emmaUser.display_name,
provider_username: emmaUser.username,
price_per_hour: 100,
rating: 4.7,
clients: 18,
status: 'Available',
featured: false,
tags: ['security', 'audit', 'hardening'],
created_at: '2024-03-20T11:30:00Z',
delivery_method: 'remote',
response_time: '12 hours'
}
];
sessionStorage.setItem('marketplaceServices', JSON.stringify(mockServices));
}
if (!sessionStorage.getItem('marketplaceSlices')) {
// Get real users from user database
const alexUser = userDB.getUser('user-001'); // Alex Thompson - Farmer
const mockSlices = [
{
id: 'slice-mock-1',
name: 'Basic Slice',
provider_id: alexUser.id,
provider: alexUser.display_name,
provider_username: alexUser.username,
nodeId: 'TF-1001',
resources: {
cpu_cores: 2,
memory_gb: 4,
storage_gb: 100
},
location: alexUser.location,
price_per_hour: 0.1,
price_per_month: 50,
uptime_sla: 99.5,
certified: false,
available: true,
created_at: new Date(Date.now() - 86400000).toISOString() // 1 day ago
},
{
id: 'slice-mock-2',
name: 'Standard Slice',
provider_id: alexUser.id,
provider: alexUser.display_name,
provider_username: alexUser.username,
nodeId: 'TF-1002',
resources: {
cpu_cores: 4,
memory_gb: 8,
storage_gb: 250
},
location: alexUser.location,
price_per_hour: 0.2,
price_per_month: 100,
uptime_sla: 99.8,
certified: true,
available: true,
created_at: new Date(Date.now() - 172800000).toISOString() // 2 days ago
},
{
id: 'slice-mock-3',
name: 'Performance Slice',
provider_id: alexUser.id,
provider: alexUser.display_name,
provider_username: alexUser.username,
nodeId: 'TF-1003',
resources: {
cpu_cores: 8,
memory_gb: 16,
storage_gb: 500
},
location: alexUser.location,
price_per_hour: 0.4,
price_per_month: 175,
uptime_sla: 99.9,
certified: true,
available: true,
created_at: new Date(Date.now() - 259200000).toISOString() // 3 days ago
}
];
sessionStorage.setItem('marketplaceSlices', JSON.stringify(mockSlices));
}
}
}
// Global notification function (shared across all dashboards)
function showNotification(message, type = 'info') {
// Remove existing notifications
const existingNotifications = document.querySelectorAll('.dashboard-notification');
existingNotifications.forEach(notification => notification.remove());
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${getBootstrapAlertClass(type)} alert-dismissible fade show dashboard-notification`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
max-width: 500px;
`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
// Add to page
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
function getBootstrapAlertClass(type) {
const typeMap = {
'success': 'success',
'error': 'danger',
'warning': 'warning',
'info': 'info'
};
return typeMap[type] || 'info';
}
// Initialize marketplace integration when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
// Only initialize if we're on a dashboard page
if (window.location.pathname.includes('/dashboard/')) {
const integration = new MarketplaceIntegration();
integration.generateMockData();
// Share data between dashboards
integration.shareDataBetweenDashboards();
// Make integration available globally
window.marketplaceIntegration = integration;
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MarketplaceIntegration;
}

View File

@@ -0,0 +1,118 @@
// marketplace_dashboard.js
// Externalized logic for Marketplace Overview page (CSP-compliant)
(function () {
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
onReady(function () {
// Event delegation for Add to Cart buttons
document.addEventListener('click', function (e) {
const btn = e.target.closest('.add-to-cart-btn');
if (!btn) return;
const productId = btn.getAttribute('data-product-id');
const productName = btn.getAttribute('data-product-name') || 'Item';
const productPrice = btn.getAttribute('data-product-price') || '';
if (!productId) {
console.warn('Missing data-product-id on .add-to-cart-btn');
return;
}
addToCart(productId, productName, productPrice, btn);
});
});
async function addToCart(productId, productName, productPrice, buttonElement) {
const originalText = buttonElement.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Adding...';
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ product_id: productId, quantity: 1 }),
});
buttonElement.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
buttonElement.classList.remove('btn-primary');
buttonElement.classList.add('btn-success');
showToast(productName + ' added to cart!', 'success');
if (typeof window.updateCartCount === 'function') {
try { window.updateCartCount(); } catch (_) {}
}
try {
if (typeof window.emitCartUpdated === 'function') {
window.emitCartUpdated();
}
} catch (e) { console.debug('emitCartUpdated failed:', e); }
setTimeout(() => {
buttonElement.innerHTML = originalText;
buttonElement.classList.remove('btn-success');
buttonElement.classList.add('btn-outline-primary');
buttonElement.disabled = false;
}, 2000);
} catch (error) {
// Let global 402 interceptor handle UI for insufficient funds
if (error && error.status === 402) {
buttonElement.innerHTML = originalText;
buttonElement.disabled = false;
return;
}
console.error('Error adding to cart:', error);
buttonElement.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
buttonElement.classList.remove('btn-primary');
buttonElement.classList.add('btn-danger');
showToast('Failed to add ' + productName + ' to cart. Please try again.', 'error');
setTimeout(() => {
buttonElement.innerHTML = originalText;
buttonElement.classList.remove('btn-danger');
buttonElement.classList.add('btn-outline-primary');
buttonElement.disabled = false;
}, 2000);
}
}
function showToast(message, type) {
// If Bootstrap Toast is available, use it
if (window.bootstrap && typeof window.bootstrap.Toast === 'function') {
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-' + (type === 'success' ? 'success' : 'danger') + ' border-0 position-fixed end-0 m-3';
toast.style.top = '80px';
toast.style.zIndex = '10000';
toast.innerHTML = '\n <div class="d-flex">\n <div class="toast-body">\n <i class="bi bi-' + (type === 'success' ? 'check-circle' : 'exclamation-triangle') + ' me-2"></i>' + message + '\n </div>\n <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>\n </div>\n ';
document.body.appendChild(toast);
try {
const bsToast = new window.bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
} catch (_) {
// Fallback remove
setTimeout(() => toast.remove(), 3000);
}
return;
}
// Fallback simple alert-like toast
const note = document.createElement('div');
note.style.cssText = 'position:fixed;right:1rem;top:5rem;z-index:10000;padding:.75rem 1rem;border-radius:.25rem;color:#fff;box-shadow:0 .5rem 1rem rgba(0,0,0,.15)';
note.style.background = type === 'success' ? '#198754' : '#dc3545';
note.textContent = message;
document.body.appendChild(note);
setTimeout(() => note.remove(), 2500);
}
})();

View File

@@ -0,0 +1,60 @@
// marketplace_layout.js
// Handles marketplace sidebar toggle and backdrop interactions (CSP-compliant)
(function () {
document.addEventListener('DOMContentLoaded', function () {
const sidebarToggleBtn = document.getElementById('sidebarToggleBtn');
const sidebar = document.getElementById('sidebar');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
if (!sidebarToggleBtn || !sidebar || !sidebarBackdrop) {
// Elements not present on this page; nothing to bind
return;
}
// Ensure clean state on page load
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
// Toggle sidebar visibility
sidebarToggleBtn.addEventListener('click', function (event) {
event.stopPropagation();
event.preventDefault();
// Toggle visibility
sidebar.classList.toggle('show');
sidebarBackdrop.classList.toggle('show');
// Set aria-expanded for accessibility
const isExpanded = sidebar.classList.contains('show');
sidebarToggleBtn.setAttribute('aria-expanded', String(isExpanded));
});
// Close sidebar when clicking on backdrop
sidebarBackdrop.addEventListener('click', function (event) {
event.stopPropagation();
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
});
// Close sidebar when clicking on any link inside it
const sidebarLinks = sidebar.querySelectorAll('a.nav-link');
sidebarLinks.forEach((link) => {
link.addEventListener('click', function () {
// Small delay to ensure the link click happens
setTimeout(function () {
sidebar.classList.remove('show');
sidebarBackdrop.classList.remove('show');
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
}, 100);
});
});
// Ensure links are clickable
sidebar.addEventListener('click', function (event) {
event.stopPropagation();
});
});
})();

View File

@@ -0,0 +1,962 @@
/**
* Generic Messaging System for Project Mycelium
* Handles communication between users and providers.
*/
class MessagingSystem {
constructor() {
this.currentThread = null;
this.threads = [];
this.unreadCount = 0;
this.isInitialized = false;
this.pollInterval = null;
this.init();
}
async init() {
if (this.isInitialized) return;
try {
// Set up current user email first
this.getCurrentUserEmail();
await this.loadThreads();
this.setupEventListeners();
this.startPolling();
this.isInitialized = true;
console.log('📨 Messaging system initialized for user:', window.currentUserEmail);
} catch (error) {
console.error('Failed to initialize messaging system:', error);
}
}
/**
* Load all message threads for current user
*/
async loadThreads() {
try {
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
console.log('📨 Threads API response:', JSON.stringify(data, null, 2));
this.threads = data.threads || [];
this.unreadCount = data.unread_count || 0;
console.log('📨 Loaded threads:', this.threads.length, 'unread:', this.unreadCount);
if (this.threads.length > 0) {
console.log('📨 First thread sample:', JSON.stringify(this.threads[0], null, 2));
}
this.updateUnreadBadge();
} catch (error) {
console.error('Error loading message threads:', error);
this.threads = [];
}
}
/**
* Start a new conversation or continue existing one
* @param {string} recipientEmail - Email of the recipient
* @param {string} contextType - Type of context (service_booking, slice_rental, etc.)
* @param {string} contextId - ID of the context object
* @param {string} subject - Subject/title of the conversation
*/
async startConversation(recipientEmail, contextType = 'general', contextId = null, subject = null) {
try {
// Check if thread already exists
const existingThread = this.threads.find(t =>
t.recipient_email === recipientEmail &&
t.context_type === contextType &&
t.context_id === contextId
);
if (existingThread) {
this.openThread(existingThread.thread_id);
return;
}
// Create new thread
const threadData = {
recipient_email: recipientEmail,
context_type: contextType,
context_id: contextId,
subject: subject || `${contextType.replace('_', ' ')} conversation`
};
const response = await window.apiJson('/api/messages/threads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(threadData)
});
this.currentThread = response.thread;
this.openMessagingModal();
await this.loadThreadMessages(response.thread.thread_id);
} catch (error) {
console.error('Error starting conversation:', error);
window.showNotification?.('Failed to start conversation', 'error');
}
}
/**
* Open existing message thread
*/
async openThread(threadId) {
try {
const thread = this.threads.find(t => t.thread_id === threadId);
if (!thread) {
throw new Error('Thread not found');
}
this.currentThread = thread;
this.openMessagingModal();
await this.loadThreadMessages(threadId);
} catch (error) {
console.error('Error opening thread:', error);
window.showNotification?.('Failed to open conversation', 'error');
}
}
/**
* Load messages for a specific thread
*/
async loadThreadMessages(threadId) {
try {
const data = await window.apiJson(`/api/messages/threads/${threadId}/messages`, { cache: 'no-store' });
this.displayMessages(data.messages || [], threadId);
} catch (error) {
console.error('Error loading thread messages:', error);
}
}
/**
* Send a message in the current thread
*/
async sendMessage(threadId, content, messageType = 'text') {
if (!content.trim()) return;
try {
const messageData = {
thread_id: threadId,
content: content.trim(),
message_type: messageType
};
const response = await window.apiJson('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messageData)
});
// Add message to UI immediately for better UX
this.addMessageToUI(response.message, true);
// Clear inputs
const messageInput = document.getElementById('messageInput');
const panelInput = document.getElementById('conversationMessageInput');
if (messageInput) messageInput.value = '';
if (panelInput) panelInput.value = '';
// Refresh thread list to update last message
await this.loadThreads();
// If we're in the panel view, refresh the conversation list
if (document.getElementById('threadsListContainer')) {
this.renderThreadsList();
}
// Don't increment notifications for messages we send ourselves
// The recipient will get notified via polling when they receive the message
} catch (error) {
console.error('Error sending message:', error);
window.showNotification?.('Failed to send message', 'error');
}
}
/**
* Mark thread as read
*/
async markThreadAsRead(threadId) {
try {
await window.apiJson(`/api/messages/threads/${threadId}/read`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
}
});
// Update local state
const thread = this.threads.find(t => t.thread_id === threadId);
if (thread) {
const readCount = thread.unread_count;
if (readCount > 0) {
this.unreadCount -= readCount;
thread.unread_count = 0;
this.updateUnreadBadge();
// Notify notification system
if (window.notificationSystem) {
window.notificationSystem.markAsRead(readCount);
}
// Dispatch custom event
document.dispatchEvent(new CustomEvent('messageRead', {
detail: { threadId, count: readCount }
}));
// Update modal thread list if it's open
this.renderThreadsList();
}
}
} catch (error) {
console.error('Error marking thread as read:', error);
}
}
/**
* Open the messaging modal
*/
openMessagingModal() {
let modal = document.getElementById('messagingModal');
if (!modal) {
this.createMessagingModal();
modal = document.getElementById('messagingModal');
}
this.renderThreadHeader();
const bootstrapModal = new bootstrap.Modal(modal);
bootstrapModal.show();
}
/**
* Create the messaging modal HTML structure
*/
createMessagingModal() {
const modalHTML = `
<div class="modal fade" id="messagingModal" tabindex="-1" aria-labelledby="messagingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messagingModalLabel">Messages</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0" style="height: 500px;">
<div class="row g-0 h-100">
<!-- Thread List Sidebar -->
<div class="col-md-4 border-end">
<div class="p-3 border-bottom">
<h6 class="mb-0">Conversations</h6>
</div>
<div class="thread-list" id="threadList" style="height: calc(100% - 60px); overflow-y: auto;">
<!-- Thread list will be populated here -->
</div>
</div>
<!-- Message Area -->
<div class="col-md-8 d-flex flex-column">
<div class="p-3 border-bottom flex-shrink-0" id="threadHeader">
<div class="text-center text-muted">
Select a conversation to view messages
</div>
</div>
<div class="messages-container flex-grow-1" id="messagesContainer" style="overflow-y: auto; padding: 1rem; min-height: 0;">
<!-- Messages will be populated here -->
</div>
<div class="border-top p-3 flex-shrink-0" id="messageInputArea" style="display: none;">
<div class="input-group">
<input type="text" class="form-control" id="messageInput" placeholder="Type your message..." maxlength="1000">
<button class="btn btn-primary" type="button" id="sendMessageBtn">
<i class="bi bi-send"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Add messaging styles
this.addMessagingStyles();
}
/**
* Add CSS styles for modern messaging appearance
*/
addMessagingStyles() {
if (document.getElementById('messaging-styles')) return;
const style = document.createElement('style');
style.id = 'messaging-styles';
style.textContent = `
.message-bubble {
position: relative;
word-wrap: break-word;
border: 1px solid rgba(0,0,0,0.1);
}
.message-bubble.own-message {
background: #007bff !important;
color: white !important;
border-color: #0056b3;
}
.message-bubble.other-message {
background: #e9ecef !important;
color: #212529 !important;
border-color: #dee2e6;
}
.message-bubble.bg-primary {
background: #007bff !important;
color: white !important;
border-color: #0056b3;
}
.message-bubble.bg-light {
background: #e9ecef !important;
color: #212529 !important;
border-color: #dee2e6;
}
.message-tail-right::after {
content: '';
position: absolute;
top: 10px;
right: -8px;
width: 0;
height: 0;
border: 8px solid transparent;
border-left-color: #007bff;
border-right: 0;
border-top: 0;
margin-top: -4px;
}
.message-tail-left::after {
content: '';
position: absolute;
top: 10px;
left: -8px;
width: 0;
height: 0;
border: 8px solid transparent;
border-right-color: #f8f9fa;
border-left: 0;
border-top: 0;
margin-top: -4px;
}
.message-wrapper {
animation: messageSlideIn 0.2s ease-out;
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.messages-container, #conversationMessages {
background: linear-gradient(to bottom, #fafafa 0%, #ffffff 100%);
position: relative;
overflow-y: auto !important;
overflow-x: hidden;
height: calc(100% - 80px) !important;
max-height: calc(100% - 80px) !important;
}
.modal-body {
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
}
#messageInputSection {
flex-shrink: 0 !important;
display: block !important;
visibility: visible !important;
position: sticky !important;
bottom: 0 !important;
z-index: 10 !important;
background: white !important;
}
.col-8 {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
}
`;
document.head.appendChild(style);
}
/**
* Render the thread header
*/
renderThreadHeader() {
const header = document.getElementById('threadHeader');
const inputArea = document.getElementById('messageInputArea');
if (!this.currentThread) {
header.innerHTML = `
<div class="text-center text-muted">
Select a conversation to view messages
</div>
`;
inputArea.style.display = 'none';
return;
}
header.innerHTML = `
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h6 class="mb-0">${this.currentThread.subject || 'Conversation'}</h6>
<small class="text-muted">with ${this.currentThread.recipient_email}</small>
</div>
<div class="text-end">
<span class="badge bg-secondary">${this.currentThread.context_type.replace('_', ' ')}</span>
</div>
</div>
`;
inputArea.style.display = 'block';
}
/**
* Display messages in the UI
*/
displayMessages(messages, threadId) {
// Check if we're in the new panel view or old modal view
const panelContainer = document.getElementById('conversationMessages');
const modalContainer = document.getElementById('messagesContainer');
const container = panelContainer || modalContainer;
if (!container) return;
if (!messages || messages.length === 0) {
container.innerHTML = `
<div class="text-center text-muted">
<i class="bi bi-chat-dots fs-1"></i>
<p class="mt-2">No messages yet. Start the conversation!</p>
</div>
`;
return;
}
container.innerHTML = '';
messages.forEach(message => {
this.addMessageToUI(message, false, container);
});
// Scroll to bottom
container.scrollTop = container.scrollHeight;
}
/**
* Render messages in the current thread
*/
renderMessages(messages) {
const container = document.getElementById('messagesContainer');
if (!container) return;
container.innerHTML = '';
if (messages.length === 0) {
container.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi bi-chat-dots fs-1"></i>
<p class="mt-2">No messages yet. Start the conversation!</p>
</div>
`;
return;
}
messages.forEach(message => {
this.addMessageToUI(message, false);
});
// Scroll to bottom
container.scrollTop = container.scrollHeight;
}
/**
* Add a single message to the UI
*/
addMessageToUI(message, scrollToBottom = true, targetContainer = null) {
const container = targetContainer || document.getElementById('conversationMessages') || document.getElementById('messagesContainer');
if (!container) {
console.error('Message container not found - available containers:',
document.getElementById('conversationMessages') ? 'conversationMessages found' : 'conversationMessages missing',
document.getElementById('messagesContainer') ? 'messagesContainer found' : 'messagesContainer missing'
);
return;
}
const messageTime = new Date(message.timestamp).toLocaleString();
// Use thread structure to determine message ownership
// In the thread, recipient_email is the OTHER user, so if sender != recipient, it's current user's message
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
// For sender name: "You" for own messages, complete email for others
const senderName = isOwnMessage ? 'You' : message.sender_email;
const messageHTML = `
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
<div class="message-wrapper" style="max-width: 70%;">
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${senderName}</div>` : ''}
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm" style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
<div class="message-content">${this.escapeHtml(message.content)}</div>
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
</div>
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${senderName}</div>` : ''}
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', messageHTML);
if (scrollToBottom) {
container.scrollTop = container.scrollHeight;
}
}
/**
* Get current user email from various sources
*/
getCurrentUserEmail() {
// Return cached value if available
if (window.currentUserEmail) {
return window.currentUserEmail;
}
// Try to get from session data first
const sessionData = document.getElementById('session-data');
if (sessionData) {
try {
const data = JSON.parse(sessionData.textContent);
if (data.user && data.user.email) {
window.currentUserEmail = data.user.email;
return data.user.email;
}
} catch (e) {
console.error('Error parsing session data:', e);
}
}
// Try to get from navbar dropdown data API response
if (window.navbarData && window.navbarData.user && window.navbarData.user.email) {
window.currentUserEmail = window.navbarData.user.email;
return window.navbarData.user.email;
}
// Try to get from user dropdown link href
const userDropdownLink = document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]');
if (userDropdownLink) {
const href = userDropdownLink.getAttribute('href');
const emailMatch = href.match(/\/dashboard\/user\/([^\/]+@[^\/]+)/);
if (emailMatch) {
const email = decodeURIComponent(emailMatch[1]);
window.currentUserEmail = email;
return email;
}
}
// Try to get from API response data
if (window.userDashboardData && window.userDashboardData.user && window.userDashboardData.user.email) {
window.currentUserEmail = window.userDashboardData.user.email;
return window.userDashboardData.user.email;
}
// Try to get from threads API response by analyzing thread ownership
if (this.threads && this.threads.length > 0) {
// In threads response, the current user is NOT the recipient_email
// Find the most common non-recipient email across threads
const nonRecipientEmails = new Set();
this.threads.forEach(thread => {
// The current user should be the one who is NOT the recipient in their own thread list
if (thread.messages && thread.messages.length > 0) {
thread.messages.forEach(message => {
if (message.sender_email !== thread.recipient_email) {
nonRecipientEmails.add(message.sender_email);
}
});
}
});
if (nonRecipientEmails.size === 1) {
const email = Array.from(nonRecipientEmails)[0];
window.currentUserEmail = email;
return email;
}
}
// Try to extract from current page URL if on dashboard/user page
if (window.location.pathname.includes('/dashboard/user/')) {
const pathParts = window.location.pathname.split('/');
const emailIndex = pathParts.indexOf('user') + 1;
if (emailIndex < pathParts.length) {
const email = decodeURIComponent(pathParts[emailIndex]);
if (email.includes('@')) {
window.currentUserEmail = email;
return email;
}
}
}
// Try to get from any element with data-user-email attribute
const userEmailElement = document.querySelector('[data-user-email]');
if (userEmailElement) {
const email = userEmailElement.getAttribute('data-user-email');
if (email && email.includes('@')) {
window.currentUserEmail = email;
return email;
}
}
console.warn('Could not determine current user email - available elements:', {
sessionData: !!document.getElementById('session-data'),
sessionDataContent: document.getElementById('session-data')?.textContent?.substring(0, 100),
userDropdown: !!document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]'),
userDropdownHref: document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]')?.getAttribute('href'),
navbarData: !!window.navbarData,
navbarDataUser: window.navbarData?.user,
threadsCount: this.threads ? this.threads.length : 0,
currentPath: window.location.pathname,
userEmailElement: !!userEmailElement,
userEmailValue: userEmailElement?.getAttribute('data-user-email')
});
return null;
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Send message on button click
document.addEventListener('click', (e) => {
if (e.target.id === 'sendMessageBtn' || e.target.closest('#sendMessageBtn')) {
const input = document.getElementById('messageInput');
if (input && input.value.trim() && this.currentThread) {
this.sendMessage(this.currentThread.thread_id, input.value.trim());
}
}
});
// Send message on Enter key
document.addEventListener('keypress', (e) => {
if (e.target.id === 'messageInput' && e.key === 'Enter') {
e.preventDefault();
if (e.target.value.trim() && this.currentThread) {
this.sendMessage(this.currentThread.thread_id, e.target.value.trim());
}
}
});
}
/**
* Start polling for new messages
*/
startPolling() {
if (this.pollInterval) return;
this.pollInterval = setInterval(async () => {
try {
const previousUnreadCount = this.unreadCount;
await this.loadThreads();
// Check if we received new messages (unread count increased)
if (this.unreadCount > previousUnreadCount) {
// Dispatch event for notification system
document.dispatchEvent(new CustomEvent('messageReceived', {
detail: {
count: this.unreadCount - previousUnreadCount,
senderEmail: 'another user' // Generic for now
}
}));
}
// If we have a current thread open, refresh its messages
if (this.currentThread) {
await this.loadThreadMessages(this.currentThread.thread_id);
}
} catch (error) {
console.error('Error polling for messages:', error);
}
}, 10000); // Poll every 10 seconds for faster updates
}
/**
* Stop polling
*/
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
/**
* Update unread message badge
*/
updateUnreadBadge() {
const badges = document.querySelectorAll('.message-badge, .unread-messages-badge, .navbar-message-badge');
badges.forEach(badge => {
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
});
}
/**
* Utility function to escape HTML
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Cleanup when page unloads
*/
destroy() {
this.stopPolling();
this.isInitialized = false;
}
}
// Global messaging system instance
window.messagingSystem = null;
// Initialize messaging system when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
window.messagingSystem = new MessagingSystem();
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.messagingSystem) {
window.messagingSystem.destroy();
}
});
// Expose messaging functions globally for easy access
window.startConversation = function(recipientEmail, contextType = 'general', contextId = null, subject = null) {
if (window.messagingSystem) {
return window.messagingSystem.startConversation(recipientEmail, contextType, contextId, subject);
}
};
window.openMessaging = function() {
if (window.messagingSystem) {
window.messagingSystem.openThreadsList();
}
};
// Add thread list functionality to MessagingSystem
MessagingSystem.prototype.openThreadsList = function() {
this.createThreadsListModal();
const modal = new bootstrap.Modal(document.getElementById('threadsListModal'));
modal.show();
this.renderThreadsList();
};
MessagingSystem.prototype.createThreadsListModal = function() {
if (document.getElementById('threadsListModal')) return;
const modalHTML = `
<div class="modal fade" id="threadsListModal" tabindex="-1" aria-labelledby="threadsListModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="threadsListModalLabel">
<i class="bi bi-chat-dots me-2"></i>Messages
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0" style="height: 600px; max-height: 80vh;">
<div class="row g-0 h-100">
<!-- Left Panel: Conversations List -->
<div class="col-4 border-end">
<div class="p-3 border-bottom bg-light">
<h6 class="mb-0">Conversations</h6>
</div>
<div id="threadsListContainer" style="height: calc(100% - 60px); overflow-y: auto;">
<div class="text-center p-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading conversations...</p>
</div>
</div>
</div>
<!-- Right Panel: Conversation View -->
<div class="col-8">
<div id="conversationViewContainer" class="h-100">
<div class="d-flex align-items-center justify-content-center h-100 text-muted">
<div class="text-center">
<i class="bi bi-chat-square-text fs-1"></i>
<p class="mt-2">Select a conversation to view messages</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
};
MessagingSystem.prototype.renderThreadsList = function() {
const container = document.getElementById('threadsListContainer');
if (!container) return;
if (!this.threads || this.threads.length === 0) {
container.innerHTML = `
<div class="text-center text-muted p-4">
<i class="bi bi-chat-dots fs-3"></i>
<p class="mt-2 mb-1">No conversations yet</p>
<small>Start a conversation from your service bookings</small>
</div>
`;
return;
}
let threadsHTML = '<div class="list-group list-group-flush">';
this.threads.forEach(thread => {
const lastMessageTime = thread.last_message_at ?
new Date(thread.last_message_at).toLocaleDateString() :
new Date(thread.created_at).toLocaleDateString();
const unreadBadge = thread.unread_count > 0 ?
`<span class="badge bg-primary rounded-pill">${thread.unread_count}</span>` : '';
threadsHTML += `
<div class="list-group-item list-group-item-action border-0 px-3 py-2" onclick="window.messagingSystem.selectThreadInPanel('${thread.thread_id}')">
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<h6 class="mb-1 fw-semibold">${this.escapeHtml(thread.subject)}</h6>
<p class="mb-1 text-muted small">With: ${this.escapeHtml(thread.recipient_email)}</p>
${thread.last_message ? `<small class="text-muted">${this.escapeHtml(thread.last_message.substring(0, 50))}${thread.last_message.length > 50 ? '...' : ''}</small>` : ''}
</div>
<div class="text-end">
<small class="text-muted">${lastMessageTime}</small>
${unreadBadge ? `<div class="mt-1">${unreadBadge}</div>` : ''}
</div>
</div>
</div>
`;
});
threadsHTML += '</div>';
container.innerHTML = threadsHTML;
};
MessagingSystem.prototype.selectThreadInPanel = function(threadId) {
// Find the thread
const thread = this.threads.find(t => t.thread_id === threadId);
if (!thread) return;
this.currentThread = thread;
this.renderConversationView(threadId);
this.loadThreadMessages(threadId);
// Always mark thread as read when selected
this.markThreadAsRead(threadId);
// Update active state in thread list
const container = document.getElementById('threadsListContainer');
if (container) {
container.querySelectorAll('.list-group-item').forEach(item => {
item.classList.remove('active');
});
container.querySelector(`[onclick*="${threadId}"]`)?.classList.add('active');
}
};
MessagingSystem.prototype.renderConversationView = function(threadId) {
const container = document.getElementById('conversationViewContainer');
if (!container || !this.currentThread) return;
const thread = this.currentThread;
const conversationHTML = `
<div class="h-100 d-flex flex-column">
<!-- Conversation Header -->
<div class="p-3 border-bottom bg-light flex-shrink-0">
<h6 class="mb-1">${this.escapeHtml(thread.subject)}</h6>
<small class="text-muted">With: ${this.escapeHtml(thread.recipient_email)}</small>
</div>
<!-- Messages Container -->
<div id="conversationMessages" class="p-3" style="flex: 1; overflow-y: auto; overflow-x: hidden; background: linear-gradient(to bottom, #fafafa 0%, #ffffff 100%);">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading messages...</span>
</div>
</div>
</div>
<!-- Message Input -->
<div class="p-3 border-top" id="messageInputSection" style="flex-shrink: 0;">
<div class="input-group">
<input type="text" id="conversationMessageInput" class="form-control" placeholder="Type your message..." onkeypress="if(event.key==='Enter') window.messagingSystem.sendMessageFromPanel()">
<button class="btn btn-primary" onclick="window.messagingSystem.sendMessageFromPanel()">
<i class="bi bi-send"></i>
</button>
</div>
</div>
</div>
`;
container.innerHTML = conversationHTML;
this.addMessagingStyles();
};
MessagingSystem.prototype.sendMessageFromPanel = function() {
const input = document.getElementById('conversationMessageInput');
if (!input || !input.value.trim() || !this.currentThread) {
console.log('Send message failed:', {
input: !!input,
hasValue: input ? !!input.value.trim() : false,
hasThread: !!this.currentThread
});
return;
}
const content = input.value.trim();
input.value = '';
this.sendMessage(this.currentThread.thread_id, content);
};
MessagingSystem.prototype.openThreadFromList = function(threadId) {
// Close threads list modal
const threadsModal = bootstrap.Modal.getInstance(document.getElementById('threadsListModal'));
if (threadsModal) {
threadsModal.hide();
}
// Open the specific thread in the old modal
this.openThread(threadId);
};

View File

@@ -0,0 +1,199 @@
// Modal System for Project Mycelium
class ModalSystem {
constructor() {
this.modals = new Map();
this.initializeModalContainer();
}
initializeModalContainer() {
// Create modal container if it doesn't exist
if (!document.getElementById('modal-container')) {
const container = document.createElement('div');
container.id = 'modal-container';
document.body.appendChild(container);
}
}
showModal(id, options = {}) {
const {
title = 'Notification',
message = '',
type = 'info', // info, success, error, warning, confirm
confirmText = 'OK',
cancelText = 'Cancel',
showCancel = false,
onConfirm = () => {},
onCancel = () => {},
onClose = () => {}
} = options;
// Remove existing modal with same ID
this.hideModal(id);
const modalHtml = `
<div class="modal fade" id="${id}" tabindex="-1" aria-labelledby="${id}Label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header ${this.getHeaderClass(type)}">
<h5 class="modal-title" id="${id}Label">
${this.getIcon(type)} ${title}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
${message}
</div>
<div class="modal-footer">
${showCancel ? `<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${cancelText}</button>` : ''}
<button type="button" class="btn ${this.getButtonClass(type)}" id="${id}-confirm">${confirmText}</button>
</div>
</div>
</div>
</div>
`;
// Add modal to container
const container = document.getElementById('modal-container');
container.insertAdjacentHTML('beforeend', modalHtml);
// Get modal element
const modalElement = document.getElementById(id);
const modal = new bootstrap.Modal(modalElement);
// Store modal reference
this.modals.set(id, modal);
// Add event listeners
const confirmBtn = document.getElementById(`${id}-confirm`);
confirmBtn.addEventListener('click', () => {
onConfirm();
modal.hide();
});
modalElement.addEventListener('hidden.bs.modal', () => {
onClose();
this.hideModal(id);
});
// Show modal
modal.show();
return modal;
}
hideModal(id) {
const modal = this.modals.get(id);
if (modal) {
modal.hide();
this.modals.delete(id);
}
// Remove modal element from DOM
const modalElement = document.getElementById(id);
if (modalElement) {
modalElement.remove();
}
}
getHeaderClass(type) {
switch (type) {
case 'success': return 'bg-success text-white';
case 'error': return 'bg-danger text-white';
case 'warning': return 'bg-warning text-dark';
case 'confirm': return 'bg-primary text-white';
default: return 'bg-light';
}
}
getButtonClass(type) {
switch (type) {
case 'success': return 'btn-success';
case 'error': return 'btn-danger';
case 'warning': return 'btn-warning';
case 'confirm': return 'btn-primary';
default: return 'btn-primary';
}
}
getIcon(type) {
switch (type) {
case 'success': return '<i class="bi bi-check-circle-fill"></i>';
case 'error': return '<i class="bi bi-exclamation-triangle-fill"></i>';
case 'warning': return '<i class="bi bi-exclamation-triangle-fill"></i>';
case 'confirm': return '<i class="bi bi-question-circle-fill"></i>';
default: return '<i class="bi bi-info-circle-fill"></i>';
}
}
// Convenience methods
showSuccess(title, message, onConfirm = () => {}) {
return this.showModal('success-modal', {
title,
message,
type: 'success',
confirmText: 'Great!',
onConfirm
});
}
showError(title, message, onConfirm = () => {}) {
return this.showModal('error-modal', {
title,
message,
type: 'error',
confirmText: 'OK',
onConfirm
});
}
showConfirm(title, message, onConfirm = () => {}, onCancel = () => {}) {
return this.showModal('confirm-modal', {
title,
message,
type: 'confirm',
confirmText: 'Yes',
cancelText: 'No',
showCancel: true,
onConfirm,
onCancel
});
}
showAuthRequired(onConfirm = () => {}) {
return this.showModal('auth-required-modal', {
title: 'Authentication Required',
message: 'Please log in or register to make purchases. Would you like to go to the dashboard to continue?',
type: 'confirm',
confirmText: 'Go to Dashboard',
cancelText: 'Cancel',
showCancel: true,
onConfirm: () => {
window.location.href = '/dashboard';
onConfirm();
}
});
}
showInsufficientBalance(shortfall, onTopUp = () => {}) {
return this.showModal('insufficient-balance-modal', {
title: 'Insufficient Balance',
message: `You need $${shortfall.toFixed(2)} more in your wallet to complete this purchase. Would you like to add credits to your wallet?`,
type: 'warning',
confirmText: 'Add Credits',
cancelText: 'Cancel',
showCancel: true,
onConfirm: () => {
window.location.href = '/dashboard/wallet?action=topup';
onTopUp();
}
});
}
}
// Global modal system instance
window.modalSystem = new ModalSystem();
// Global convenience functions
window.showSuccessModal = (title, message, onConfirm) => window.modalSystem.showSuccess(title, message, onConfirm);
window.showErrorModal = (title, message, onConfirm) => window.modalSystem.showError(title, message, onConfirm);
window.showConfirmModal = (title, message, onConfirm, onCancel) => window.modalSystem.showConfirm(title, message, onConfirm, onCancel);

View File

@@ -0,0 +1,326 @@
/**
* Enhanced Notification System for Project Mycelium
* Provides industry-standard message notifications across the platform
*/
class NotificationSystem {
constructor() {
this.unreadCount = 0;
this.lastNotificationCheck = null;
this.notificationInterval = null;
this.isInitialized = false;
this.init();
}
async init() {
if (this.isInitialized) return;
try {
await this.loadUnreadCount();
this.startNotificationPolling();
this.setupEventListeners();
this.isInitialized = true;
console.log('🔔 Notification system initialized');
} catch (error) {
console.error('Failed to initialize notification system:', error);
}
}
/**
* Load current unread message count
*/
async loadUnreadCount() {
try {
// Use the existing threads endpoint which includes unread_count
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
console.log('🔔 Notification API response:', JSON.stringify(data, null, 2));
this.unreadCount = data.unread_count || 0;
this.updateAllBadges();
console.log('📊 Notification system: unread count =', this.unreadCount);
} catch (error) {
console.error('Error loading unread count:', error);
this.unreadCount = 0;
}
}
/**
* Update all notification badges across the platform
*/
updateAllBadges() {
const selectors = [
'.message-badge',
];
selectors.forEach(selector => {
const badges = document.querySelectorAll(selector);
badges.forEach(badge => {
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = 'inline';
badge.classList.add('animate-pulse');
// Remove pulse animation after 2 seconds
setTimeout(() => {
badge.classList.remove('animate-pulse');
}, 2000);
} else {
badge.style.display = 'none';
badge.classList.remove('animate-pulse');
}
});
});
// Update sidebar badge
const sidebarBadge = document.querySelector('.sidebar-message-count');
if (sidebarBadge) {
if (this.unreadCount > 0) {
sidebarBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
sidebarBadge.style.display = 'inline-block';
sidebarBadge.classList.add('pulse-animation');
} else {
sidebarBadge.style.display = 'none';
sidebarBadge.classList.remove('pulse-animation');
}
}
// Update navbar badge
const navbarBadge = document.getElementById('navbar-message-badge');
if (navbarBadge) {
if (this.unreadCount > 0) {
navbarBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
navbarBadge.classList.remove('d-none');
} else {
navbarBadge.classList.add('d-none');
}
}
// Update dropdown badge
const dropdownBadge = document.getElementById('dropdown-message-count');
if (dropdownBadge) {
dropdownBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
}
// Update document title with unread count
this.updateDocumentTitle();
}
/**
* Update document title to show unread count
*/
updateDocumentTitle() {
const baseTitle = 'Project Mycelium';
if (this.unreadCount > 0) {
document.title = `(${this.unreadCount}) ${baseTitle}`;
} else {
document.title = baseTitle;
}
}
/**
* Start polling for notification updates
*/
startNotificationPolling() {
if (this.notificationInterval) return;
// Initial load
this.loadUnreadCount();
this.notificationInterval = setInterval(async () => {
try {
const previousCount = this.unreadCount;
await this.loadUnreadCount();
// Show desktop notification for new messages
if (this.unreadCount > previousCount && this.hasNotificationPermission()) {
this.showDesktopNotification(
'New Message',
`You have ${this.unreadCount - previousCount} new message(s)`,
'/static/images/logo_light.png'
);
}
} catch (error) {
console.error('Error polling notifications:', error);
}
}, 10000); // Poll every 10 seconds for faster updates
}
/**
* Stop notification polling
*/
stopNotificationPolling() {
if (this.notificationInterval) {
clearInterval(this.notificationInterval);
this.notificationInterval = null;
}
}
/**
* Check if browser supports and has permission for desktop notifications
*/
hasNotificationPermission() {
return 'Notification' in window && Notification.permission === 'granted';
}
/**
* Request desktop notification permission
*/
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('This browser does not support desktop notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
}
/**
* Show desktop notification
*/
showDesktopNotification(title, body, icon = null) {
if (!this.hasNotificationPermission()) return;
const notification = new Notification(title, {
body: body,
icon: icon,
badge: icon,
tag: 'threefold-message',
requireInteraction: false,
silent: false
});
// Auto-close after 5 seconds
setTimeout(() => {
notification.close();
}, 5000);
// Handle click to open messages
notification.onclick = () => {
window.focus();
if (window.openMessaging) {
window.openMessaging();
}
notification.close();
};
}
/**
* Mark messages as read and update count
*/
markAsRead(count = null) {
if (count !== null) {
this.unreadCount = Math.max(0, this.unreadCount - count);
} else {
this.unreadCount = 0;
}
this.updateAllBadges();
}
/**
* Add new unread messages
*/
addUnread(count = 1) {
this.unreadCount += count;
this.updateAllBadges();
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Listen for messaging system updates
document.addEventListener('messageRead', (event) => {
this.markAsRead(event.detail.count);
});
// Listen for new messages received by this user (not sent by them)
document.addEventListener('messageReceived', (event) => {
this.addUnread(1);
// Show desktop notification immediately
if (this.hasNotificationPermission()) {
this.showDesktopNotification(
'New Message',
`New message from ${event.detail.senderEmail}`,
'/static/images/logo_light.png'
);
}
});
// Request notification permission on first user interaction
document.addEventListener('click', () => {
this.requestNotificationPermission();
}, { once: true });
// Handle visibility change to refresh when tab becomes active
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.loadUnreadCount();
}
});
}
/**
* Cleanup when page unloads
*/
destroy() {
this.stopNotificationPolling();
this.isInitialized = false;
}
}
// Global notification system instance
window.notificationSystem = null;
// Initialize notification system when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Only initialize if user is logged in
if (document.querySelector('#userDropdown')) {
window.notificationSystem = new NotificationSystem();
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.notificationSystem) {
window.notificationSystem.destroy();
}
});
// Add CSS for pulse animation
const style = document.createElement('style');
style.textContent = `
.animate-pulse {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
.notification-badge {
font-size: 0.7rem;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
`;
document.head.appendChild(style);

276
src/static/js/orders.js Normal file
View File

@@ -0,0 +1,276 @@
// Orders page logic externalized for CSP compliance
(function () {
function initialize() {
// Initialize filters from URL
initializeFilters();
// Bind filters
const statusFilter = document.getElementById('statusFilter');
const dateFilter = document.getElementById('dateRange');
if (statusFilter) statusFilter.addEventListener('change', filterOrdersClientSide);
if (dateFilter) dateFilter.addEventListener('change', filterOrdersClientSide);
// Bind export button
const exportBtn = document.querySelector('[data-action="export-orders"], #export-orders');
if (exportBtn && !exportBtn.dataset.bound) {
exportBtn.addEventListener('click', function (e) {
e.preventDefault();
exportOrders();
});
exportBtn.dataset.bound = '1';
}
// Initial filter pass
filterOrdersClientSide();
}
function initializeFilters() {
const urlParams = new URLSearchParams(window.location.search);
const currentStatus = urlParams.get('status');
const currentDays = urlParams.get('days');
if (currentStatus) {
const statusFilter = document.getElementById('statusFilter');
if (statusFilter) statusFilter.value = currentStatus;
}
if (currentDays) {
const dateRange = document.getElementById('dateRange');
if (dateRange) dateRange.value = currentDays;
}
}
function filterOrdersClientSide() {
const statusFilter = document.getElementById('statusFilter')?.value;
const dateFilter = document.getElementById('dateRange')?.value;
const orderCards = document.querySelectorAll('.order-card');
orderCards.forEach((card) => {
let showCard = true;
if (statusFilter && statusFilter !== '') {
const statusBadge = card.querySelector('.status-badge');
if (statusBadge) {
const orderStatus = statusBadge.textContent.trim().toLowerCase();
const filterStatus = statusFilter.toLowerCase();
if (!orderStatus.includes(filterStatus)) {
showCard = false;
}
}
}
if (dateFilter && dateFilter !== '' && showCard) {
const dateElement = card.querySelector('.text-muted');
if (dateElement) {
const dateText = dateElement.textContent.replace('Placed on ', '');
const orderDate = new Date(dateText);
const now = new Date();
const daysAgo = parseInt(dateFilter);
const cutoffDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
if (orderDate < cutoffDate) {
showCard = false;
}
}
}
card.style.display = showCard ? 'block' : 'none';
});
updateVisibleOrderCount();
showNoResultsMessage();
}
function clearAllFilters() {
const statusFilter = document.getElementById('statusFilter');
const dateRange = document.getElementById('dateRange');
if (statusFilter) statusFilter.value = '';
if (dateRange) dateRange.value = '';
filterOrdersClientSide();
}
function showNoResultsMessage() {
const visibleOrders = document.querySelectorAll(
'.order-card[style*="block"], .order-card:not([style*="none"])'
);
const ordersContainer = document.querySelector('.col-lg-8');
const existingMessage = document.getElementById('no-results-message');
if (existingMessage) existingMessage.remove();
if (visibleOrders.length === 0 && ordersContainer) {
const noResultsDiv = document.createElement('div');
noResultsDiv.id = 'no-results-message';
noResultsDiv.className = 'text-center py-5';
const icon = document.createElement('i');
icon.className = 'bi bi-search fs-1 text-muted mb-3';
const h5 = document.createElement('h5');
h5.className = 'text-muted';
h5.textContent = 'No orders match your filters';
const p = document.createElement('p');
p.className = 'text-muted';
p.textContent = 'Try adjusting your filter criteria to see more results.';
const btn = document.createElement('button');
btn.className = 'btn btn-outline-primary';
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Clear Filters';
btn.addEventListener('click', clearAllFilters);
noResultsDiv.appendChild(icon);
noResultsDiv.appendChild(h5);
noResultsDiv.appendChild(p);
noResultsDiv.appendChild(btn);
ordersContainer.appendChild(noResultsDiv);
}
}
function updateVisibleOrderCount() {
const visibleOrders = document.querySelectorAll(
'.order-card[style*="block"], .order-card:not([style*="none"])'
);
const totalOrders = document.querySelectorAll('.order-card');
const countElements = document.querySelectorAll('.order-count');
countElements.forEach((el) => {
el.textContent = `${visibleOrders.length} of ${totalOrders.length}`;
});
}
function exportOrders() {
const exportData = [];
Array.from(document.querySelectorAll('.order-card'))
.filter((card) => card.style.display !== 'none')
.forEach((card) => {
const orderId = card.querySelector('h5')?.textContent?.replace('Order #', '') || '';
const status = card.querySelector('.status-badge')?.textContent?.trim().replace(/\s+/g, ' ') || '';
const date = card.querySelector('.text-muted')?.textContent?.replace('Placed on ', '') || '';
const total = card.querySelector('.h4.text-primary')?.textContent?.trim() || '';
const paymentMethod = card.querySelector('.col-md-4 .mb-3')?.textContent?.trim() || '';
const confirmationNumber = card.querySelector('.card-footer strong')?.textContent?.trim() || '';
const itemElements = card.querySelectorAll('.d-flex.align-items-center.mb-2');
if (itemElements.length > 0) {
itemElements.forEach((itemElement) => {
const productName = itemElement.querySelector('.fw-bold')?.textContent?.trim() || '';
const itemDetails = itemElement.querySelector('.text-muted')?.textContent?.trim() || '';
const itemPrice = itemElement.querySelector('.text-end .fw-bold')?.textContent?.trim() || '';
const detailsParts = itemDetails.split(' • ');
const provider = detailsParts[0] || '';
const quantityMatch = itemDetails.match(/Qty:\s*(\d+)/);
const quantity = quantityMatch ? quantityMatch[1] : '';
let category = '';
const iconElement = itemElement.querySelector('i[class*="bi-"]');
if (iconElement) {
if (iconElement.classList.contains('bi-cpu')) category = 'Compute';
else if (iconElement.classList.contains('bi-hdd-rack')) category = 'Hardware';
else if (iconElement.classList.contains('bi-globe')) category = 'Gateways';
else if (iconElement.classList.contains('bi-app')) category = 'Applications';
else if (iconElement.classList.contains('bi-person-workspace')) category = 'Services';
else category = 'Other';
}
exportData.push({
'Order ID': orderId,
Status: status,
Date: date,
'Product Name': productName,
Category: category,
Provider: provider,
Quantity: quantity,
'Item Price': itemPrice,
'Order Total': total,
'Payment Method': paymentMethod,
'Confirmation Number': confirmationNumber,
});
});
} else {
exportData.push({
'Order ID': orderId,
Status: status,
Date: date,
'Product Name': '',
Category: '',
Provider: '',
Quantity: '',
'Item Price': '',
'Order Total': total,
'Payment Method': paymentMethod,
'Confirmation Number': confirmationNumber,
});
}
});
if (exportData.length === 0) {
showToast('No orders to export', 'warning');
return;
}
const csvContent = convertToCSV(exportData);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `threefold_orders_detailed_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const orderCount = new Set(exportData.map((row) => row['Order ID'])).size;
showToast(`Exported ${orderCount} orders with ${exportData.length} items successfully`, 'success');
}
function convertToCSV(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const csvRows = [];
csvRows.push(headers.join(','));
for (const row of data) {
const values = headers.map((header) => {
const value = row[header] || '';
if (value.includes(',') || value.includes('\n') || value.includes('"')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
csvRows.push(values.join(','));
}
return csvRows.join('\n');
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
toast.style.top = '80px';
toast.style.zIndex = '10000';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-info-circle me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Expose helpers for potential future use
window.OrdersPage = {
filter: filterOrdersClientSide,
clearFilters: clearAllFilters,
export: exportOrders,
};
})();

View File

@@ -0,0 +1,38 @@
// Shared print utilities for CSP-compliant pages
// Binds click listeners to elements with class `.js-print` or `[data-action="print"]`
(function () {
function bindPrintButtons() {
const handler = function (e) {
e.preventDefault();
try {
window.print();
} catch (err) {
// Silently fail; printing may be blocked in some contexts
if (window && window.console) {
console.warn('Print action failed:', err);
}
}
};
// Bind to static buttons present at load
const selectors = ['.js-print', '[data-action="print"]'];
document.querySelectorAll(selectors.join(',')).forEach((el) => {
// Avoid double-binding
if (!el.dataset.printBound) {
el.addEventListener('click', handler);
el.dataset.printBound = '1';
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindPrintButtons);
} else {
bindPrintButtons();
}
// Expose a minimal API in case pages need to (re)bind after dynamic content updates
window.PrintUtils = {
bind: bindPrintButtons,
};
})();

View File

@@ -0,0 +1,98 @@
/**
* Product detail step 2 functionality
* Handles quantity controls and add-to-cart with price calculation
*/
document.addEventListener('DOMContentLoaded', function() {
const quantityInput = document.getElementById('quantity');
const decreaseBtn = document.getElementById('decreaseQty');
const increaseBtn = document.getElementById('increaseQty');
const totalPriceElement = document.getElementById('totalPrice');
const addToCartBtn = document.getElementById('addToCartBtn');
if (!quantityInput || !addToCartBtn) return;
const unitPrice = parseFloat(addToCartBtn.dataset.unitPrice);
const currency = addToCartBtn.dataset.currency;
// Quantity controls
if (decreaseBtn) {
decreaseBtn.addEventListener('click', function() {
const currentValue = parseInt(quantityInput.value);
if (currentValue > 1) {
quantityInput.value = currentValue - 1;
updateTotalPrice();
}
});
}
if (increaseBtn) {
increaseBtn.addEventListener('click', function() {
const currentValue = parseInt(quantityInput.value);
if (currentValue < 10) {
quantityInput.value = currentValue + 1;
updateTotalPrice();
}
});
}
quantityInput.addEventListener('change', function() {
const value = parseInt(this.value);
if (value < 1) this.value = 1;
if (value > 10) this.value = 10;
updateTotalPrice();
});
function updateTotalPrice() {
if (!totalPriceElement) return;
const quantity = parseInt(quantityInput.value);
const total = (unitPrice * quantity).toFixed(2);
totalPriceElement.textContent = `${total} ${currency}`;
}
// Add to cart functionality
addToCartBtn.addEventListener('click', async function() {
const quantity = parseInt(quantityInput.value);
const productId = this.dataset.productId;
const productName = this.dataset.productName;
try {
// Show loading state
window.setButtonLoading(this, '<i class="bi bi-hourglass-split me-2"></i>Adding...');
// Add to cart API call using apiJson
const response = await window.apiJson('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
product_id: productId,
quantity: quantity
})
});
// Show success message
window.setButtonSuccess(this, 'Added!', 2000);
// Update cart count in header
if (window.updateCartCount) {
window.updateCartCount();
}
// Notify other listeners about cart update
try {
const meta = (response && response.metadata) ? response.metadata : {};
if (typeof window.emitCartUpdated === 'function') {
window.emitCartUpdated(meta.cart_count);
}
} catch (e) {
console.debug('cartUpdated event dispatch failed:', e);
}
} catch (error) {
console.error('Error adding to cart:', error);
window.handleApiError(error, 'adding item to cart', this);
}
});
});

View File

@@ -0,0 +1,171 @@
/**
* Product detail page functionality
* Migrated from inline scripts to use apiJson and shared error handlers
*/
document.addEventListener('DOMContentLoaded', function() {
const quantityInput = document.getElementById('quantity');
const decreaseBtn = document.getElementById('decreaseQty');
const increaseBtn = document.getElementById('increaseQty');
const totalPriceElement = document.getElementById('totalPrice');
const addToCartBtn = document.getElementById('addToCartBtn');
const currencySelector = document.getElementById('currencySelector');
// Get pricing data from button attributes
const unitPrice = addToCartBtn ? parseFloat(addToCartBtn.dataset.unitPrice) : 0;
const currency = addToCartBtn ? addToCartBtn.dataset.currency : 'USD';
// Quantity controls
if (decreaseBtn && increaseBtn && quantityInput) {
decreaseBtn.addEventListener('click', function() {
const currentValue = parseInt(quantityInput.value);
if (currentValue > 1) {
quantityInput.value = currentValue - 1;
updateTotalPrice();
}
});
increaseBtn.addEventListener('click', function() {
const currentValue = parseInt(quantityInput.value);
if (currentValue < 10) {
quantityInput.value = currentValue + 1;
updateTotalPrice();
}
});
quantityInput.addEventListener('change', function() {
const value = parseInt(this.value);
if (value < 1) this.value = 1;
if (value > 10) this.value = 10;
updateTotalPrice();
});
}
function updateTotalPrice() {
if (totalPriceElement && quantityInput) {
const quantity = parseInt(quantityInput.value);
const total = (unitPrice * quantity).toFixed(2);
totalPriceElement.textContent = `${total} ${currency}`;
}
}
// Add to cart functionality for main product
if (addToCartBtn) {
addToCartBtn.addEventListener('click', async function() {
const quantity = parseInt(quantityInput?.value) || 1;
const productId = this.dataset.productId;
const productName = this.dataset.productName;
if (!productId) {
showErrorToast('Product ID not found');
return;
}
setButtonLoading(this, 'Adding...');
try {
const data = await window.apiJson('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
product_id: productId,
quantity: quantity
})
});
setButtonSuccess(this, 'Added!');
showSuccessToast('Item added to cart');
// Update cart count in navbar
if (typeof window.updateCartCount === 'function') {
window.updateCartCount();
}
// Notify other listeners about cart update
try {
const meta = (data && data.metadata) ? data.metadata : {};
if (typeof window.emitCartUpdated === 'function') {
window.emitCartUpdated(meta.cart_count);
}
} catch (e) {
console.debug('cartUpdated event dispatch failed:', e);
}
} catch (error) {
handleApiError(error, 'adding to cart', this);
}
});
}
// Currency selector
if (currencySelector) {
currencySelector.addEventListener('change', async function() {
const newCurrency = this.value;
try {
await window.apiJson('/api/user/currency', {
method: 'POST',
body: JSON.stringify({
currency: newCurrency
})
});
// Reload page to show new prices
window.location.reload();
} catch (error) {
handleApiError(error, 'updating currency');
}
});
}
// Add to cart for recommendation buttons
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const productId = this.dataset.productId;
const productName = this.dataset.productName;
if (!productId) {
showErrorToast('Product ID not found');
return;
}
setButtonLoading(this, 'Adding...');
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
product_id: productId,
quantity: 1
})
});
// Show success state
this.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
this.classList.remove('btn-outline-primary');
this.classList.add('btn-success');
showSuccessToast(`${productName} added to cart!`);
// Update cart count in navbar
if (typeof window.updateCartCount === 'function') {
window.updateCartCount();
}
// Reset button after 2 seconds
setTimeout(() => {
resetButton(this);
this.classList.remove('btn-success');
this.classList.add('btn-outline-primary');
}, 2000);
} catch (error) {
handleApiError(error, 'adding to cart', this);
// Reset button styling after error
setTimeout(() => {
this.classList.remove('btn-danger');
this.classList.add('btn-outline-primary');
}, 2000);
}
});
});
});

View File

@@ -0,0 +1,72 @@
/**
* Products page functionality
* Handles view mode toggle and add-to-cart functionality
*/
document.addEventListener('DOMContentLoaded', function() {
// View mode toggle
const gridView = document.getElementById('grid-view');
const listView = document.getElementById('list-view');
const productsGrid = document.getElementById('products-grid');
const productsList = document.getElementById('products-list');
if (gridView && listView && productsGrid && productsList) {
gridView.addEventListener('change', function() {
if (this.checked) {
productsGrid.classList.remove('d-none');
productsList.classList.add('d-none');
}
});
listView.addEventListener('change', function() {
if (this.checked) {
productsGrid.classList.add('d-none');
productsList.classList.remove('d-none');
}
});
}
// Add to cart functionality
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const productId = this.dataset.productId;
const productName = this.dataset.productName;
try {
// Show loading state
window.setButtonLoading(this, '<i class="bi bi-hourglass-split me-1"></i>Adding...');
// Add to cart API call using apiJson
const response = await window.apiJson('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
product_id: productId,
quantity: 1
})
});
// Show success message
window.setButtonSuccess(this, '<i class="bi bi-check-circle me-1"></i>Added!', 2000);
// Update cart count in header
if (window.updateCartCount) {
window.updateCartCount();
}
// Notify other listeners about cart update
try {
const meta = (response && response.metadata) ? response.metadata : {};
if (typeof window.emitCartUpdated === 'function') {
window.emitCartUpdated(meta.cart_count);
}
} catch (e) {
console.debug('cartUpdated event dispatch failed:', e);
}
} catch (error) {
console.error('Error adding to cart:', error);
window.handleApiError(error, 'adding item to cart', this);
}
});
});
});

365
src/static/js/services.js Normal file
View File

@@ -0,0 +1,365 @@
(function () {
'use strict';
function parseHydrationData() {
try {
const el = document.getElementById('services-data');
if (!el) return {};
const txt = el.textContent || '{}';
return JSON.parse(txt);
} catch (e) {
console.debug('services hydration parse failed:', e);
return {};
}
}
function qs(selector, root = document) {
return root.querySelector(selector);
}
function qsa(selector, root = document) {
return Array.from(root.querySelectorAll(selector));
}
function showAuthenticationModal(message) {
const modalHtml = `
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="authModalLabel">
<i class="bi bi-lock me-2"></i>Authentication Required
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<div class="mb-3">
<i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i>
</div>
<p class="mb-3">${message}</p>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="/login" class="btn btn-primary me-md-2">
<i class="bi bi-box-arrow-in-right me-2"></i>Log In
</a>
<a href="/register" class="btn btn-outline-primary">
<i class="bi bi-person-plus me-2"></i>Register
</a>
</div>
</div>
</div>
</div>
</div>`;
const existing = document.getElementById('authModal');
if (existing) existing.remove();
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('authModal'));
modal.show();
document.getElementById('authModal').addEventListener('hidden.bs.modal', function () {
this.remove();
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) notification.remove();
}, 3000);
}
function bindAddToCart(btn) {
if (!btn || btn.dataset.bound === '1') return;
btn.dataset.bound = '1';
btn.addEventListener('click', async function () {
const productId = this.dataset.productId;
const originalText = this.innerHTML;
this.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Booking...';
this.disabled = true;
try {
await window.apiJson('/api/cart/add', {
method: 'POST',
body: { product_id: productId, quantity: 1 },
});
this.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
this.classList.remove('btn-primary');
this.classList.add('btn-success');
if (typeof window.updateCartCount === 'function') {
try { window.updateCartCount(); } catch (_) {}
}
try {
if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated();
} catch (e) {
console.debug('cartUpdated event dispatch failed:', e);
}
setTimeout(() => {
this.innerHTML = originalText;
this.classList.remove('btn-success');
this.classList.add('btn-primary');
this.disabled = false;
}, 2000);
} catch (error) {
// 401: require authentication
if (error && error.status === 401) {
showAuthenticationModal('Make sure to register or log in to continue');
this.innerHTML = originalText;
this.disabled = false;
return;
}
// 402: insufficient funds handled globally by interceptor
if (error && error.status === 402) {
this.innerHTML = originalText;
this.disabled = false;
return;
}
console.error('Error adding to cart:', error);
this.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
this.classList.remove('btn-primary');
this.classList.add('btn-danger');
setTimeout(() => {
this.innerHTML = originalText;
this.classList.remove('btn-danger');
this.classList.add('btn-primary');
this.disabled = false;
}, 2000);
}
});
}
function generateStarRating(rating) {
const full = Math.floor(rating || 0);
const half = (rating || 0) % 1 !== 0;
const empty = 5 - full - (half ? 1 : 0);
let html = '';
for (let i = 0; i < full; i++) html += '<i class="bi bi-star-fill text-warning"></i>';
if (half) html += '<i class="bi bi-star-half text-warning"></i>';
for (let i = 0; i < empty; i++) html += '<i class="bi bi-star text-muted"></i>';
return html;
}
function createServiceCard(service) {
const col = document.createElement('div');
col.className = 'col-lg-6 mb-4';
const priceDisplay = service.pricing_type === 'hourly'
? `$${service.price_per_hour}/hour`
: `$${service.price_per_hour}`;
const skillsDisplay = Array.isArray(service.skills)
? service.skills.slice(0, 3).map(skill => `<span class="badge bg-light text-dark me-1 mb-1">${skill}</span>`).join('')
: '';
const ratingStars = generateStarRating(service.rating);
col.innerHTML = `
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="service-icon me-3">
<i class="bi bi-gear-fill fs-2 text-primary"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<h5 class="card-title mb-1">
<a href="#" class="text-decoration-none text-dark">${service.name}</a>
</h5>
<span class="badge bg-success">Available</span>
</div>
<div class="text-muted small mb-2">
<i class="bi bi-building me-1"></i>${service.provider_name}
<span class="ms-2"><i class="bi bi-geo-alt me-1"></i>Remote</span>
</div>
</div>
</div>
<p class="card-text">${service.description}</p>
<div class="mb-3">
<h6 class="mb-2">Service Details:</h6>
<div class="row">
<div class="col-md-6">
<div class="service-detail"><i class="bi bi-star me-2"></i><span>Level: ${service.experience_level}</span></div>
</div>
<div class="col-md-6">
<div class="service-detail"><i class="bi bi-reply me-2"></i><span>Response: ${service.response_time}h</span></div>
</div>
</div>
</div>
${skillsDisplay ? `
<div class="mb-3">
<h6 class="mb-2">Skills:</h6>
<div class="d-flex flex-wrap">${skillsDisplay}</div>
</div>` : ''}
<div class="mb-3">
<div class="d-flex align-items-center">
<span class="me-2">Rating:</span>
${ratingStars}
<span class="ms-2 text-muted small">(New Service)</span>
</div>
</div>
</div>
<div class="card-footer bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<div class="price-info">
<div class="fw-bold text-primary fs-5">${priceDisplay}</div>
<small class="text-muted">per engagement</small>
</div>
<div class="btn-group">
<button class="btn btn-primary btn-sm contact-btn"><i class="bi bi-envelope me-1"></i>Contact</button>
<button class="btn btn-outline-primary btn-sm view-details-btn">View Details</button>
</div>
</div>
</div>
</div>`;
const contactBtn = col.querySelector('.contact-btn');
const detailsBtn = col.querySelector('.view-details-btn');
contactBtn.addEventListener('click', () => contactServiceProvider(service.id, service.name));
detailsBtn.addEventListener('click', () => viewServiceDetails(service.id));
return col;
}
function displaySessionServices(services) {
const grid = document.getElementById('services-grid') || qs('.row');
if (!grid) return;
const existingEmptyState = grid.querySelector('.col-12 .text-center');
if (existingEmptyState) existingEmptyState.parentElement.remove();
services.forEach(service => {
const card = createServiceCard(service);
grid.appendChild(card);
});
}
function convertUserServiceToMarketplace(service) {
return {
id: `marketplace-service-${service.id}`,
source_service_id: service.id,
name: service.name,
description: service.description,
category: service.category || 'Professional Services',
provider_name: service.provider_name || 'Service Provider',
price_per_hour: service.price_amount || service.hourly_rate || service.price_per_hour || 0,
pricing_type: service.pricing_type || 'hourly',
experience_level: service.experience_level || 'intermediate',
response_time: service.response_time || 24,
skills: service.skills || [],
rating: service.rating || 0,
status: service.status || 'Active',
availability: service.status === 'Active' ? 'Available' : 'Unavailable',
created_at: service.created_at || new Date().toISOString(),
featured: service.featured || false,
metadata: service.metadata || {
tags: service.skills || [],
location: 'Remote',
rating: service.rating || 0,
review_count: 0
},
attributes: service.attributes || {
duration_hours: { value: service.available_hours || 0 },
expertise_level: { value: service.experience_level || 'intermediate' },
response_time_hours: { value: service.response_time || 24 },
support_type: { value: service.delivery_method || 'remote' }
}
};
}
function convertProductToMarketplace(product) {
return {
id: product.id,
source_product_id: product.id,
name: product.name,
description: product.description,
category: product.category_id || 'Application',
provider_name: product.provider_name || 'Service Provider',
price_per_hour: product.base_price || 0,
pricing_type: 'fixed',
experience_level: product.attributes?.experience_level?.value || 'intermediate',
response_time: product.attributes?.response_time?.value || 24,
skills: product.metadata?.tags || [],
rating: product.metadata?.rating || 0,
status: product.availability === 'Available' ? 'Active' : 'Inactive',
availability: product.availability || 'Available',
created_at: product.created_at || new Date().toISOString(),
featured: product.metadata?.featured || false,
metadata: {
tags: product.metadata?.tags || [],
location: product.metadata?.location || 'Remote',
rating: product.metadata?.rating || 0,
review_count: product.metadata?.review_count || 0
},
attributes: product.attributes || {
delivery_method: { value: 'remote' },
pricing_type: { value: 'fixed' },
experience_level: { value: 'intermediate' },
response_time_hours: { value: 24 }
}
};
}
function contactServiceProvider(serviceId, serviceName) {
showNotification(`Contacting service provider for ${serviceName}...`, 'info');
}
function viewServiceDetails(serviceId) {
showNotification('Loading service details...', 'info');
}
async function loadSessionStorageServices() {
// NOTE: For marketplace services page, services are already rendered server-side
// We just need to bind to existing buttons and optionally load additional user services
// The backend marketplace controller already aggregates all users' public services
try {
// Only attempt to load additional session storage services as fallback
const marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
const userServices = JSON.parse(sessionStorage.getItem('userServices') || '[]');
const allSessionServices = [...marketplaceServices];
userServices.forEach(userService => {
const existsInMarketplace = marketplaceServices.some(ms => ms.source_service_id === userService.id);
if (!existsInMarketplace && userService.status === 'Active') {
allSessionServices.push(convertUserServiceToMarketplace(userService));
}
});
if (allSessionServices.length > 0) {
displaySessionServices(allSessionServices);
}
} catch (error) {
console.log('Could not load services from session storage:', error);
}
}
function bindExistingSSRButtons() {
qsa('.add-to-cart-btn').forEach(bindAddToCart);
}
function listenForServiceCreated() {
window.addEventListener('serviceCreated', function (event) {
console.log('New service created, refreshing marketplace:', event.detail);
setTimeout(() => { loadSessionStorageServices(); }, 500);
});
}
function init() {
parseHydrationData();
bindExistingSSRButtons();
listenForServiceCreated();
loadSessionStorageServices();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,38 @@
/**
* Slice rental form functionality
* Handles form submission with apiJson
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('sliceRentalForm');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const formData = new FormData(this);
try {
window.setButtonLoading(submitBtn, 'Processing...');
// Submit form using apiJson
const response = await window.apiJson('/marketplace/slice/rent', {
method: 'POST',
body: formData
});
window.setButtonSuccess(submitBtn, 'Success!', 2000);
window.showSuccessToast('Slice rental request submitted successfully');
// Reset form after successful submission
setTimeout(() => {
this.reset();
}, 1000);
} catch (error) {
console.error('Error submitting slice rental form:', error);
window.handleApiError(error, 'submitting slice rental request', submitBtn);
}
});
});

View File

@@ -0,0 +1,94 @@
/**
* Slice rental form functionality
* Migrated from inline scripts to use apiJson and shared error handlers
*/
document.addEventListener('DOMContentLoaded', function() {
// Get slice data from JSON hydration
const sliceDataElement = document.getElementById('slice-data');
let sliceData = {};
if (sliceDataElement) {
try {
sliceData = JSON.parse(sliceDataElement.textContent);
} catch (error) {
console.error('Error parsing slice data:', error);
}
}
const vmRadio = document.getElementById('vm_deployment');
const k8sRadio = document.getElementById('k8s_deployment');
const vmOptions = document.getElementById('vm_options');
const k8sOptions = document.getElementById('k8s_options');
const totalPriceElement = document.getElementById('total_price');
const quantityInput = document.getElementById('quantity');
const durationSelect = document.getElementById('duration');
// Show/hide deployment options based on selection
function toggleDeploymentOptions() {
if (vmRadio && vmRadio.checked) {
vmOptions?.classList.remove('d-none');
k8sOptions?.classList.add('d-none');
} else if (k8sRadio && k8sRadio.checked) {
vmOptions?.classList.add('d-none');
k8sOptions?.classList.remove('d-none');
}
updateTotalPrice();
}
// Update total price calculation
function updateTotalPrice() {
const basePrice = sliceData.basePrice || 0;
const quantity = parseInt(quantityInput?.value) || 1;
const duration = parseInt(durationSelect?.value) || 1;
const totalPrice = basePrice * quantity * duration;
if (totalPriceElement) {
totalPriceElement.textContent = `$${totalPrice.toFixed(2)}`;
}
}
// Event listeners
if (vmRadio) vmRadio.addEventListener('change', toggleDeploymentOptions);
if (k8sRadio) k8sRadio.addEventListener('change', toggleDeploymentOptions);
if (quantityInput) quantityInput.addEventListener('input', updateTotalPrice);
if (durationSelect) durationSelect.addEventListener('change', updateTotalPrice);
// Initialize
toggleDeploymentOptions();
updateTotalPrice();
// Form submission
const rentalForm = document.getElementById('slice-rental-form');
if (rentalForm) {
rentalForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
if (submitBtn) {
setButtonLoading(submitBtn, 'Processing...');
}
try {
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
await window.apiJson('/api/slice-rental', {
method: 'POST',
body: JSON.stringify(data)
});
showSuccessToast('Slice rental request submitted successfully');
// Redirect to dashboard or confirmation page
setTimeout(() => {
window.location.href = '/dashboard/user';
}, 1500);
} catch (error) {
handleApiError(error, 'submitting rental request', submitBtn);
}
});
}
});

370
src/static/js/statistics.js Normal file
View File

@@ -0,0 +1,370 @@
(function () {
function getHydrationData(id) {
try {
const el = document.getElementById(id);
if (!el) return {};
const text = el.textContent || el.innerText || '{}';
return JSON.parse(text || '{}');
} catch (e) {
console.warn('Failed to parse hydration data for', id, e);
return {};
}
}
function getCtx(id) {
const el = document.getElementById(id);
if (!el) return null;
const ctx = el.getContext ? el.getContext('2d') : null;
return ctx || null;
}
function makeChart(ctx, cfg) {
if (!ctx || typeof Chart === 'undefined') return null;
return new Chart(ctx, cfg);
}
document.addEventListener('DOMContentLoaded', function () {
// Global defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.responsive = true;
Chart.defaults.maintainAspectRatio = false;
}
const data = getHydrationData('statistics-data') || {};
// Helpers to pull arrays with defaults
const pick = (obj, path, fallback) => {
try {
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj) ?? fallback;
} catch (_) {
return fallback;
}
};
// Resource Distribution (Doughnut)
makeChart(getCtx('resourceDistributionChart'), {
type: 'doughnut',
data: {
labels: pick(data, 'resourceDistribution.labels', ['Compute', 'Storage', 'Network', 'Specialized']),
datasets: [{
data: pick(data, 'resourceDistribution.values', [45, 25, 20, 10]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
borderWidth: 1,
}],
},
options: {
plugins: {
legend: { position: 'right' },
title: { display: true, text: 'Resource Type Distribution' },
},
},
});
// Monthly Growth (Line)
makeChart(getCtx('monthlyGrowthChart'), {
type: 'line',
data: {
labels: pick(data, 'monthlyGrowth.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [
{
label: 'Compute Resources',
data: pick(data, 'monthlyGrowth.compute', [120, 150, 180, 210, 250, 280]),
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
tension: 0.3,
fill: true,
},
{
label: '3Nodes',
data: pick(data, 'monthlyGrowth.nodes', [45, 60, 75, 90, 120, 150]),
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.3,
fill: true,
},
{
label: 'Applications',
data: pick(data, 'monthlyGrowth.apps', [30, 40, 50, 60, 70, 80]),
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
tension: 0.3,
fill: true,
},
],
},
options: {
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Monthly Growth by Category' },
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Number of Resources' } },
},
},
});
// CPU Utilization (Bar)
makeChart(getCtx('cpuUtilizationChart'), {
type: 'bar',
data: {
labels: pick(data, 'cpuUtilization.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
datasets: [{
label: 'Average CPU Utilization (%)',
data: pick(data, 'cpuUtilization.values', [75, 68, 82, 60, 65, 72]),
backgroundColor: 'rgba(0, 123, 255, 0.7)',
borderWidth: 1,
}],
},
options: {
plugins: {
legend: { display: false },
title: { display: true, text: 'Average CPU Utilization by Region' },
},
scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: 'Utilization %' } } },
},
});
// Memory Allocation (Pie)
makeChart(getCtx('memoryAllocationChart'), {
type: 'pie',
data: {
labels: pick(data, 'memoryAllocation.labels', ['2GB', '4GB', '8GB', '16GB', '32GB+']),
datasets: [{
data: pick(data, 'memoryAllocation.values', [15, 25, 30, 20, 10]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545'],
borderWidth: 1,
}],
},
options: {
plugins: {
legend: { position: 'right' },
title: { display: true, text: 'Memory Size Distribution' },
},
},
});
// Storage Distribution (Pie)
makeChart(getCtx('storageDistributionChart'), {
type: 'pie',
data: {
labels: pick(data, 'storageDistribution.labels', ['SSD', 'HDD', 'Hybrid', 'Object Storage']),
datasets: [{
data: pick(data, 'storageDistribution.values', [45, 30, 15, 10]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
borderWidth: 1,
}],
},
options: {
plugins: {
legend: { position: 'right' },
title: { display: true, text: 'Storage Type Distribution' },
},
},
});
// Resource Pricing (Line)
makeChart(getCtx('resourcePricingChart'), {
type: 'line',
data: {
labels: pick(data, 'resourcePricing.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [
{ label: 'Compute (per vCPU)', data: pick(data, 'resourcePricing.compute', [50, 48, 45, 42, 40, 38]), borderColor: '#007bff', tension: 0.3 },
{ label: 'Memory (per GB)', data: pick(data, 'resourcePricing.memory', [25, 24, 22, 20, 19, 18]), borderColor: '#28a745', tension: 0.3 },
{ label: 'Storage (per 10GB)', data: pick(data, 'resourcePricing.storage', [15, 14, 13, 12, 11, 10]), borderColor: '#ffc107', tension: 0.3 },
],
},
options: {
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Resource Pricing Trends ($)' },
},
scales: { y: { beginAtZero: true, title: { display: true, text: 'Price ($)' } } },
},
});
// Node Geographic (Bar)
makeChart(getCtx('nodeGeographicChart'), {
type: 'bar',
data: {
labels: pick(data, 'nodeGeographic.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
datasets: [{
label: 'Number of 3Nodes',
data: pick(data, 'nodeGeographic.values', [45, 32, 20, 15, 8, 5]),
backgroundColor: 'rgba(40, 167, 69, 0.7)',
borderWidth: 1,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: 'Geographic Distribution of 3Nodes' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Number of Nodes' } } },
},
});
// Node Types (Doughnut)
makeChart(getCtx('nodeTypesChart'), {
type: 'doughnut',
data: {
labels: pick(data, 'nodeTypes.labels', ['Basic', 'Standard', 'Advanced', 'Enterprise']),
datasets: [{
data: pick(data, 'nodeTypes.values', [20, 40, 30, 10]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
borderWidth: 1,
}],
},
options: {
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Node Types Distribution' } },
},
});
// Node Uptime (Line)
makeChart(getCtx('nodeUptimeChart'), {
type: 'line',
data: {
labels: pick(data, 'nodeUptime.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [{
label: 'Average Uptime (%)',
data: pick(data, 'nodeUptime.values', [98.5, 99.1, 99.3, 99.5, 99.6, 99.8]),
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.3,
fill: true,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: '3Node Uptime Performance' } },
scales: { y: { min: 95, max: 100, title: { display: true, text: 'Uptime %' } } },
},
});
// Node Certification (Line)
makeChart(getCtx('nodeCertificationChart'), {
type: 'line',
data: {
labels: pick(data, 'nodeCertification.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [{
label: 'Certification Rate (%)',
data: pick(data, 'nodeCertification.values', [70, 75, 80, 85, 88, 92]),
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
tension: 0.3,
fill: true,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: '3Node Certification Rate' } },
scales: { y: { min: 50, max: 100, title: { display: true, text: 'Certification %' } } },
},
});
// Gateway Traffic (Line)
makeChart(getCtx('gatewayTrafficChart'), {
type: 'line',
data: {
labels: pick(data, 'gatewayTraffic.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [{
label: 'Traffic (TB)',
data: pick(data, 'gatewayTraffic.values', [25, 32, 40, 50, 65, 75]),
borderColor: '#17a2b8',
backgroundColor: 'rgba(23, 162, 184, 0.1)',
tension: 0.3,
fill: true,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: 'Monthly Gateway Traffic' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Traffic (TB)' } } },
},
});
// Gateway Availability (Bar)
makeChart(getCtx('gatewayAvailabilityChart'), {
type: 'bar',
data: {
labels: pick(data, 'gatewayAvailability.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
datasets: [{
label: 'Availability (%)',
data: pick(data, 'gatewayAvailability.values', [99.8, 99.7, 99.5, 99.2, 99.0, 99.6]),
backgroundColor: 'rgba(23, 162, 184, 0.7)',
borderWidth: 1,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: 'Gateway Availability by Region' } },
scales: { y: { min: 98, max: 100, title: { display: true, text: 'Availability %' } } },
},
});
// App Categories (Doughnut)
makeChart(getCtx('appCategoriesChart'), {
type: 'doughnut',
data: {
labels: pick(data, 'appCategories.labels', ['Web Applications', 'Databases', 'Developer Tools', 'Collaboration', 'Storage', 'Other']),
datasets: [{
data: pick(data, 'appCategories.values', [30, 25, 15, 12, 10, 8]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545', '#6c757d'],
borderWidth: 1,
}],
},
options: {
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Application Categories' } },
},
});
// App Deployment (Line)
makeChart(getCtx('appDeploymentChart'), {
type: 'line',
data: {
labels: pick(data, 'appDeployment.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
datasets: [{
label: 'Monthly Deployments',
data: pick(data, 'appDeployment.values', [35, 42, 50, 65, 80, 95]),
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
tension: 0.3,
fill: true,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: 'Application Deployment Trends' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Number of Deployments' } } },
},
});
// Service Categories (Doughnut)
makeChart(getCtx('serviceCategoriesChart'), {
type: 'doughnut',
data: {
labels: pick(data, 'serviceCategories.labels', ['System Administration', 'Development', 'Migration', 'Consulting', 'Training', 'Other']),
datasets: [{
data: pick(data, 'serviceCategories.values', [35, 25, 15, 10, 10, 5]),
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545', '#6c757d'],
borderWidth: 1,
}],
},
options: {
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Service Categories' } },
},
});
// Service Rates (Bar)
makeChart(getCtx('serviceRatesChart'), {
type: 'bar',
data: {
labels: pick(data, 'serviceRates.labels', ['System Admin', 'Development', 'Migration', 'Consulting', 'Training']),
datasets: [{
label: 'Average Rate ($/hour)',
data: pick(data, 'serviceRates.values', [50, 75, 65, 85, 60]),
backgroundColor: 'rgba(0, 123, 255, 0.7)',
borderColor: 'rgba(0, 123, 255, 1)',
borderWidth: 1,
}],
},
options: {
plugins: { legend: { display: false }, title: { display: true, text: 'Average Service Rates' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Rate ($/hour)' } } },
},
});
});
})();

View File

@@ -0,0 +1,160 @@
// User Database Simulation
// This file simulates a user database with realistic user profiles
class UserDatabase {
constructor() {
this.initializeUsers();
}
initializeUsers() {
// Initialize mock users if not already in session storage
if (!sessionStorage.getItem('userDatabase')) {
const mockUsers = [
{
id: 'user-001',
username: 'sara_farmer',
display_name: 'Sara Nicks',
email: 'user1@example.com',
password: 'password',
role: 'farmer',
location: 'Amsterdam, Netherlands',
joined_date: '2024-01-15',
reputation: 4.8,
verified: true,
stats: {
nodes_operated: 5,
total_uptime: 99.7,
earnings_total: 2450
}
},
{
id: 'user-002',
username: 'alex_dev',
display_name: 'Alex Thompson',
email: 'user2@example.com',
password: 'password',
role: 'app_provider',
location: 'Berlin, Germany',
joined_date: '2024-02-20',
reputation: 4.9,
verified: true,
stats: {
apps_published: 3,
total_deployments: 150,
revenue_total: 3200
}
},
{
id: 'user-003',
username: 'mike_consultant',
display_name: 'Mike Rodriguez',
email: 'user3@example.com',
password: 'password',
role: 'service_provider',
location: 'New York, USA',
joined_date: '2024-01-10',
reputation: 4.7,
verified: true,
stats: {
services_offered: 4,
clients_served: 25,
hours_completed: 340
}
},
{
id: 'user-004',
username: 'emma_security',
display_name: 'Emma Wilson',
email: 'user4@example.com',
password: 'password',
role: 'service_provider',
location: 'London, UK',
joined_date: '2024-03-05',
reputation: 4.8,
verified: true,
stats: {
services_offered: 2,
clients_served: 18,
hours_completed: 220
}
},
{
id: 'user-005',
username: 'jordan_multi',
display_name: 'Jordan Mitchell',
email: 'user5@example.com',
password: 'password',
role: 'multi', // Can be farmer, app_provider, service_provider, user
location: 'Toronto, Canada',
joined_date: new Date().toISOString().split('T')[0],
reputation: 5.0,
verified: true,
stats: {
nodes_operated: 2,
apps_published: 1,
services_offered: 1,
deployments: 5
}
}
];
sessionStorage.setItem('userDatabase', JSON.stringify(mockUsers));
}
}
getUser(userId) {
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
return users.find(user => user.id === userId);
}
getUserByUsername(username) {
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
return users.find(user => user.username === username);
}
getAllUsers() {
return JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
}
updateUserStats(userId, statUpdates) {
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
const userIndex = users.findIndex(user => user.id === userId);
if (userIndex !== -1) {
users[userIndex].stats = { ...users[userIndex].stats, ...statUpdates };
sessionStorage.setItem('userDatabase', JSON.stringify(users));
}
}
getCurrentUser() {
return this.getUser('user-005'); // Current user
}
getUsersByRole(role) {
const users = this.getAllUsers();
return users.filter(user => user.role === role || user.role === 'multi');
}
authenticateUser(email, password) {
const users = this.getAllUsers();
const user = users.find(user => user.email === email && user.password === password);
return user || null;
}
validateCredentials(email, password) {
return this.authenticateUser(email, password) !== null;
}
getUserByEmail(email) {
const users = this.getAllUsers();
return users.find(user => user.email === email);
}
}
// Initialize user database when script loads
const userDB = new UserDatabase();
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = UserDatabase;
}

Some files were not shown because too many files have changed in this diff Show More