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