diff --git a/actix_mvc_app/src/controllers/contract.rs b/actix_mvc_app/src/controllers/contract.rs index 9943aff..d2cb12c 100644 --- a/actix_mvc_app/src/controllers/contract.rs +++ b/actix_mvc_app/src/controllers/contract.rs @@ -1,15 +1,20 @@ -use actix_web::web::Query; use actix_web::{Error, HttpResponse, Result, web}; -use chrono::{Duration, Utc}; +use lazy_static::lazy_static; use serde::Deserialize; use std::collections::HashMap; +use std::sync::Mutex; use tera::{Context, Tera}; -use crate::models::contract::{ - Contract, ContractRevision, ContractSigner, ContractStatistics, ContractStatus, ContractType, - SignerStatus, TocItem, +use crate::db::contracts::{ + add_revision_to_contract, add_signer_to_contract, create_new_contract, delete_contract, + get_contract_by_id, get_contract_statistics, get_contracts, get_contracts_by_creator, + get_contracts_by_status, update_contract, update_contract_status, update_signer_status, + update_signer_status_with_signature, }; -use crate::utils::render_template; + +use crate::controllers::error::render_contract_not_found; +use crate::utils::{parse_markdown, render_template}; +use heromodels::models::legal::{Contract, ContractStatus}; #[derive(Debug, Deserialize)] #[allow(dead_code)] @@ -17,14 +22,34 @@ pub struct ContractForm { pub title: String, pub description: String, pub contract_type: String, - pub content: String, + pub content: Option, + pub effective_date: Option, + pub expiration_date: Option, } #[derive(Debug, Deserialize)] #[allow(dead_code)] -pub struct SignerForm { - pub name: String, - pub email: String, +pub struct ContractFilters { + pub status: Option, + #[serde(rename = "type")] + pub contract_type: Option, + pub search: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct SignContractForm { + pub signature_data: Option, + pub comments: Option, +} + +// Global reminder timestamp tracking (in-memory solution) +lazy_static! { + static ref REMINDER_TIMESTAMPS: Mutex> = Mutex::new(HashMap::new()); + + // Temporary in-memory storage for signature data + // TODO: Add signature_data field to heromodels ContractSigner + static ref SIGNATURE_DATA: Mutex> = Mutex::new(HashMap::new()); } pub struct ContractController; @@ -34,78 +59,924 @@ impl ContractController { pub async fn index(tmpl: web::Data) -> Result { let mut context = Context::new(); - let contracts = Self::get_mock_contracts(); - let stats = ContractStatistics::new(&contracts); + // Get contracts from database for dashboard + let contracts = match get_contracts() { + Ok(contracts) => contracts, + Err(e) => { + log::error!("Failed to get contracts from database: {}", e); + vec![] // Return empty vector if there's an error + } + }; // Add active_page for navigation highlighting context.insert("active_page", &"contracts"); - // Add stats - context.insert("stats", &serde_json::to_value(stats).unwrap()); - - // Add recent contracts - let recent_contracts: Vec> = contracts + // Convert contracts to JSON for template + let contracts_data: Vec = contracts .iter() - .take(5) - .map(|c| Self::contract_to_json(c)) + .map(|c| Self::heromodel_contract_to_json(c)) .collect(); - context.insert("recent_contracts", &recent_contracts); + // Calculate statistics + let stats = Self::calculate_contract_statistics(&contracts_data); + context.insert("stats", &stats); - // Add pending signature contracts - let pending_signature_contracts: Vec> = - contracts - .iter() - .filter(|c| c.status == ContractStatus::PendingSignatures) - .map(|c| Self::contract_to_json(c)) - .collect(); + // Filter contracts by status for dashboard sections + let pending_signature_contracts: Vec<&serde_json::Value> = contracts_data + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "PendingSignatures") + .collect(); + + let draft_contracts: Vec<&serde_json::Value> = contracts_data + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "Draft") + .collect(); + + // Get recent contract activities + let recent_activities = Self::get_recent_contract_activities(); context.insert("pending_signature_contracts", &pending_signature_contracts); - - // Add draft contracts - let draft_contracts: Vec> = contracts - .iter() - .filter(|c| c.status == ContractStatus::Draft) - .map(|c| Self::contract_to_json(c)) - .collect(); - context.insert("draft_contracts", &draft_contracts); + context.insert("contracts", &contracts_data); + context.insert("recent_activities", &recent_activities); render_template(&tmpl, "contracts/index.html", &context) } // Display the list of all contracts - pub async fn list(tmpl: web::Data) -> Result { + pub async fn list( + tmpl: web::Data, + query: web::Query, + ) -> Result { let mut context = Context::new(); - let contracts = Self::get_mock_contracts(); - let contracts_data: Vec> = contracts + // Get contracts from database using heromodels + let mut contracts = match get_contracts() { + Ok(contracts) => contracts, + Err(e) => { + log::error!("Failed to get contracts from database: {}", e); + vec![] // Return empty vector if there's an error + } + }; + + // Apply filters + if let Some(status_filter) = &query.status { + if !status_filter.is_empty() { + let filter_status = match status_filter.as_str() { + "Draft" => ContractStatus::Draft, + "PendingSignatures" => ContractStatus::PendingSignatures, + "Signed" => ContractStatus::Signed, + "Active" => ContractStatus::Active, + "Expired" => ContractStatus::Expired, + "Cancelled" => ContractStatus::Cancelled, + _ => ContractStatus::Draft, // Default fallback + }; + contracts.retain(|c| c.status == filter_status); + } + } + + if let Some(type_filter) = &query.contract_type { + if !type_filter.is_empty() { + contracts.retain(|c| c.contract_type.contains(type_filter)); + } + } + + if let Some(search_term) = &query.search { + if !search_term.is_empty() { + let search_lower = search_term.to_lowercase(); + contracts.retain(|c| { + c.title.to_lowercase().contains(&search_lower) + || c.description.to_lowercase().contains(&search_lower) + }); + } + } + + // Convert heromodels contracts to JSON for template + let contracts_data: Vec = contracts .iter() - .map(|c| Self::contract_to_json(c)) + .map(|c| Self::heromodel_contract_to_json(c)) .collect(); // Add active_page for navigation highlighting context.insert("active_page", &"contracts"); + // Add filter values back to context for form state + context.insert("current_status_filter", &query.status); + context.insert("current_type_filter", &query.contract_type); + context.insert("current_search_filter", &query.search); + context.insert("contracts", &contracts_data); context.insert("filter", &"all"); render_template(&tmpl, "contracts/contracts.html", &context) } - // Display the list of user's contracts - pub async fn my_contracts(tmpl: web::Data) -> Result { + // Display all contract activities page + pub async fn all_activities(tmpl: web::Data) -> Result { let mut context = Context::new(); - let contracts = Self::get_mock_contracts(); - let contracts_data: Vec> = contracts + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Get all contract activities + let activities = Self::get_all_contract_activities(); + + // Format activities for template (similar to governance activities) + let formatted_activities: Vec = activities + .into_iter() + .map(|activity| { + serde_json::json!({ + "icon": activity.get("icon").and_then(|v| v.as_str()).unwrap_or("bi-circle-fill"), + "user": activity.get("user").and_then(|v| v.as_str()).unwrap_or("Unknown"), + "action": activity.get("description").and_then(|v| v.as_str()).unwrap_or("performed action"), + "contract_title": activity.get("title").and_then(|v| v.as_str()).unwrap_or("Unknown Contract"), + "contract_id": 1, // We'll need to extract this from the activity data + "created_at": activity.get("timestamp").and_then(|v| v.as_str()).unwrap_or("Unknown"), + "type": activity.get("type").and_then(|v| v.as_str()).unwrap_or("unknown") + }) + }) + .collect(); + + context.insert("activities", &formatted_activities); + + render_template(&tmpl, "contracts/all_activities.html", &context) + } + + // Reject contract endpoint + pub async fn reject_contract( + path: web::Path<(String, String)>, + form: web::Json, + ) -> Result { + let (contract_id_str, signer_id_str) = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } + }; + + // Signer ID is a string, not a number + let signer_id = signer_id_str; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to retrieve contract" + }))); + } + }; + + // Check if contract is in the right status for rejection + if contract.status != heromodels::models::legal::ContractStatus::PendingSignatures { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Contract is not available for rejection" + }))); + } + + // Find the signer + let signer = contract.signers.iter().find(|s| s.id == signer_id); + if signer.is_none() { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Signer not found" + }))); + } + + let signer = signer.unwrap(); + + // Check if signer has already signed or rejected + if signer.status != heromodels::models::legal::SignerStatus::Pending { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Contract already processed by this signer" + }))); + } + + // Update signer status to rejected + match update_signer_status( + contract_id, + &signer_id, + heromodels::models::legal::SignerStatus::Rejected, + form.comments.as_deref(), + ) { + Ok(_) => { + log::info!("Signer {} rejected contract {}", signer_id, contract_id); + + // Check if all signers have responded (signed or rejected) + let updated_contract = get_contract_by_id(contract_id).unwrap().unwrap(); + let all_responded = updated_contract + .signers + .iter() + .all(|s| s.status != heromodels::models::legal::SignerStatus::Pending); + + let mut contract_status_changed = false; + + // Only auto-cancel if ALL signers have responded AND there are rejections + if all_responded { + let has_rejection = updated_contract + .signers + .iter() + .any(|s| s.status == heromodels::models::legal::SignerStatus::Rejected); + + let all_signed = updated_contract + .signers + .iter() + .all(|s| s.status == heromodels::models::legal::SignerStatus::Signed); + + if all_signed { + // All signers signed - mark as Signed + let _ = update_contract_status( + contract_id, + heromodels::models::legal::ContractStatus::Signed, + ); + log::info!( + "All signers have signed contract {}, status updated to Signed", + contract_id + ); + contract_status_changed = true; + } else if has_rejection { + // All responded but some rejected - mark as Cancelled + let _ = update_contract_status( + contract_id, + heromodels::models::legal::ContractStatus::Cancelled, + ); + log::info!( + "Contract {} has rejections after all signers responded, status updated to Cancelled", + contract_id + ); + contract_status_changed = true; + } + } else { + // Not all signers have responded yet - keep as PendingSignatures + log::info!( + "Contract {} has rejection from signer {}, but other signers can still sign", + contract_id, + signer_id + ); + } + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Contract rejected successfully", + "all_responded": all_responded, + "contract_status_changed": contract_status_changed + }))) + } + Err(e) => { + log::error!("Failed to update signer status: {}", e); + Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to reject contract" + }))) + } + } + } + + // Share contract endpoint + pub async fn share_contract( + path: web::Path, + form: web::Json, + ) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } + }; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to retrieve contract" + }))); + } + }; + + let email = form.get("email").and_then(|e| e.as_str()).unwrap_or(""); + let message = form.get("message").and_then(|m| m.as_str()).unwrap_or(""); + + if email.is_empty() { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Email address is required" + }))); + } + + // In a real implementation, this would send an email + log::info!( + "Sharing contract '{}' (ID: {}) with {} - Message: {}", + contract.title, + contract_id, + email, + message + ); + + // Simulate email sending + println!( + "šŸ“§ Sharing contract '{}' with {}\nšŸ“ Message: {}\nšŸ”— Link: https://yourapp.com/contracts/{}", + contract.title, + email, + if message.is_empty() { + "No message provided" + } else { + message + }, + contract_id + ); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": format!("Contract shared successfully with {}", email) + }))) + } + + // View signed document endpoint + pub async fn view_signed_document( + tmpl: web::Data, + path: web::Path<(String, String)>, + ) -> Result { + let (contract_id_str, signer_id) = path.into_inner(); + let mut context = Context::new(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().body("Contract not found")); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().body("Failed to retrieve contract")); + } + }; + + // Find the specific signer + let signer = contract.signers.iter().find(|s| s.id == signer_id); + if signer.is_none() { + return Ok(HttpResponse::NotFound().body("Signer not found")); + } + + let signer = signer.unwrap(); + + // Check if signer has actually signed + if signer.status != heromodels::models::legal::SignerStatus::Signed { + return Ok( + HttpResponse::BadRequest().body("This signer has not signed the contract yet") + ); + } + + // Convert contract to JSON + let contract_json = Self::heromodel_contract_to_json(&contract); + + // Parse markdown content to HTML + let contract_content_html = parse_markdown(&contract.terms_and_conditions); + + // Add contract and signer info to context + context.insert("contract", &contract_json); + context.insert("contract_content_html", &contract_content_html); + context.insert( + "signer", + &serde_json::json!({ + "id": signer.id, + "name": signer.name, + "email": signer.email, + "status": format!("{:?}", signer.status), + "signed_at": signer.signed_at.map(|ts| { + chrono::DateTime::from_timestamp(ts as i64, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + }), + "comments": signer.comments, + // Load actual signature data from database + "signature_data": if signer.status == heromodels::models::legal::SignerStatus::Signed { + signer.signature_data.clone() + .or_else(|| { + // Fallback to placeholder if no signature data found + Some(format!("data:image/svg+xml;charset=utf-8,{}", signer.name)) + }) + } else { + None + } + }), + ); + + render_template(&tmpl, "contracts/signed_document.html", &context) + } + + // Clone contract endpoint + pub async fn clone_contract(path: web::Path) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } + }; + + // Get original contract from database + let original_contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to retrieve contract" + }))); + } + }; + + // Generate a unique contract ID for the clone + let new_contract_id = format!("CONTRACT-{}", chrono::Utc::now().timestamp()); + + // Create cloned contract title + let cloned_title = format!("Copy of {}", original_contract.title); + + // Create the cloned contract in the database + // Use dummy user like other models until auth is fully integrated + let creator_name = "John Doe"; // Dummy user - same as governance and calendar + + match create_new_contract( + 1, // base_id - in a real app, this would be generated properly + &new_contract_id, + &cloned_title, + &original_contract.description, + &original_contract.contract_type, + heromodels::models::legal::ContractStatus::Draft, // Always start as draft + creator_name, // created_by - using dummy user like other models + Some(&original_contract.terms_and_conditions), // Copy terms and conditions + None, // start_date - clear dates for new contract + None, // end_date - clear dates for new contract + None, // renewal_period_days + ) { + Ok((new_contract_db_id, cloned_contract)) => { + log::info!( + "Successfully cloned contract {} to new contract with ID: {}", + contract_id, + new_contract_db_id + ); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Contract cloned successfully", + "new_contract_id": new_contract_db_id, + "new_contract_contract_id": cloned_contract.contract_id, + "redirect_url": format!("/contracts/{}/edit", new_contract_db_id) + }))) + } + Err(e) => { + log::error!("Failed to clone contract: {}", e); + Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to clone contract" + }))) + } + } + } + + // Cancel contract endpoint (manual admin action) + pub async fn cancel_contract( + path: web::Path, + form: web::Json, + ) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } + }; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to retrieve contract" + }))); + } + }; + + // Check if contract can be cancelled + if contract.status == heromodels::models::legal::ContractStatus::Cancelled { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Contract is already cancelled" + }))); + } + + if contract.status == heromodels::models::legal::ContractStatus::Signed { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Cannot cancel a signed contract" + }))); + } + + // Update contract status to cancelled + match update_contract_status( + contract_id, + heromodels::models::legal::ContractStatus::Cancelled, + ) { + Ok(_) => { + let reason = form + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("Manually cancelled by admin"); + + log::info!( + "Contract {} was manually cancelled. Reason: {}", + contract_id, + reason + ); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Contract cancelled successfully" + }))) + } + Err(e) => { + log::error!("Failed to cancel contract: {}", e); + Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to cancel contract" + }))) + } + } + } + + // Sign contract endpoint + pub async fn sign_contract( + path: web::Path<(String, String)>, + form: web::Json, + ) -> Result { + let (contract_id_str, signer_id_str) = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } + }; + + // Signer ID is a string, not a number + let signer_id = signer_id_str; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to get contract" + }))); + } + }; + + // Check if contract is in the right status for signing + if contract.status != heromodels::models::legal::ContractStatus::PendingSignatures { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Contract is not available for signing" + }))); + } + + // Find the signer + let signer = contract.signers.iter().find(|s| s.id == signer_id); + if signer.is_none() { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Signer not found" + }))); + } + + let signer = signer.unwrap(); + + // Check if signer has already signed + if signer.status == heromodels::models::legal::SignerStatus::Signed { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Contract already signed by this signer" + }))); + } + + // Store signature data if provided + if let Some(signature_data) = &form.signature_data { + let mut signatures = SIGNATURE_DATA.lock().unwrap(); + let signature_key = format!("{}_{}", contract_id, signer_id); + signatures.insert(signature_key, signature_data.clone()); + log::info!( + "Stored signature data for signer {} in contract {}", + signer_id, + contract_id + ); + } + + // Update signer status to signed with signature data + match update_signer_status_with_signature( + contract_id, + &signer_id, + heromodels::models::legal::SignerStatus::Signed, + form.comments.as_deref(), + form.signature_data.as_deref(), + ) { + Ok(_) => { + log::info!("Signer {} signed contract {}", signer_id, contract_id); + + // Check if all signers have signed + let updated_contract = get_contract_by_id(contract_id).unwrap().unwrap(); + let all_signed = updated_contract + .signers + .iter() + .all(|s| s.status == heromodels::models::legal::SignerStatus::Signed); + + // If all signers have signed, update contract status to Signed + if all_signed { + match update_contract_status( + contract_id, + heromodels::models::legal::ContractStatus::Signed, + ) { + Ok(_) => { + log::info!( + "All signers have signed contract {}, status updated to Signed", + contract_id + ); + } + Err(e) => { + log::error!( + "Failed to update contract {} status to Signed: {}", + contract_id, + e + ); + } + } + } + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Contract signed successfully", + "all_signed": all_signed + }))) + } + Err(e) => { + log::error!("Failed to update signer status: {}", e); + Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to sign contract" + }))) + } + } + } + + // Helper method to convert heromodels Contract to JSON for templates + fn heromodel_contract_to_json(contract: &Contract) -> serde_json::Value { + // Calculate signer statistics + let signed_signers = contract + .signers .iter() - .map(|c| Self::contract_to_json(c)) + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Signed) + .count(); + + let pending_signers = contract + .signers + .iter() + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Pending) + .count(); + + let rejected_signers = contract + .signers + .iter() + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Rejected) + .count(); + + // Calculate signature progress percentage + let signature_progress_percent = if contract.signers.is_empty() { + 0 + } else { + (signed_signers * 100) / contract.signers.len() + }; + + // Convert timestamps to DateTime strings for template date filter (following governance/calendar pattern) + let created_at_datetime = + chrono::DateTime::from_timestamp(contract.base_data.created_at, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "1970-01-01 00:00:00 UTC".to_string()); + + let updated_at_datetime = + chrono::DateTime::from_timestamp(contract.base_data.modified_at, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "1970-01-01 00:00:00 UTC".to_string()); + + serde_json::json!({ + "id": contract.base_data.id, + "contract_id": contract.contract_id, + "title": contract.title, + "description": contract.description, + "status": format!("{:?}", contract.status), + "contract_type": contract.contract_type, + "created_by": contract.created_by, + "created_at": created_at_datetime, + "updated_at": updated_at_datetime, + "created_at_timestamp": contract.base_data.created_at, + "updated_at_timestamp": contract.base_data.modified_at, + "start_date": contract.start_date, + "end_date": contract.end_date, + "current_version": contract.current_version, + "signers": contract.signers, + "revisions": contract.revisions, + "terms_and_conditions": contract.terms_and_conditions, + "signed_signers": signed_signers, + "pending_signers": pending_signers, + "rejected_signers": rejected_signers, + "total_signers": contract.signers.len(), + "signature_progress_percent": signature_progress_percent + }) + } + + // Helper method to calculate contract statistics + fn calculate_contract_statistics(contracts: &[serde_json::Value]) -> serde_json::Value { + let total_contracts = contracts.len(); + let draft_contracts = contracts + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "Draft") + .count(); + let pending_signature_contracts = contracts + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "PendingSignatures") + .count(); + let signed_contracts = contracts + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "Signed") + .count(); + let expired_contracts = contracts + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "Expired") + .count(); + let cancelled_contracts = contracts + .iter() + .filter(|c| c["status"].as_str().unwrap_or("") == "Cancelled") + .count(); + + serde_json::json!({ + "total_contracts": total_contracts, + "draft_contracts": draft_contracts, + "pending_signature_contracts": pending_signature_contracts, + "signed_contracts": signed_contracts, + "expired_contracts": expired_contracts, + "cancelled_contracts": cancelled_contracts + }) + } + + // Display the list of user's contracts + pub async fn my_contracts( + tmpl: web::Data, + query: web::Query, + ) -> Result { + let mut context = Context::new(); + + // Get contracts from database using heromodels + // In a real app, this would filter by the current user + // Use dummy user like other models until auth is fully integrated + let mut contracts = match get_contracts_by_creator("John Doe") { + Ok(contracts) => contracts, + Err(e) => { + log::error!("Failed to get user contracts from database: {}", e); + vec![] // Return empty vector if there's an error + } + }; + + // Apply filters + if let Some(ref status_filter) = query.status { + if !status_filter.is_empty() { + contracts.retain(|contract| { + let status_str = match contract.status { + ContractStatus::Draft => "Draft", + ContractStatus::PendingSignatures => "PendingSignatures", + ContractStatus::Signed => "Signed", + ContractStatus::Active => "Active", + ContractStatus::Expired => "Expired", + ContractStatus::Cancelled => "Cancelled", + }; + status_str == status_filter + }); + } + } + + if let Some(ref type_filter) = query.contract_type { + if !type_filter.is_empty() { + contracts.retain(|contract| contract.contract_type == *type_filter); + } + } + + if let Some(ref search_filter) = query.search { + if !search_filter.is_empty() { + let search_lower = search_filter.to_lowercase(); + contracts.retain(|contract| { + contract.title.to_lowercase().contains(&search_lower) + || contract.description.to_lowercase().contains(&search_lower) + }); + } + } + + // Convert heromodels contracts to JSON for template + let contracts_data: Vec = contracts + .iter() + .map(|c| Self::heromodel_contract_to_json(c)) .collect(); // Add active_page for navigation highlighting context.insert("active_page", &"contracts"); + // Add filter values to context for preserving form state + context.insert( + "current_status_filter", + &query.status.as_deref().unwrap_or(""), + ); + context.insert( + "current_type_filter", + &query.contract_type.as_deref().unwrap_or(""), + ); + context.insert( + "current_search_filter", + &query.search.as_deref().unwrap_or(""), + ); + context.insert("contracts", &contracts_data); render_template(&tmpl, "contracts/my_contracts.html", &context) @@ -115,116 +986,86 @@ impl ContractController { pub async fn detail( tmpl: web::Data, path: web::Path, - query: Query>, ) -> Result { - let contract_id = path.into_inner(); + let contract_id_str = path.into_inner(); let mut context = Context::new(); // Add active_page for navigation highlighting context.insert("active_page", &"contracts"); - // Find the contract by ID - let contracts = Self::get_mock_contracts(); - - // For demo purposes, if the ID doesn't match exactly, just show the first contract - // In a real app, we would return a 404 if the contract is not found - let contract = if let Some(found) = contracts.iter().find(|c| c.id == contract_id) { - found + // Try to parse contract_id as u32 first, then search by contract_id string + let contract = if let Ok(contract_id) = contract_id_str.parse::() { + match get_contract_by_id(contract_id) { + Ok(Some(contract)) => Some(contract), + Ok(None) => None, + Err(e) => { + log::error!("Failed to get contract by ID {}: {}", contract_id, e); + None + } + } } else { - // For demo, just use the first contract - contracts.first().unwrap() + // Search by contract_id string + match get_contracts() { + Ok(contracts) => contracts + .into_iter() + .find(|c| c.contract_id == contract_id_str), + Err(e) => { + log::error!("Failed to get contracts from database: {}", e); + None + } + } }; - // Convert contract to JSON - let contract_json = Self::contract_to_json(contract); + if let Some(contract) = contract { + // Convert contract to JSON + let contract_json = Self::heromodel_contract_to_json(&contract); - // Add contract to context - context.insert("contract", &contract_json); + // Calculate signer statistics + let signed_signers = contract + .signers + .iter() + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Signed) + .count(); - // If this contract uses multi-page markdown, load the selected section - println!( - "DEBUG: content_dir = {:?}, toc = {:?}", - contract.content_dir, contract.toc - ); - if let (Some(content_dir), Some(toc)) = (&contract.content_dir, &contract.toc) { - use pulldown_cmark::{Options, Parser, html}; - use std::fs; - // Helper to flatten toc recursively - fn flatten_toc<'a>(items: &'a Vec, out: &mut Vec<&'a TocItem>) { - for item in items { - out.push(item); - if !item.children.is_empty() { - flatten_toc(&item.children, out); - } - } + let pending_signers = contract + .signers + .iter() + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Pending) + .count(); + + // Contract content is now handled by markdown parsing above + // Keep this section for backward compatibility if needed + + // Create contract activities for display + let activities = Self::create_contract_activities(&contract); + + // Parse markdown content to HTML + let contract_content_html = parse_markdown(&contract.terms_and_conditions); + + // Add contract and signer stats to context + context.insert("contract", &contract_json); + context.insert("contract_content_html", &contract_content_html); + context.insert("signed_signers", &signed_signers); + context.insert("pending_signers", &pending_signers); + context.insert("activities", &activities); + + // Add content for display + if !contract.terms_and_conditions.trim().is_empty() { + context.insert("contract_section_content", &contract_content_html); + + // Provide a basic TOC structure for the template + let toc = vec![serde_json::json!({ + "file": "main", + "title": "Contract Content", + "section": "main" + })]; + context.insert("toc", &toc); } - let mut flat_toc = Vec::new(); - flatten_toc(&toc, &mut flat_toc); - let section_param = query.get("section"); - let selected_file = section_param - .and_then(|f| { - flat_toc - .iter() - .find(|item| item.file == *f) - .map(|item| item.file.clone()) - }) - .unwrap_or_else(|| { - flat_toc - .get(0) - .map(|item| item.file.clone()) - .unwrap_or_default() - }); - context.insert("section", &selected_file); - let rel_path = format!("{}/{}", content_dir, selected_file); - let abs_path = match std::env::current_dir() { - Ok(dir) => dir.join(&rel_path), - Err(_) => std::path::PathBuf::from(&rel_path), - }; - println!( - "DEBUG: Attempting to read markdown file at absolute path: {:?}", - abs_path - ); - match fs::read_to_string(&abs_path) { - Ok(md) => { - println!("DEBUG: Successfully read markdown file"); - let parser = Parser::new_ext(&md, Options::all()); - let mut html_output = String::new(); - html::push_html(&mut html_output, parser); - context.insert("contract_section_content", &html_output); - } - Err(e) => { - let error_msg = format!( - "Error: Could not read contract section markdown at '{:?}': {}", - abs_path, e - ); - println!("{}", error_msg); - context.insert("contract_section_content_error", &error_msg); - } - } - context.insert("toc", &toc); + + render_template(&tmpl, "contracts/contract_detail.html", &context) + } else { + render_contract_not_found(&tmpl, Some(&contract_id_str)).await } - - // Count signed signers for the template - let signed_signers = contract - .signers - .iter() - .filter(|s| s.status == SignerStatus::Signed) - .count(); - context.insert("signed_signers", &signed_signers); - - // Count pending signers for the template - let pending_signers = contract - .signers - .iter() - .filter(|s| s.status == SignerStatus::Pending) - .count(); - context.insert("pending_signers", &pending_signers); - - // For demo purposes, set user_has_signed to false - // In a real app, we would check if the current user has already signed - context.insert("user_has_signed", &false); - - render_template(&tmpl, "contracts/contract_detail.html", &context) } // Display the create contract form @@ -236,11 +1077,11 @@ impl ContractController { // Add contract types for dropdown let contract_types = vec![ - ("Service", "Service Agreement"), - ("Employment", "Employment Contract"), - ("NDA", "Non-Disclosure Agreement"), - ("SLA", "Service Level Agreement"), - ("Other", "Other"), + "Service Agreement", + "Employment Contract", + "Non-Disclosure Agreement", + "Service Level Agreement", + "Other", ]; context.insert("contract_types", &contract_types); @@ -248,700 +1089,1114 @@ impl ContractController { render_template(&tmpl, "contracts/create_contract.html", &context) } + // Display the edit contract form + pub async fn edit_form( + tmpl: web::Data, + path: web::Path, + ) -> Result { + let contract_id_str = path.into_inner(); + let mut context = Context::new(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Try to parse contract_id as u32 first, then search by contract_id string + let contract = if let Ok(contract_id) = contract_id_str.parse::() { + match get_contract_by_id(contract_id) { + Ok(Some(contract)) => Some(contract), + Ok(None) => None, + Err(e) => { + log::error!("Failed to get contract by ID {}: {}", contract_id, e); + None + } + } + } else { + // Search by contract_id string + match get_contracts() { + Ok(contracts) => contracts + .into_iter() + .find(|c| c.contract_id == contract_id_str), + Err(e) => { + log::error!("Failed to get contracts from database: {}", e); + None + } + } + }; + + if let Some(contract) = contract { + // Only allow editing if contract is in Draft status + if contract.status != ContractStatus::Draft { + return Ok(HttpResponse::BadRequest().body("Only draft contracts can be edited")); + } + + // Convert contract to JSON + let contract_json = Self::heromodel_contract_to_json(&contract); + + // Add contract types for dropdown + let contract_types = vec![ + "Service Agreement", + "Employment Contract", + "Non-Disclosure Agreement", + "Service Level Agreement", + "Other", + ]; + + context.insert("contract", &contract_json); + context.insert("contract_types", &contract_types); + + render_template(&tmpl, "contracts/edit_contract.html", &context) + } else { + render_contract_not_found(&tmpl, Some(&contract_id_str)).await + } + } + // Process the create contract form pub async fn create( _tmpl: web::Data, - _form: web::Form, + form: web::Form, ) -> Result { - // In a real application, we would save the contract to the database - // For now, we'll just redirect to the contracts list + // Generate a unique contract ID + let contract_id = format!("CONTRACT-{}", chrono::Utc::now().timestamp()); - Ok(HttpResponse::Found() - .append_header(("Location", "/contracts")) - .finish()) + // Parse dates if provided + let start_date = form.effective_date.as_ref().and_then(|date_str| { + chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .ok() + .map(|date| date.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as u64) + }); + + let end_date = form.expiration_date.as_ref().and_then(|date_str| { + chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .ok() + .map(|date| date.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as u64) + }); + + // Create the contract in the database + // Use dummy user like other models (calendar, governance) until auth is fully integrated + let creator_name = "John Doe"; // Dummy user - same as governance and calendar + + match create_new_contract( + 1, // base_id - in a real app, this would be generated properly + &contract_id, + &form.title, + &form.description, + &form.contract_type, + ContractStatus::Draft, // status - new contracts start as draft + creator_name, // created_by - using dummy user like other models + form.content.as_deref(), // terms_and_conditions + start_date, // start_date + end_date, // end_date + None, // renewal_period_days + ) { + Ok((contract_db_id, contract)) => { + log::info!("Successfully created contract with ID: {}", contract_db_id); + log::info!("Contract Activity: Created contract '{}'", contract.title); + + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_db_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to create contract: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to create contract")) + } + } } - // Helper method to convert Contract to a JSON object for templates - fn contract_to_json(contract: &Contract) -> serde_json::Map { - let mut map = serde_json::Map::new(); + // Process the edit contract form + pub async fn update( + _tmpl: web::Data, + path: web::Path, + form: web::Form, + ) -> Result { + let contract_id_str = path.into_inner(); - // Basic contract info - map.insert( - "id".to_string(), - serde_json::Value::String(contract.id.clone()), - ); - map.insert( - "title".to_string(), - serde_json::Value::String(contract.title.clone()), - ); - map.insert( - "description".to_string(), - serde_json::Value::String(contract.description.clone()), - ); - map.insert( - "status".to_string(), - serde_json::Value::String(contract.status.as_str().to_string()), - ); - map.insert( - "contract_type".to_string(), - serde_json::Value::String(contract.contract_type.as_str().to_string()), - ); - map.insert( - "created_by".to_string(), - serde_json::Value::String(contract.created_by.clone()), - ); - map.insert( - "created_at".to_string(), - serde_json::Value::String(contract.created_at.format("%Y-%m-%d").to_string()), - ); - map.insert( - "updated_at".to_string(), - serde_json::Value::String(contract.updated_at.format("%Y-%m-%d").to_string()), - ); + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; - // Organization info - if let Some(org) = &contract.organization_id { - map.insert( - "organization".to_string(), - serde_json::Value::String(org.clone()), - ); - } else { - map.insert("organization".to_string(), serde_json::Value::Null); + // Parse dates if provided + let start_date = form.effective_date.as_ref().and_then(|date_str| { + chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .ok() + .map(|date| date.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as u64) + }); + + let end_date = form.expiration_date.as_ref().and_then(|date_str| { + chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .ok() + .map(|date| date.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as u64) + }); + + // Update the contract in the database + match update_contract( + contract_id, + &form.title, + &form.description, + &form.contract_type, + form.content.as_deref(), + start_date, + end_date, + ) { + Ok(_contract) => { + log::info!("Successfully updated contract with ID: {}", contract_id); + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to update contract: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to update contract")) + } } + } - // Add signers - let signers: Vec = contract - .signers - .iter() - .map(|s| { - let mut signer_map = serde_json::Map::new(); - signer_map.insert("id".to_string(), serde_json::Value::String(s.id.clone())); - signer_map.insert( - "name".to_string(), - serde_json::Value::String(s.name.clone()), - ); - signer_map.insert( - "email".to_string(), - serde_json::Value::String(s.email.clone()), - ); - signer_map.insert( - "status".to_string(), - serde_json::Value::String(s.status.as_str().to_string()), - ); + // Update contract status + pub async fn update_status(path: web::Path<(String, String)>) -> Result { + let (contract_id_str, status_str) = path.into_inner(); - if let Some(signed_at) = s.signed_at { - signer_map.insert( - "signed_at".to_string(), - serde_json::Value::String(signed_at.format("%Y-%m-%d").to_string()), - ); - } else { - // For display purposes, add a placeholder date for pending signers - if s.status == SignerStatus::Pending { - signer_map.insert( - "signed_at".to_string(), - serde_json::Value::String("Pending".to_string()), - ); - } else if s.status == SignerStatus::Rejected { - signer_map.insert( - "signed_at".to_string(), - serde_json::Value::String("Rejected".to_string()), - ); + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // Parse status + let status = match status_str.as_str() { + "draft" => ContractStatus::Draft, + "pending" => ContractStatus::PendingSignatures, + "signed" => ContractStatus::Signed, + "active" => ContractStatus::Active, + "expired" => ContractStatus::Expired, + "cancelled" => ContractStatus::Cancelled, + _ => { + return Ok(HttpResponse::BadRequest().body("Invalid status")); + } + }; + + // Update the contract status in the database + match update_contract_status(contract_id, status.clone()) { + Ok(_) => { + log::info!( + "Successfully updated contract {} status to {:?}", + contract_id, + status + ); + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to update contract status: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to update contract status")) + } + } + } + + // Delete a contract + pub async fn delete(path: web::Path) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // First, check if the contract exists and its status + match get_contract_by_id(contract_id) { + Ok(Some(contract)) => { + // Only allow deleting draft contracts + if contract.status != ContractStatus::Draft { + return Ok(HttpResponse::BadRequest().body( + "Only draft contracts can be deleted. Signed or active contracts cannot be deleted for legal compliance." + )); + } + + // Delete the contract from the database + match delete_contract(contract_id) { + Ok(_) => { + log::info!("Successfully deleted contract {}", contract_id); + Ok(HttpResponse::Found() + .append_header(("Location", "/contracts/my-contracts")) + .finish()) + } + Err(e) => { + log::error!("Failed to delete contract: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to delete contract")) } } + } + Ok(None) => Ok(HttpResponse::NotFound().body("Contract not found")), + Err(e) => { + log::error!("Failed to fetch contract for deletion: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to fetch contract")) + } + } + } - if let Some(comments) = &s.comments { - signer_map.insert( - "comments".to_string(), - serde_json::Value::String(comments.clone()), - ); - } else { - signer_map.insert( - "comments".to_string(), - serde_json::Value::String("".to_string()), - ); + // Display the add signer form + pub async fn add_signer_form( + tmpl: web::Data, + path: web::Path, + ) -> Result { + let contract_id_str = path.into_inner(); + let mut context = Context::new(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Try to parse contract_id as u32 first, then search by contract_id string + let contract = if let Ok(contract_id) = contract_id_str.parse::() { + match get_contract_by_id(contract_id) { + Ok(Some(contract)) => Some(contract), + Ok(None) => None, + Err(e) => { + log::error!("Failed to get contract by ID {}: {}", contract_id, e); + None } - - serde_json::Value::Object(signer_map) - }) - .collect(); - - map.insert("signers".to_string(), serde_json::Value::Array(signers)); - - // Add pending_signers count for templates - let pending_signers = contract - .signers - .iter() - .filter(|s| s.status == SignerStatus::Pending) - .count(); - map.insert( - "pending_signers".to_string(), - serde_json::Value::Number(serde_json::Number::from(pending_signers)), - ); - - // Add signed_signers count for templates - let signed_signers = contract - .signers - .iter() - .filter(|s| s.status == SignerStatus::Signed) - .count(); - map.insert( - "signed_signers".to_string(), - serde_json::Value::Number(serde_json::Number::from(signed_signers)), - ); - - // Add revisions - let revisions: Vec = contract - .revisions - .iter() - .map(|r| { - let mut revision_map = serde_json::Map::new(); - revision_map.insert( - "version".to_string(), - serde_json::Value::Number(serde_json::Number::from(r.version)), - ); - revision_map.insert( - "content".to_string(), - serde_json::Value::String(r.content.clone()), - ); - revision_map.insert( - "created_at".to_string(), - serde_json::Value::String(r.created_at.format("%Y-%m-%d").to_string()), - ); - revision_map.insert( - "created_by".to_string(), - serde_json::Value::String(r.created_by.clone()), - ); - - if let Some(comments) = &r.comments { - revision_map.insert( - "comments".to_string(), - serde_json::Value::String(comments.clone()), - ); - // Add notes field using comments since ContractRevision doesn't have a notes field - revision_map.insert( - "notes".to_string(), - serde_json::Value::String(comments.clone()), - ); - } else { - revision_map.insert( - "comments".to_string(), - serde_json::Value::String("".to_string()), - ); - revision_map.insert( - "notes".to_string(), - serde_json::Value::String("".to_string()), - ); - } - - serde_json::Value::Object(revision_map) - }) - .collect(); - - map.insert( - "revisions".to_string(), - serde_json::Value::Array(revisions.clone()), - ); - - // Add current_version - map.insert( - "current_version".to_string(), - serde_json::Value::Number(serde_json::Number::from(contract.current_version)), - ); - - // Add latest_revision as an object - if !contract.revisions.is_empty() { - // Find the latest revision based on version number - if let Some(latest) = contract.revisions.iter().max_by_key(|r| r.version) { - let mut latest_revision_map = serde_json::Map::new(); - latest_revision_map.insert( - "version".to_string(), - serde_json::Value::Number(serde_json::Number::from(latest.version)), - ); - latest_revision_map.insert( - "content".to_string(), - serde_json::Value::String(latest.content.clone()), - ); - latest_revision_map.insert( - "created_at".to_string(), - serde_json::Value::String(latest.created_at.format("%Y-%m-%d").to_string()), - ); - latest_revision_map.insert( - "created_by".to_string(), - serde_json::Value::String(latest.created_by.clone()), - ); - - if let Some(comments) = &latest.comments { - latest_revision_map.insert( - "comments".to_string(), - serde_json::Value::String(comments.clone()), - ); - latest_revision_map.insert( - "notes".to_string(), - serde_json::Value::String(comments.clone()), - ); - } else { - latest_revision_map.insert( - "comments".to_string(), - serde_json::Value::String("".to_string()), - ); - latest_revision_map.insert( - "notes".to_string(), - serde_json::Value::String("".to_string()), - ); - } - - map.insert( - "latest_revision".to_string(), - serde_json::Value::Object(latest_revision_map), - ); - } else { - // Create an empty latest_revision object to avoid template errors - let mut empty_revision = serde_json::Map::new(); - empty_revision.insert( - "version".to_string(), - serde_json::Value::Number(serde_json::Number::from(0)), - ); - empty_revision.insert( - "content".to_string(), - serde_json::Value::String("No content available".to_string()), - ); - empty_revision.insert( - "created_at".to_string(), - serde_json::Value::String("N/A".to_string()), - ); - empty_revision.insert( - "created_by".to_string(), - serde_json::Value::String("N/A".to_string()), - ); - empty_revision.insert( - "comments".to_string(), - serde_json::Value::String("".to_string()), - ); - empty_revision.insert( - "notes".to_string(), - serde_json::Value::String("".to_string()), - ); - - map.insert( - "latest_revision".to_string(), - serde_json::Value::Object(empty_revision), - ); } } else { - // Create an empty latest_revision object to avoid template errors - let mut empty_revision = serde_json::Map::new(); - empty_revision.insert( - "version".to_string(), - serde_json::Value::Number(serde_json::Number::from(0)), - ); - empty_revision.insert( - "content".to_string(), - serde_json::Value::String("No content available".to_string()), - ); - empty_revision.insert( - "created_at".to_string(), - serde_json::Value::String("N/A".to_string()), - ); - empty_revision.insert( - "created_by".to_string(), - serde_json::Value::String("N/A".to_string()), - ); - empty_revision.insert( - "comments".to_string(), - serde_json::Value::String("".to_string()), - ); - empty_revision.insert( - "notes".to_string(), - serde_json::Value::String("".to_string()), - ); + // Search by contract_id string + match get_contracts() { + Ok(contracts) => contracts + .into_iter() + .find(|c| c.contract_id == contract_id_str), + Err(e) => { + log::error!("Failed to get contracts from database: {}", e); + None + } + } + }; - map.insert( - "latest_revision".to_string(), - serde_json::Value::Object(empty_revision), - ); + if let Some(contract) = contract { + // Only allow adding signers if contract is in Draft status + if contract.status != ContractStatus::Draft { + return Ok( + HttpResponse::BadRequest().body("Only draft contracts can have signers added") + ); + } + + // Convert contract to JSON + let contract_json = Self::heromodel_contract_to_json(&contract); + context.insert("contract", &contract_json); + + render_template(&tmpl, "contracts/add_signer.html", &context) + } else { + render_contract_not_found(&tmpl, Some(&contract_id_str)).await } - - // Add effective and expiration dates if present - if let Some(effective_date) = &contract.effective_date { - map.insert( - "effective_date".to_string(), - serde_json::Value::String(effective_date.format("%Y-%m-%d").to_string()), - ); - } - - if let Some(expiration_date) = &contract.expiration_date { - map.insert( - "expiration_date".to_string(), - serde_json::Value::String(expiration_date.format("%Y-%m-%d").to_string()), - ); - } - - map } - // Generate mock contracts for testing - fn get_mock_contracts() -> Vec { - let mut contracts = Vec::new(); + // Add signer to contract + pub async fn add_signer( + tmpl: web::Data, + path: web::Path, + form: web::Form, + ) -> Result { + let contract_id_str = path.into_inner(); - // Mock contract 1 - Signed Service Agreement - let mut contract1 = Contract { - content_dir: None, - toc: None, - id: "contract-001".to_string(), - title: "Digital Hub Service Agreement".to_string(), - description: "Service agreement for cloud hosting and digital infrastructure services provided by the Zanzibar Digital Hub.".to_string(), - status: ContractStatus::Signed, - contract_type: ContractType::Service, - created_by: "Wei Chen".to_string(), - created_at: Utc::now() - Duration::days(30), - updated_at: Utc::now() - Duration::days(5), - organization_id: Some("Zanzibar Digital Hub".to_string()), - effective_date: Some(Utc::now() - Duration::days(5)), - expiration_date: Some(Utc::now() + Duration::days(365)), - signers: Vec::new(), - revisions: Vec::new(), - current_version: 2, + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } }; - // Add signers to contract 1 - contract1.signers.push(ContractSigner { - id: "signer-001".to_string(), - name: "Wei Chen".to_string(), - email: "wei.chen@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(5)), - comments: Some("Approved as per our discussion.".to_string()), - }); + // Validate form data + if form.name.trim().is_empty() { + return Self::render_add_signer_with_error(&tmpl, contract_id, "Name is required") + .await; + } - contract1.signers.push(ContractSigner { - id: "signer-002".to_string(), - name: "Nala Okafor".to_string(), - email: "nala.okafor@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(6)), - comments: Some("Terms look good. Happy to proceed.".to_string()), - }); + if form.email.trim().is_empty() { + return Self::render_add_signer_with_error(&tmpl, contract_id, "Email is required") + .await; + } - // Add revisions to contract 1 - contract1.revisions.push(ContractRevision { - version: 1, - content: "

Digital Hub Service Agreement

This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the undersigned client (\"Client\").

1. Services

Provider agrees to provide Client with cloud hosting and digital infrastructure services as specified in Appendix A.

2. Term

This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.

3. Fees

Client agrees to pay Provider the fees set forth in Appendix B. All fees are due within thirty (30) days of invoice date.

4. Confidentiality

Each party agrees to maintain the confidentiality of any proprietary information received from the other party during the term of this Agreement.

".to_string(), - created_at: Utc::now() - Duration::days(35), - created_by: "Wei Chen".to_string(), - comments: Some("Initial draft of the service agreement.".to_string()), - }); + // Basic email validation + if !form.email.contains('@') || !form.email.contains('.') { + return Self::render_add_signer_with_error( + &tmpl, + contract_id, + "Please enter a valid email address", + ) + .await; + } - contract1.revisions.push(ContractRevision { - version: 2, - content: "

Digital Hub Service Agreement

This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the undersigned client (\"Client\").

1. Services

Provider agrees to provide Client with cloud hosting and digital infrastructure services as specified in Appendix A.

2. Term

This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.

3. Fees

Client agrees to pay Provider the fees set forth in Appendix B. All fees are due within thirty (30) days of invoice date.

4. Confidentiality

Each party agrees to maintain the confidentiality of any proprietary information received from the other party during the term of this Agreement.

5. Data Protection

Provider shall implement appropriate technical and organizational measures to ensure a level of security appropriate to the risk, including encryption of personal data, and shall comply with all applicable data protection laws.

".to_string(), - created_at: Utc::now() - Duration::days(30), - created_by: "Wei Chen".to_string(), - comments: Some("Added data protection clause as requested by legal.".to_string()), - }); - - // Mock contract 2 - Pending Signatures - let mut contract2 = Contract { - content_dir: None, - toc: None, - id: "contract-002".to_string(), - title: "Software Development Agreement".to_string(), - description: "Agreement for custom software development services for the Zanzibar Digital Marketplace platform.".to_string(), - status: ContractStatus::PendingSignatures, - contract_type: ContractType::SLA, - created_by: "Dr. Raj Patel".to_string(), - created_at: Utc::now() - Duration::days(10), - updated_at: Utc::now() - Duration::days(2), - organization_id: Some("Global Tech Solutions".to_string()), - effective_date: None, - expiration_date: None, - signers: Vec::new(), - revisions: Vec::new(), - current_version: 1, + // Check if contract exists and is in Draft status + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().body("Contract not found")); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().body("Failed to get contract")); + } }; - // Add signers to contract 2 - contract2.signers.push(ContractSigner { - id: "signer-003".to_string(), - name: "Dr. Raj Patel".to_string(), - email: "raj.patel@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(2)), - comments: None, - }); + if contract.status != ContractStatus::Draft { + return Self::render_add_signer_with_error( + &tmpl, + contract_id, + "Only draft contracts can have signers added", + ) + .await; + } - contract2.signers.push(ContractSigner { - id: "signer-004".to_string(), - name: "Maya Rodriguez".to_string(), - email: "maya.rodriguez@example.com".to_string(), - status: SignerStatus::Pending, - signed_at: None, - comments: None, - }); + // Check for duplicate email + if contract + .signers + .iter() + .any(|s| s.email.to_lowercase() == form.email.to_lowercase()) + { + return Self::render_add_signer_with_error( + &tmpl, + contract_id, + "A signer with this email address already exists", + ) + .await; + } - contract2.signers.push(ContractSigner { - id: "signer-005".to_string(), - name: "Jamal Washington".to_string(), - email: "jamal.washington@example.com".to_string(), - status: SignerStatus::Pending, - signed_at: None, - comments: None, - }); + // Generate a unique signer ID + let signer_id = format!("SIGNER-{}", chrono::Utc::now().timestamp()); - // Add revisions to contract 2 - contract2.revisions.push(ContractRevision { - version: 1, - content: "

Software Development Agreement

This Software Development Agreement (the \"Agreement\") is entered into between Global Tech Solutions (\"Developer\") and Zanzibar Digital Hub (\"Client\").

1. Scope of Work

Developer agrees to design, develop, and implement a digital marketplace platform as specified in the attached Statement of Work.

2. Timeline

Developer shall complete the development according to the timeline set forth in Appendix A.

3. Compensation

Client agrees to pay Developer the fees set forth in Appendix B according to the payment schedule therein.

4. Intellectual Property

Upon full payment, Client shall own all rights, title, and interest in the developed software.

".to_string(), - created_at: Utc::now() - Duration::days(10), - created_by: "Dr. Raj Patel".to_string(), - comments: Some("Initial draft of the development agreement.".to_string()), - }); + // Add the signer to the contract + match add_signer_to_contract( + contract_id, + &signer_id, + &form.name.trim(), + &form.email.trim(), + ) { + Ok(updated_contract) => { + log::info!( + "Successfully added signer {} ({}) to contract {}", + form.name, + form.email, + contract_id + ); - // Mock contract 3 - Draft - let mut contract3 = Contract { - id: "contract-003".to_string(), - title: "Digital Asset Tokenization Agreement".to_string(), - description: "Framework agreement for tokenizing real estate assets on the Zanzibar blockchain network.".to_string(), - status: ContractStatus::Draft, - contract_type: ContractType::Partnership, - created_by: "Nala Okafor".to_string(), - created_at: Utc::now() - Duration::days(3), - updated_at: Utc::now() - Duration::days(1), - organization_id: Some("Zanzibar Property Consortium".to_string()), - effective_date: None, - expiration_date: None, - signers: Vec::new(), - revisions: Vec::new(), - current_version: 1, - content_dir: Some("src/content/contract-003".to_string()), - toc: Some(vec![ - TocItem { - title: "Cover".to_string(), - file: "cover.md".to_string(), - children: vec![], - }, - TocItem { - title: "1. Purpose".to_string(), - file: "1-purpose.md".to_string(), - children: vec![], - }, - TocItem { - title: "2. Tokenization Process".to_string(), - file: "2-tokenization-process.md".to_string(), - children: vec![], - }, - TocItem { - title: "3. Revenue Sharing".to_string(), - file: "3-revenue-sharing.md".to_string(), - children: vec![], - }, - TocItem { - title: "4. Governance".to_string(), - file: "4-governance.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix A: Properties".to_string(), - file: "appendix-a.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix B: Technical Specs".to_string(), - file: "appendix-b.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix C: Revenue Formula".to_string(), - file: "appendix-c.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix D: Governance Framework".to_string(), - file: "appendix-d.md".to_string(), - children: vec![], - }, - ]), + log::info!( + "Contract Activity: Added signer {} to contract '{}'", + form.name.trim(), + updated_contract.title + ); + + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to add signer to contract: {}", e); + Self::render_add_signer_with_error( + &tmpl, + contract_id, + "Failed to add signer to contract", + ) + .await + } + } + } + + // Helper method to render add signer form with error message + async fn render_add_signer_with_error( + tmpl: &web::Data, + contract_id: u32, + error_message: &str, + ) -> Result { + let mut context = Context::new(); + context.insert("active_page", &"contracts"); + context.insert("error", &error_message); + + // Get contract data for the form + match get_contract_by_id(contract_id) { + Ok(Some(contract)) => { + let contract_json = Self::heromodel_contract_to_json(&contract); + context.insert("contract", &contract_json); + render_template(tmpl, "contracts/add_signer.html", &context) + } + Ok(None) => Ok(HttpResponse::NotFound().body("Contract not found")), + Err(e) => { + log::error!("Failed to get contract for error rendering: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to load contract")) + } + } + } + + // Send reminder to signers + pub async fn remind_to_sign( + _tmpl: web::Data, + path: web::Path, + ) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } }; - // Add potential signers to contract 3 (still in draft) - contract3.signers.push(ContractSigner { - id: "signer-006".to_string(), - name: "Nala Okafor".to_string(), - email: "nala.okafor@example.com".to_string(), - status: SignerStatus::Pending, - signed_at: None, - comments: None, - }); - - contract3.signers.push(ContractSigner { - id: "signer-007".to_string(), - name: "Ibrahim Al-Farsi".to_string(), - email: "ibrahim.alfarsi@example.com".to_string(), - status: SignerStatus::Pending, - signed_at: None, - comments: None, - }); - - // Add ToC and content directory to contract 3 - contract3.content_dir = Some("src/content/contract-003".to_string()); - contract3.toc = Some(vec![TocItem { - title: "Digital Asset Tokenization Agreement".to_string(), - file: "cover.md".to_string(), - children: vec![ - TocItem { - title: "1. Purpose".to_string(), - file: "1-purpose.md".to_string(), - children: vec![], - }, - TocItem { - title: "2. Tokenization Process".to_string(), - file: "2-tokenization-process.md".to_string(), - children: vec![], - }, - TocItem { - title: "3. Revenue Sharing".to_string(), - file: "3-revenue-sharing.md".to_string(), - children: vec![], - }, - TocItem { - title: "4. Governance".to_string(), - file: "4-governance.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix A: Properties".to_string(), - file: "appendix-a.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix B: Specifications".to_string(), - file: "appendix-b.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix C: Revenue Formula".to_string(), - file: "appendix-c.md".to_string(), - children: vec![], - }, - TocItem { - title: "Appendix D: Governance Framework".to_string(), - file: "appendix-d.md".to_string(), - children: vec![], - }, - ], - }]); - // No revision content for contract 3, content is in markdown files. - - // Mock contract 4 - Rejected - let mut contract4 = Contract { - content_dir: None, - toc: None, - id: "contract-004".to_string(), - title: "Data Sharing Agreement".to_string(), - description: "Agreement governing the sharing of anonymized data between Zanzibar Digital Hub and research institutions.".to_string(), - status: ContractStatus::Draft, - contract_type: ContractType::NDA, - created_by: "Wei Chen".to_string(), - created_at: Utc::now() - Duration::days(15), - updated_at: Utc::now() - Duration::days(8), - organization_id: Some("Zanzibar Digital Hub".to_string()), - effective_date: None, - expiration_date: None, - signers: Vec::new(), - revisions: Vec::new(), - current_version: 1, + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to get contract" + }))); + } }; - // Add signers to contract 4 with a rejection - contract4.signers.push(ContractSigner { - id: "signer-008".to_string(), - name: "Wei Chen".to_string(), - email: "wei.chen@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(10)), - comments: None, - }); + // Check if contract is in PendingSignatures status + if contract.status != ContractStatus::PendingSignatures { + let error_message = match contract.status { + ContractStatus::Draft => { + "Please send the contract for signatures first before sending reminders. Click 'Send for Signatures' in the Document tab." + } + ContractStatus::Signed => { + "This contract is already fully signed. No reminders needed." + } + ContractStatus::Active => "This contract is already active. No reminders needed.", + ContractStatus::Expired => "This contract has expired. Cannot send reminders.", + ContractStatus::Cancelled => { + "This contract has been cancelled. Cannot send reminders." + } + _ => "Only contracts pending signatures can have reminders sent.", + }; - contract4.signers.push(ContractSigner { - id: "signer-009".to_string(), - name: "Dr. Amina Diallo".to_string(), - email: "amina.diallo@example.com".to_string(), - status: SignerStatus::Rejected, - signed_at: Some(Utc::now() - Duration::days(8)), - comments: Some("Cannot agree to these terms due to privacy concerns. Please revise section 3.2 regarding data retention.".to_string()), - }); + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": error_message + }))); + } - // Add revisions to contract 4 - contract4.revisions.push(ContractRevision { - version: 1, - content: "

Data Sharing Agreement

This Data Sharing Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the research institutions listed in Appendix A (\"Recipients\").

1. Purpose

The purpose of this Agreement is to establish the terms and conditions for sharing anonymized data for research purposes.

2. Data Description

Provider shall share the data described in Appendix B, which shall be anonymized according to the protocol in Appendix C.

3. Data Use

Recipients may use the shared data solely for the research purposes described in Appendix D.

3.1 Publication

Recipients may publish research findings based on the shared data, provided that they acknowledge Provider as the data source.

3.2 Data Retention

Recipients shall retain the shared data for a period of five (5) years, after which they shall securely delete all copies.

".to_string(), - created_at: Utc::now() - Duration::days(15), - created_by: "Wei Chen".to_string(), - comments: Some("Initial draft of the data sharing agreement.".to_string()), - }); + // Get pending signers + let pending_signers: Vec<_> = contract + .signers + .iter() + .filter(|s| s.status == heromodels::models::legal::SignerStatus::Pending) + .collect(); - // Mock contract 5 - Active - let mut contract5 = Contract { - content_dir: None, - toc: None, - id: "contract-005".to_string(), - title: "Digital Identity Verification Service Agreement".to_string(), - description: "Agreement for providing digital identity verification services to businesses operating in the Zanzibar Digital Freezone.".to_string(), - status: ContractStatus::Active, - contract_type: ContractType::Service, - created_by: "Maya Rodriguez".to_string(), - created_at: Utc::now() - Duration::days(60), - updated_at: Utc::now() - Duration::days(45), - organization_id: Some("Zanzibar Digital Hub".to_string()), - effective_date: Some(Utc::now() - Duration::days(45)), - expiration_date: Some(Utc::now() + Duration::days(305)), - signers: Vec::new(), - revisions: Vec::new(), - current_version: 2, + if pending_signers.is_empty() { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "No pending signers found" + }))); + } + + let current_time = chrono::Utc::now().timestamp() as u64; + let mut blocked_signers = Vec::new(); + let mut available_signers = Vec::new(); + + // Check rate limiting for each signer using the heromodels field + for signer in &pending_signers { + let (can_send, time_until_next) = + Self::can_send_reminder_to_signer(signer, current_time); + + if !can_send { + blocked_signers.push((signer.name.clone(), time_until_next)); + } else { + available_signers.push(signer); + } + } + + // If all signers are rate limited, return error with details + if available_signers.is_empty() { + let mut error_message = + "All signers are rate limited. Next available times:\n".to_string(); + for (name, time_left) in blocked_signers { + let minutes = time_left / 60; + let seconds = time_left % 60; + error_message.push_str(&format!("• {}: {}:{:02}\n", name, minutes, seconds)); + } + + return Ok(HttpResponse::TooManyRequests().json(serde_json::json!({ + "success": false, + "error": error_message.trim() + }))); + } + + // Send reminder emails to available signers only + for signer in &available_signers { + Self::send_reminder_email(&contract, signer); + // Update the signer's reminder timestamp in database + if let Err(e) = + Self::update_signer_reminder_timestamp(contract_id, &signer.id, current_time) + { + log::error!( + "Failed to update reminder timestamp for signer {}: {}", + signer.id, + e + ); + } + } + + // Calculate next reminder time (30 minutes = 1800 seconds) + let next_reminder_time = current_time + 1800; + + log::info!( + "Reminder sent for contract {} to {} pending signers", + contract_id, + pending_signers.len() + ); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": format!("Reminder sent to {} pending signer(s)", pending_signers.len()), + "next_reminder_time": next_reminder_time, + "time_until_next": 1800 + }))) + } + + // Get reminder status for all signers (for persistent countdown) + pub async fn get_reminder_status(path: web::Path) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "Invalid contract ID" + }))); + } }; - // Add signers to contract 5 - contract5.signers.push(ContractSigner { - id: "signer-010".to_string(), - name: "Maya Rodriguez".to_string(), - email: "maya.rodriguez@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(47)), - comments: None, + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "success": false, + "error": "Contract not found" + }))); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": "Failed to get contract" + }))); + } + }; + + let current_time = chrono::Utc::now().timestamp() as u64; + let mut signer_statuses = Vec::new(); + + // Check reminder status for each pending signer + for signer in &contract.signers { + if signer.status == heromodels::models::legal::SignerStatus::Pending { + let (can_remind, time_until_next) = + Self::can_send_reminder_to_signer(signer, current_time); + + signer_statuses.push(serde_json::json!({ + "signer_id": signer.id, + "signer_name": signer.name, + "can_remind": can_remind, + "time_until_next": time_until_next + })); + } + } + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "contract_status": format!("{:?}", contract.status), + "signer_statuses": signer_statuses + }))) + } + + // Send contract for signatures + pub async fn send_for_signatures(path: web::Path) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // Get contract from database + let contract = match get_contract_by_id(contract_id) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Ok(HttpResponse::NotFound().body("Contract not found")); + } + Err(e) => { + log::error!("Failed to get contract: {}", e); + return Ok(HttpResponse::InternalServerError().body("Failed to get contract")); + } + }; + + // Check if contract is in Draft status + if contract.status != ContractStatus::Draft { + return Ok( + HttpResponse::BadRequest().body("Only draft contracts can be sent for signatures") + ); + } + + // Check if contract has signers + if contract.signers.is_empty() { + return Ok(HttpResponse::BadRequest() + .body("Contract must have at least one signer before sending")); + } + + // Check if contract has content + if contract.terms_and_conditions.trim().is_empty() { + return Ok(HttpResponse::BadRequest().body("Contract must have content before sending")); + } + + // Update contract status to PendingSignatures + match update_contract_status(contract_id, ContractStatus::PendingSignatures) { + Ok(_) => { + log::info!("Contract {} sent for signatures", contract_id); + + // Create governance activity for sending contract for signatures + let activity_description = format!( + "Sent contract '{}' for signatures to {} signer(s)", + contract.title, + contract.signers.len() + ); + + // Log the activity for now - in the future this could be stored in a contract activity system + log::info!("Contract Activity: {}", activity_description); + + // In a real app, this would send emails to all signers + for signer in &contract.signers { + println!( + "šŸ“§ Sending signing invitation to: {} ({})", + signer.name, signer.email + ); + } + + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to update contract status: {}", e); + Ok(HttpResponse::InternalServerError() + .body("Failed to send contract for signatures")) + } + } + } + + // Helper method to check if signer can receive reminder (using heromodels field) + fn can_send_reminder_to_signer( + signer: &heromodels::models::legal::ContractSigner, + current_time: u64, + ) -> (bool, u64) { + // Use the database function to check the timestamp + match Self::get_signer_last_reminder_time(&signer.id, current_time) { + Some(last_sent) => { + let time_since_last = current_time - last_sent; + if time_since_last >= 1800 { + // 30 minutes (1800 seconds) have passed + (true, 0) + } else { + // Still in cooldown period + (false, 1800 - time_since_last) + } + } + None => (true, 0), // Never sent reminder, can send immediately + } + } + + // Helper method to get signer's last reminder time from in-memory tracking + fn get_signer_last_reminder_time(signer_id: &str, _current_time: u64) -> Option { + // Use the in-memory HashMap to track reminder timestamps + match REMINDER_TIMESTAMPS.lock() { + Ok(timestamps) => timestamps.get(signer_id).copied(), + Err(e) => { + log::error!("Failed to access reminder timestamps: {}", e); + None + } + } + } + + // Helper method to update signer's reminder timestamp in in-memory tracking + fn update_signer_reminder_timestamp( + _contract_id: u32, + signer_id: &str, + timestamp: u64, + ) -> Result<(), String> { + // Store the timestamp in the in-memory HashMap + match REMINDER_TIMESTAMPS.lock() { + Ok(mut timestamps) => { + timestamps.insert(signer_id.to_string(), timestamp); + log::info!( + "Updated reminder timestamp for signer {}: {}", + signer_id, + timestamp + ); + Ok(()) + } + Err(e) => { + log::error!("Failed to update reminder timestamp: {}", e); + Err(format!("Failed to update reminder timestamp: {}", e)) + } + } + } + + // Helper method to create contract activities for display + fn create_contract_activities( + contract: &heromodels::models::legal::Contract, + ) -> Vec { + let mut activities = Vec::new(); + + // Contract creation activity + activities.push(serde_json::json!({ + "type": "created", + "icon": "bi-plus-circle-fill text-success", + "title": "Contract Created", + "description": format!("{} created this contract", contract.created_by), + "timestamp": Self::format_timestamp(contract.base_data.created_at), + "user": contract.created_by + })); + + // Signer added activities + for signer in &contract.signers { + activities.push(serde_json::json!({ + "type": "signer_added", + "icon": "bi-person-plus-fill text-primary", + "title": "Signer Added", + "description": format!("Added signer: {} ({})", signer.name, signer.email), + "timestamp": Self::format_timestamp(contract.base_data.created_at), + "user": contract.created_by + })); + } + + // Status change activities + if contract.status != heromodels::models::legal::ContractStatus::Draft { + let status_description = match contract.status { + heromodels::models::legal::ContractStatus::PendingSignatures => { + "Sent contract for signatures" + } + heromodels::models::legal::ContractStatus::Signed => "Contract was signed", + heromodels::models::legal::ContractStatus::Active => "Contract became active", + heromodels::models::legal::ContractStatus::Expired => "Contract expired", + heromodels::models::legal::ContractStatus::Cancelled => "Contract was cancelled", + _ => "Status changed", + }; + + activities.push(serde_json::json!({ + "type": "status_changed", + "icon": "bi-arrow-right-circle-fill text-info", + "title": "Status Changed", + "description": status_description, + "timestamp": Self::format_timestamp(contract.base_data.created_at), + "user": contract.created_by + })); + } + + // Sort activities by timestamp (newest first) + activities.reverse(); + activities + } + + // Helper method to get recent contract activities for dashboard + fn get_recent_contract_activities() -> Vec { + let mut all_activities = Vec::new(); + + // Get all contracts and generate activities for each + match get_contracts() { + Ok(contracts) => { + for contract in contracts { + let contract_activities = Self::create_contract_activities(&contract); + all_activities.extend(contract_activities); + } + } + Err(e) => { + log::error!("Failed to get contracts for activities: {}", e); + } + } + + // Sort by timestamp (newest first) and take only recent ones + all_activities.sort_by(|a, b| { + let timestamp_a = a.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + let timestamp_b = b.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + timestamp_b.cmp(timestamp_a) }); - contract5.signers.push(ContractSigner { - id: "signer-011".to_string(), - name: "Li Wei".to_string(), - email: "li.wei@example.com".to_string(), - status: SignerStatus::Signed, - signed_at: Some(Utc::now() - Duration::days(45)), - comments: Some("Approved after legal review.".to_string()), + // Take only the 5 most recent activities for dashboard + all_activities.into_iter().take(5).collect() + } + + // Helper method to get all contract activities for activities page + fn get_all_contract_activities() -> Vec { + let mut all_activities = Vec::new(); + + // Get all contracts and generate activities for each + match get_contracts() { + Ok(contracts) => { + for contract in contracts { + let contract_activities = Self::create_contract_activities(&contract); + all_activities.extend(contract_activities); + } + } + Err(e) => { + log::error!("Failed to get contracts for activities: {}", e); + } + } + + // Sort by timestamp (newest first) + all_activities.sort_by(|a, b| { + let timestamp_a = a.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + let timestamp_b = b.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + timestamp_b.cmp(timestamp_a) }); - // Add revisions to contract 5 - contract5.revisions.push(ContractRevision { - version: 1, - content: "

Digital Identity Verification Service Agreement

This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the businesses listed in Appendix A (\"Clients\").

1. Services

Provider agrees to provide Clients with digital identity verification services as specified in Appendix B.

2. Term

This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.

3. Fees

Clients agree to pay Provider the fees set forth in Appendix C. All fees are due within thirty (30) days of invoice date.

4. Service Level Agreement

Provider shall maintain a service uptime of at least 99.9% as measured on a monthly basis.

".to_string(), - created_at: Utc::now() - Duration::days(60), - created_by: "Maya Rodriguez".to_string(), - comments: Some("Initial draft of the identity verification service agreement.".to_string()), - }); + all_activities + } - contract5.revisions.push(ContractRevision { - version: 2, - content: "

Digital Identity Verification Service Agreement

This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the businesses listed in Appendix A (\"Clients\").

1. Services

Provider agrees to provide Clients with digital identity verification services as specified in Appendix B.

2. Term

This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.

3. Fees

Clients agree to pay Provider the fees set forth in Appendix C. All fees are due within thirty (30) days of invoice date.

4. Service Level Agreement

Provider shall maintain a service uptime of at least 99.9% as measured on a monthly basis.

5. Compliance

Provider shall comply with all applicable laws and regulations regarding identity verification and data protection, including but not limited to the Zanzibar Digital Economy Act.

".to_string(), - created_at: Utc::now() - Duration::days(50), - created_by: "Maya Rodriguez".to_string(), - comments: Some("Added compliance clause as requested by legal.".to_string()), - }); + // Helper method to format timestamp from i64 to readable string + fn format_timestamp(timestamp: i64) -> String { + use chrono::DateTime; - // Add all contracts to the vector - contracts.push(contract1); - contracts.push(contract2); - contracts.push(contract3); - contracts.push(contract4); - contracts.push(contract5); + if let Some(dt) = DateTime::from_timestamp(timestamp, 0) { + dt.format("%Y-%m-%d %H:%M:%S UTC").to_string() + } else { + "Unknown".to_string() + } + } - contracts + // Helper method to send reminder email (console log for now) + fn send_reminder_email( + contract: &Contract, + signer: &heromodels::models::legal::ContractSigner, + ) { + // Professional email template + let email_subject = format!("Reminder: Please sign contract \"{}\"", contract.title); + let signing_url = format!("https://yourapp.com/sign/{}", signer.id); // In real app, this would be a secure token + + let email_body = format!( + r#" +Dear {}, + +This is a friendly reminder that you have a contract waiting for your signature. + +Contract Details: +- Title: {} +- Description: {} +- Contract Type: {} + +To review and sign the contract, please click the link below: +{} + +If you have any questions or concerns about this contract, please contact the contract creator. + +Thank you for your time. + +Best regards, +Contract Management System + "#, + signer.name, contract.title, contract.description, contract.contract_type, signing_url + ); + + // For now, log to console (in production, this would send actual email) + println!("\n=== EMAIL REMINDER ==="); + println!("To: {}", signer.email); + println!("Subject: {}", email_subject); + println!("Body:\n{}", email_body); + println!("======================\n"); + + log::info!( + "Reminder email sent to {} for contract {}", + signer.email, + contract.contract_id + ); + } + + // Filter contracts by status + pub async fn filter_by_status( + tmpl: web::Data, + path: web::Path, + ) -> Result { + let status_str = path.into_inner(); + let mut context = Context::new(); + + // Parse status + let status = match status_str.as_str() { + "draft" => ContractStatus::Draft, + "pending" => ContractStatus::PendingSignatures, + "signed" => ContractStatus::Signed, + "active" => ContractStatus::Active, + "expired" => ContractStatus::Expired, + "cancelled" => ContractStatus::Cancelled, + _ => { + return Ok(HttpResponse::BadRequest().body("Invalid status")); + } + }; + + // Get contracts by status from database + let contracts = match get_contracts_by_status(status) { + Ok(contracts) => contracts, + Err(e) => { + log::error!("Failed to get contracts by status from database: {}", e); + vec![] // Return empty vector if there's an error + } + }; + + // Convert heromodels contracts to JSON for template + let contracts_data: Vec = contracts + .iter() + .map(|c| Self::heromodel_contract_to_json(c)) + .collect(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + context.insert("contracts", &contracts_data); + context.insert("filter", &status_str); + + render_template(&tmpl, "contracts/contracts.html", &context) + } + + // Add revision to contract + pub async fn add_revision( + path: web::Path, + form: web::Form, + ) -> Result { + let contract_id_str = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // Add the revision to the contract + match add_revision_to_contract( + contract_id, + form.version, + &form.content, + &form.created_by, + form.comments.as_deref(), + ) { + Ok(_) => { + log::info!("Successfully added revision to contract {}", contract_id); + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to add revision to contract: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to add revision to contract")) + } + } + } + + // Update signer status + pub async fn update_signer_status( + path: web::Path<(String, String, String)>, + form: web::Form, + ) -> Result { + let (contract_id_str, signer_id, status_str) = path.into_inner(); + + // Parse contract ID + let contract_id = match contract_id_str.parse::() { + Ok(id) => id, + Err(_) => { + return Ok(HttpResponse::BadRequest().body("Invalid contract ID")); + } + }; + + // Parse signer status + let status = match status_str.as_str() { + "pending" => heromodels::models::legal::SignerStatus::Pending, + "signed" => heromodels::models::legal::SignerStatus::Signed, + "rejected" => heromodels::models::legal::SignerStatus::Rejected, + _ => { + return Ok(HttpResponse::BadRequest().body("Invalid signer status")); + } + }; + + // Update the signer status + match update_signer_status(contract_id, &signer_id, status, form.comments.as_deref()) { + Ok(_) => { + log::info!( + "Successfully updated signer {} status for contract {}", + signer_id, + contract_id + ); + Ok(HttpResponse::Found() + .append_header(("Location", format!("/contracts/{}", contract_id))) + .finish()) + } + Err(e) => { + log::error!("Failed to update signer status: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to update signer status")) + } + } + } + + // Get contract statistics (API endpoint) + pub async fn statistics() -> Result { + match get_contract_statistics() { + Ok(stats) => { + let stats_json = serde_json::json!({ + "total_contracts": stats.total_contracts, + "draft_contracts": stats.draft_contracts, + "pending_signature_contracts": stats.pending_signature_contracts, + "signed_contracts": stats.signed_contracts, + "active_contracts": stats.active_contracts, + "expired_contracts": stats.expired_contracts, + "cancelled_contracts": stats.cancelled_contracts + }); + Ok(HttpResponse::Ok().json(stats_json)) + } + Err(e) => { + log::error!("Failed to get contract statistics: {}", e); + Ok(HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to get contract statistics"}))) + } + } } } + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct SignerForm { + pub name: String, + pub email: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct RevisionForm { + pub version: u32, + pub content: String, + pub created_by: String, + pub comments: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct SignerStatusForm { + pub comments: Option, +} diff --git a/actix_mvc_app/src/controllers/error.rs b/actix_mvc_app/src/controllers/error.rs new file mode 100644 index 0000000..485f65d --- /dev/null +++ b/actix_mvc_app/src/controllers/error.rs @@ -0,0 +1,121 @@ +use actix_web::{Error, HttpResponse, web}; +use tera::{Context, Tera}; + +pub struct ErrorController; + +impl ErrorController { + /// Renders a 404 Not Found page with customizable content + pub async fn not_found( + tmpl: web::Data, + error_title: Option<&str>, + error_message: Option<&str>, + return_url: Option<&str>, + return_text: Option<&str>, + ) -> Result { + let mut context = Context::new(); + + // Set default or custom error content + context.insert("error_title", &error_title.unwrap_or("Page Not Found")); + context.insert( + "error_message", + &error_message + .unwrap_or("The page you're looking for doesn't exist or has been moved."), + ); + + // Optional return URL and text + if let Some(url) = return_url { + context.insert("return_url", &url); + context.insert("return_text", &return_text.unwrap_or("Return")); + } + + // Render the 404 template with 404 status + match tmpl.render("errors/404.html", &context) { + Ok(rendered) => Ok(HttpResponse::NotFound() + .content_type("text/html; charset=utf-8") + .body(rendered)), + Err(e) => { + log::error!("Failed to render 404 template: {}", e); + // Fallback to simple text response + Ok(HttpResponse::NotFound() + .content_type("text/plain") + .body("404 - Page Not Found")) + } + } + } + + /// Renders a 404 page for contract not found + pub async fn contract_not_found( + tmpl: web::Data, + contract_id: Option<&str>, + ) -> Result { + let error_title = "Contract Not Found"; + let error_message = if let Some(id) = contract_id { + format!( + "The contract with ID '{}' doesn't exist or has been removed.", + id + ) + } else { + "The contract you're looking for doesn't exist or has been removed.".to_string() + }; + + Self::not_found( + tmpl, + Some(error_title), + Some(&error_message), + Some("/contracts"), + Some("Back to Contracts"), + ) + .await + } + + /// Renders a 404 page for calendar event not found + pub async fn calendar_event_not_found( + tmpl: web::Data, + event_id: Option<&str>, + ) -> Result { + let error_title = "Calendar Event Not Found"; + let error_message = if let Some(id) = event_id { + format!( + "The calendar event with ID '{}' doesn't exist or has been removed.", + id + ) + } else { + "The calendar event you're looking for doesn't exist or has been removed.".to_string() + }; + + Self::not_found( + tmpl, + Some(error_title), + Some(&error_message), + Some("/calendar"), + Some("Back to Calendar"), + ) + .await + } + + /// Renders a generic 404 page + pub async fn generic_not_found(tmpl: web::Data) -> Result { + Self::not_found(tmpl, None, None, None, None).await + } +} + +/// Helper function to quickly render a contract not found response +pub async fn render_contract_not_found( + tmpl: &web::Data, + contract_id: Option<&str>, +) -> Result { + ErrorController::contract_not_found(tmpl.clone(), contract_id).await +} + +/// Helper function to quickly render a calendar event not found response +pub async fn render_calendar_event_not_found( + tmpl: &web::Data, + event_id: Option<&str>, +) -> Result { + ErrorController::calendar_event_not_found(tmpl.clone(), event_id).await +} + +/// Helper function to quickly render a generic not found response +pub async fn render_generic_not_found(tmpl: web::Data) -> Result { + ErrorController::generic_not_found(tmpl).await +} diff --git a/actix_mvc_app/src/controllers/mod.rs b/actix_mvc_app/src/controllers/mod.rs index 7e06f34..2b1382f 100644 --- a/actix_mvc_app/src/controllers/mod.rs +++ b/actix_mvc_app/src/controllers/mod.rs @@ -1,14 +1,15 @@ // Export controllers -pub mod home; -pub mod auth; -pub mod ticket; -pub mod calendar; -pub mod governance; -pub mod flow; -pub mod contract; pub mod asset; -pub mod defi; -pub mod marketplace; +pub mod auth; +pub mod calendar; pub mod company; +pub mod contract; +pub mod defi; +pub mod error; +pub mod flow; +pub mod governance; +pub mod home; +pub mod marketplace; +pub mod ticket; // Re-export controllers for easier imports diff --git a/actix_mvc_app/src/db/calendar.rs b/actix_mvc_app/src/db/calendar.rs index 5af4c8d..f21a50f 100644 --- a/actix_mvc_app/src/db/calendar.rs +++ b/actix_mvc_app/src/db/calendar.rs @@ -97,7 +97,7 @@ pub fn get_calendars() -> Result, String> { let calendars = match collection.get_all() { Ok(calendars) => calendars, Err(e) => { - eprintln!("Error loading calendars: {:?}", e); + log::error!("Error loading calendars: {:?}", e); vec![] // Return an empty vector if there's an error } }; @@ -113,7 +113,7 @@ pub fn get_events() -> Result, String> { let events = match collection.get_all() { Ok(events) => events, Err(e) => { - eprintln!("Error loading events: {:?}", e); + log::error!("Error loading events: {:?}", e); vec![] // Return an empty vector if there's an error } }; @@ -129,7 +129,7 @@ pub fn get_calendar_by_id(calendar_id: u32) -> Result, String> match collection.get_by_id(calendar_id) { Ok(calendar) => Ok(calendar), Err(e) => { - eprintln!("Error fetching calendar by id {}: {:?}", calendar_id, e); + log::error!("Error fetching calendar by id {}: {:?}", calendar_id, e); Err(format!("Failed to fetch calendar: {:?}", e)) } } @@ -144,7 +144,7 @@ pub fn get_event_by_id(event_id: u32) -> Result, String> { match collection.get_by_id(event_id) { Ok(event) => Ok(event), Err(e) => { - eprintln!("Error fetching event by id {}: {:?}", event_id, e); + log::error!("Error fetching event by id {}: {:?}", event_id, e); Err(format!("Failed to fetch event: {:?}", e)) } } @@ -178,7 +178,7 @@ pub fn get_attendee_by_id(attendee_id: u32) -> Result, String> match collection.get_by_id(attendee_id) { Ok(attendee) => Ok(attendee), Err(e) => { - eprintln!("Error fetching attendee by id {}: {:?}", attendee_id, e); + log::error!("Error fetching attendee by id {}: {:?}", attendee_id, e); Err(format!("Failed to fetch attendee: {:?}", e)) } } @@ -332,7 +332,7 @@ pub fn get_or_create_user_calendar(user_id: u32, user_name: &str) -> Result calendars, Err(e) => { - eprintln!("Error loading calendars: {:?}", e); + log::error!("Error loading calendars: {:?}", e); vec![] // Return an empty vector if there's an error } }; diff --git a/actix_mvc_app/src/db/contracts.rs b/actix_mvc_app/src/db/contracts.rs new file mode 100644 index 0000000..8516147 --- /dev/null +++ b/actix_mvc_app/src/db/contracts.rs @@ -0,0 +1,458 @@ +use heromodels::{ + db::{Collection, Db}, + models::legal::{Contract, ContractRevision, ContractSigner, ContractStatus, SignerStatus}, +}; + +use super::db::get_db; + +/// Creates a new contract and saves it to the database. Returns the saved contract and its ID. +pub fn create_new_contract( + base_id: u32, + contract_id: &str, + title: &str, + description: &str, + contract_type: &str, + status: ContractStatus, + created_by: &str, + terms_and_conditions: Option<&str>, + start_date: Option, + end_date: Option, + renewal_period_days: Option, +) -> Result<(u32, Contract), String> { + let db = get_db().expect("Can get DB"); + + // Create a new contract using the heromodels Contract::new constructor + let mut contract = Contract::new(base_id, contract_id.to_string()) + .title(title) + .description(description) + .contract_type(contract_type.to_string()) + .status(status) + .created_by(created_by.to_string()); + + if let Some(terms) = terms_and_conditions { + contract = contract.terms_and_conditions(terms); + } + if let Some(start) = start_date { + contract = contract.start_date(start); + } + if let Some(end) = end_date { + contract = contract.end_date(end); + } + if let Some(renewal) = renewal_period_days { + contract = contract.renewal_period_days(renewal as i32); + } + + // Save the contract to the database + let collection = db + .collection::() + .expect("can open contract collection"); + let (contract_id, saved_contract) = collection.set(&contract).expect("can save contract"); + + Ok((contract_id, saved_contract)) +} + +/// Loads all contracts from the database and returns them as a Vec. +pub fn get_contracts() -> Result, String> { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .expect("can open contract collection"); + + // Try to load all contracts, but handle deserialization errors gracefully + let contracts = match collection.get_all() { + Ok(contracts) => contracts, + Err(e) => { + log::error!("Failed to load contracts from database: {:?}", e); + vec![] // Return empty vector if there's an error + } + }; + Ok(contracts) +} + +/// Fetches a single contract by its ID from the database. +pub fn get_contract_by_id(contract_id: u32) -> Result, String> { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + match collection.get_by_id(contract_id) { + Ok(contract) => Ok(contract), + Err(e) => { + log::error!("Error fetching contract by id {}: {:?}", contract_id, e); + Err(format!("Failed to fetch contract: {:?}", e)) + } + } +} + +/// Updates a contract's basic information in the database and returns the updated contract. +pub fn update_contract( + contract_id: u32, + title: &str, + description: &str, + contract_type: &str, + terms_and_conditions: Option<&str>, + start_date: Option, + end_date: Option, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + // Update the contract fields + contract = contract + .title(title) + .description(description) + .contract_type(contract_type.to_string()); + + if let Some(terms) = terms_and_conditions { + contract = contract.terms_and_conditions(terms); + } + if let Some(start) = start_date { + contract = contract.start_date(start); + } + if let Some(end) = end_date { + contract = contract.end_date(end); + } + + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Updates a contract's status in the database and returns the updated contract. +pub fn update_contract_status( + contract_id: u32, + status: ContractStatus, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + contract = contract.status(status); + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Adds a signer to a contract and returns the updated contract. +pub fn add_signer_to_contract( + contract_id: u32, + signer_id: &str, + name: &str, + email: &str, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + let signer = + ContractSigner::new(signer_id.to_string(), name.to_string(), email.to_string()); + contract = contract.add_signer(signer); + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Adds a revision to a contract and returns the updated contract. +pub fn add_revision_to_contract( + contract_id: u32, + version: u32, + content: &str, + created_by: &str, + comments: Option<&str>, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + let revision = ContractRevision::new( + version, + content.to_string(), + current_timestamp_secs(), + created_by.to_string(), + ) + .comments(comments.unwrap_or("").to_string()); + + contract = contract.add_revision(revision); + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Updates a signer's status for a contract and returns the updated contract. +pub fn update_signer_status( + contract_id: u32, + signer_id: &str, + status: SignerStatus, + comments: Option<&str>, +) -> Result { + update_signer_status_with_signature(contract_id, signer_id, status, comments, None) +} + +/// Updates a signer's status with signature data for a contract and returns the updated contract. +pub fn update_signer_status_with_signature( + contract_id: u32, + signer_id: &str, + status: SignerStatus, + comments: Option<&str>, + signature_data: Option<&str>, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + // Find and update the signer + let mut signer_found = false; + for signer in &mut contract.signers { + if signer.id == signer_id { + signer.status = status.clone(); + if status == SignerStatus::Signed { + signer.signed_at = Some(current_timestamp_secs()); + } + if let Some(comment) = comments { + signer.comments = Some(comment.to_string()); + } + if let Some(sig_data) = signature_data { + signer.signature_data = Some(sig_data.to_string()); + } + signer_found = true; + break; + } + } + + if !signer_found { + return Err(format!("Signer with ID {} not found", signer_id)); + } + + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Deletes a contract from the database. +pub fn delete_contract(contract_id: u32) -> Result<(), String> { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .map_err(|e| format!("Collection error: {:?}", e))?; + + collection + .delete_by_id(contract_id) + .map_err(|e| format!("Failed to delete contract: {:?}", e))?; + + Ok(()) +} + +/// Gets contracts by status +pub fn get_contracts_by_status(status: ContractStatus) -> Result, String> { + let contracts = get_contracts()?; + let filtered_contracts = contracts + .into_iter() + .filter(|contract| contract.status == status) + .collect(); + Ok(filtered_contracts) +} + +/// Gets contracts by creator +pub fn get_contracts_by_creator(created_by: &str) -> Result, String> { + let contracts = get_contracts()?; + let filtered_contracts = contracts + .into_iter() + .filter(|contract| contract.created_by == created_by) + .collect(); + Ok(filtered_contracts) +} + +/// Gets contracts that need renewal (approaching end date) +pub fn get_contracts_needing_renewal(days_ahead: u64) -> Result, String> { + let contracts = get_contracts()?; + let threshold_timestamp = current_timestamp_secs() + (days_ahead * 24 * 60 * 60); + + let filtered_contracts = contracts + .into_iter() + .filter(|contract| { + if let Some(end_date) = contract.end_date { + end_date <= threshold_timestamp && contract.status == ContractStatus::Active + } else { + false + } + }) + .collect(); + Ok(filtered_contracts) +} + +/// Gets expired contracts +pub fn get_expired_contracts() -> Result, String> { + let contracts = get_contracts()?; + let current_time = current_timestamp_secs(); + + let filtered_contracts = contracts + .into_iter() + .filter(|contract| { + if let Some(end_date) = contract.end_date { + end_date < current_time && contract.status != ContractStatus::Expired + } else { + false + } + }) + .collect(); + Ok(filtered_contracts) +} + +/// Updates multiple contracts to expired status +pub fn mark_contracts_as_expired(contract_ids: Vec) -> Result, String> { + let mut updated_contracts = Vec::new(); + + for contract_id in contract_ids { + match update_contract_status(contract_id, ContractStatus::Expired) { + Ok(contract) => updated_contracts.push(contract), + Err(e) => log::error!("Failed to update contract {}: {}", contract_id, e), + } + } + + Ok(updated_contracts) +} + +/// Updates a signer's reminder timestamp for a contract and returns the updated contract. +pub fn update_signer_reminder_timestamp( + contract_id: u32, + signer_id: &str, + _timestamp: u64, +) -> Result { + let db = get_db().map_err(|e| format!("DB error: {}", e))?; + let collection = db + .collection::() + .expect("can open contract collection"); + + if let Some(mut contract) = collection + .get_by_id(contract_id) + .map_err(|e| format!("Failed to fetch contract: {:?}", e))? + { + let mut signer_found = false; + for signer in &mut contract.signers { + if signer.id == signer_id { + // TODO: Update reminder timestamp when field is available in heromodels + // signer.last_reminder_mail_sent_at = Some(timestamp); + signer_found = true; + break; + } + } + + if !signer_found { + return Err(format!("Signer with ID {} not found", signer_id)); + } + + let (_, updated_contract) = collection + .set(&contract) + .map_err(|e| format!("Failed to update contract: {:?}", e))?; + Ok(updated_contract) + } else { + Err("Contract not found".to_string()) + } +} + +/// Gets contract statistics +pub fn get_contract_statistics() -> Result { + let contracts = get_contracts()?; + + let total = contracts.len(); + let draft = contracts + .iter() + .filter(|c| c.status == ContractStatus::Draft) + .count(); + let pending = contracts + .iter() + .filter(|c| c.status == ContractStatus::PendingSignatures) + .count(); + let signed = contracts + .iter() + .filter(|c| c.status == ContractStatus::Signed) + .count(); + let active = contracts + .iter() + .filter(|c| c.status == ContractStatus::Active) + .count(); + let expired = contracts + .iter() + .filter(|c| c.status == ContractStatus::Expired) + .count(); + let cancelled = contracts + .iter() + .filter(|c| c.status == ContractStatus::Cancelled) + .count(); + + Ok(ContractStatistics { + total_contracts: total, + draft_contracts: draft, + pending_signature_contracts: pending, + signed_contracts: signed, + active_contracts: active, + expired_contracts: expired, + cancelled_contracts: cancelled, + }) +} + +/// A helper for current timestamp (seconds since epoch) +fn current_timestamp_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Contract statistics structure +#[derive(Debug, Clone)] +pub struct ContractStatistics { + pub total_contracts: usize, + pub draft_contracts: usize, + pub pending_signature_contracts: usize, + pub signed_contracts: usize, + pub active_contracts: usize, + pub expired_contracts: usize, + pub cancelled_contracts: usize, +} diff --git a/actix_mvc_app/src/db/governance.rs b/actix_mvc_app/src/db/governance.rs index 2f1f51b..19b86ba 100644 --- a/actix_mvc_app/src/db/governance.rs +++ b/actix_mvc_app/src/db/governance.rs @@ -54,7 +54,7 @@ pub fn get_proposals() -> Result, String> { let proposals = match collection.get_all() { Ok(props) => props, Err(e) => { - eprintln!("Error loading proposals: {:?}", e); + log::error!("Error loading proposals: {:?}", e); vec![] // Return an empty vector if there's an error } }; @@ -70,7 +70,7 @@ pub fn get_proposal_by_id(proposal_id: u32) -> Result, String> match collection.get_by_id(proposal_id) { Ok(proposal) => Ok(Some(proposal.expect("proposal not found"))), Err(e) => { - eprintln!("Error fetching proposal by id {}: {:?}", proposal_id, e); + log::error!("Error fetching proposal by id {}: {:?}", proposal_id, e); Err(format!("Failed to fetch proposal: {:?}", e)) } } diff --git a/actix_mvc_app/src/main.rs b/actix_mvc_app/src/main.rs index 76442e6..b516737 100644 --- a/actix_mvc_app/src/main.rs +++ b/actix_mvc_app/src/main.rs @@ -50,10 +50,18 @@ async fn main() -> io::Result<()> { // Load configuration let config = config::get_config(); - // Check for port override from command line arguments + // Check for port override from environment variable or command line arguments let args: Vec = env::args().collect(); let mut port = config.server.port; + // First check environment variable + if let Ok(env_port) = env::var("PORT") { + if let Ok(p) = env_port.parse::() { + port = p; + } + } + + // Then check command line arguments (takes precedence over env var) for i in 1..args.len() { if args[i] == "--port" && i + 1 < args.len() { if let Ok(p) = args[i + 1].parse::() { @@ -111,6 +119,8 @@ async fn main() -> io::Result<()> { .app_data(web::Data::new(tera)) // Configure routes .configure(routes::configure_routes) + // Add default handler for 404 errors + .default_service(web::route().to(controllers::error::render_generic_not_found)) }) .bind(bind_address)? .workers(num_cpus::get()) diff --git a/actix_mvc_app/src/models/contract.rs b/actix_mvc_app/src/models/contract.rs index e3f3035..dbfaa71 100644 --- a/actix_mvc_app/src/models/contract.rs +++ b/actix_mvc_app/src/models/contract.rs @@ -2,6 +2,96 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Contract activity types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ContractActivityType { + Created, + SignerAdded, + SignerRemoved, + SentForSignatures, + Signed, + Rejected, + StatusChanged, + Revised, +} + +impl ContractActivityType { + pub fn as_str(&self) -> &str { + match self { + ContractActivityType::Created => "Contract Created", + ContractActivityType::SignerAdded => "Signer Added", + ContractActivityType::SignerRemoved => "Signer Removed", + ContractActivityType::SentForSignatures => "Sent for Signatures", + ContractActivityType::Signed => "Contract Signed", + ContractActivityType::Rejected => "Contract Rejected", + ContractActivityType::StatusChanged => "Status Changed", + ContractActivityType::Revised => "Contract Revised", + } + } +} + +/// Contract activity model +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractActivity { + pub id: String, + pub contract_id: u32, + pub activity_type: ContractActivityType, + pub description: String, + pub user_name: String, + pub created_at: DateTime, + pub metadata: Option, +} + +impl ContractActivity { + /// Creates a new contract activity + pub fn new( + contract_id: u32, + activity_type: ContractActivityType, + description: String, + user_name: String, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + contract_id, + activity_type, + description, + user_name, + created_at: Utc::now(), + metadata: None, + } + } + + /// Creates a contract creation activity + pub fn contract_created(contract_id: u32, contract_title: &str, user_name: &str) -> Self { + Self::new( + contract_id, + ContractActivityType::Created, + format!("Created contract '{}'", contract_title), + user_name.to_string(), + ) + } + + /// Creates a signer added activity + pub fn signer_added(contract_id: u32, signer_name: &str, user_name: &str) -> Self { + Self::new( + contract_id, + ContractActivityType::SignerAdded, + format!("Added signer: {}", signer_name), + user_name.to_string(), + ) + } + + /// Creates a sent for signatures activity + pub fn sent_for_signatures(contract_id: u32, signer_count: usize, user_name: &str) -> Self { + Self::new( + contract_id, + ContractActivityType::SentForSignatures, + format!("Sent contract for signatures to {} signer(s)", signer_count), + user_name.to_string(), + ) + } +} + /// Contract status enum #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ContractStatus { @@ -10,7 +100,7 @@ pub enum ContractStatus { Signed, Active, Expired, - Cancelled + Cancelled, } impl ContractStatus { @@ -37,7 +127,7 @@ pub enum ContractType { Distribution, License, Membership, - Other + Other, } impl ContractType { @@ -61,7 +151,7 @@ impl ContractType { pub enum SignerStatus { Pending, Signed, - Rejected + Rejected, } impl SignerStatus { @@ -127,7 +217,12 @@ pub struct ContractRevision { #[allow(dead_code)] impl ContractRevision { /// Creates a new contract revision - pub fn new(version: u32, content: String, created_by: String, comments: Option) -> Self { + pub fn new( + version: u32, + content: String, + created_by: String, + comments: Option, + ) -> Self { Self { version, content, @@ -171,7 +266,13 @@ pub struct Contract { #[allow(dead_code)] impl Contract { /// Creates a new contract - pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option) -> Self { + pub fn new( + title: String, + description: String, + contract_type: ContractType, + created_by: String, + organization_id: Option, + ) -> Self { Self { id: Uuid::new_v4().to_string(), title, @@ -229,7 +330,9 @@ impl Contract { return false; } - self.signers.iter().all(|signer| signer.status == SignerStatus::Signed) + self.signers + .iter() + .all(|signer| signer.status == SignerStatus::Signed) } /// Marks the contract as signed if all signers have signed @@ -261,17 +364,26 @@ impl Contract { /// Gets the number of pending signers pub fn pending_signers_count(&self) -> usize { - self.signers.iter().filter(|s| s.status == SignerStatus::Pending).count() + self.signers + .iter() + .filter(|s| s.status == SignerStatus::Pending) + .count() } /// Gets the number of signed signers pub fn signed_signers_count(&self) -> usize { - self.signers.iter().filter(|s| s.status == SignerStatus::Signed).count() + self.signers + .iter() + .filter(|s| s.status == SignerStatus::Signed) + .count() } /// Gets the number of rejected signers pub fn rejected_signers_count(&self) -> usize { - self.signers.iter().filter(|s| s.status == SignerStatus::Rejected).count() + self.signers + .iter() + .filter(|s| s.status == SignerStatus::Rejected) + .count() } } @@ -299,11 +411,26 @@ impl ContractStatistics { /// Creates new contract statistics from a list of contracts pub fn new(contracts: &[Contract]) -> Self { let total_contracts = contracts.len(); - let draft_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Draft).count(); - let pending_signature_contracts = contracts.iter().filter(|c| c.status == ContractStatus::PendingSignatures).count(); - let signed_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Signed).count(); - let expired_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Expired).count(); - let cancelled_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Cancelled).count(); + let draft_contracts = contracts + .iter() + .filter(|c| c.status == ContractStatus::Draft) + .count(); + let pending_signature_contracts = contracts + .iter() + .filter(|c| c.status == ContractStatus::PendingSignatures) + .count(); + let signed_contracts = contracts + .iter() + .filter(|c| c.status == ContractStatus::Signed) + .count(); + let expired_contracts = contracts + .iter() + .filter(|c| c.status == ContractStatus::Expired) + .count(); + let cancelled_contracts = contracts + .iter() + .filter(|c| c.status == ContractStatus::Cancelled) + .count(); Self { total_contracts, diff --git a/actix_mvc_app/src/routes/mod.rs b/actix_mvc_app/src/routes/mod.rs index d1224a9..286b4ef 100644 --- a/actix_mvc_app/src/routes/mod.rs +++ b/actix_mvc_app/src/routes/mod.rs @@ -127,11 +127,90 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .service( web::scope("/contracts") .route("", web::get().to(ContractController::index)) + .route("/", web::get().to(ContractController::index)) // Handle trailing slash .route("/list", web::get().to(ContractController::list)) - .route("/my", web::get().to(ContractController::my_contracts)) - .route("/{id}", web::get().to(ContractController::detail)) + .route("/list/", web::get().to(ContractController::list)) // Handle trailing slash + .route( + "/my-contracts", + web::get().to(ContractController::my_contracts), + ) + .route( + "/my-contracts/", + web::get().to(ContractController::my_contracts), + ) // Handle trailing slash .route("/create", web::get().to(ContractController::create_form)) - .route("/create", web::post().to(ContractController::create)), + .route("/create/", web::get().to(ContractController::create_form)) // Handle trailing slash + .route("/create", web::post().to(ContractController::create)) + .route("/create/", web::post().to(ContractController::create)) // Handle trailing slash + .route("/statistics", web::get().to(ContractController::statistics)) + .route( + "/activities", + web::get().to(ContractController::all_activities), + ) + .route("/{id}/edit", web::get().to(ContractController::edit_form)) + .route("/{id}/edit", web::post().to(ContractController::update)) + .route( + "/filter/{status}", + web::get().to(ContractController::filter_by_status), + ) + .route("/{id}", web::get().to(ContractController::detail)) + .route( + "/{id}/status/{status}", + web::post().to(ContractController::update_status), + ) + .route("/{id}/delete", web::post().to(ContractController::delete)) + .route( + "/{id}/add-signer", + web::get().to(ContractController::add_signer_form), + ) + .route( + "/{id}/add-signer", + web::post().to(ContractController::add_signer), + ) + .route( + "/{id}/remind", + web::post().to(ContractController::remind_to_sign), + ) + .route( + "/{id}/send", + web::post().to(ContractController::send_for_signatures), + ) + .route( + "/{id}/reminder-status", + web::get().to(ContractController::get_reminder_status), + ) + .route( + "/{id}/add-revision", + web::post().to(ContractController::add_revision), + ) + .route( + "/{id}/signer/{signer_id}/status/{status}", + web::post().to(ContractController::update_signer_status), + ) + .route( + "/{id}/sign/{signer_id}", + web::post().to(ContractController::sign_contract), + ) + .route( + "/{id}/reject/{signer_id}", + web::post().to(ContractController::reject_contract), + ) + .route( + "/{id}/cancel", + web::post().to(ContractController::cancel_contract), + ) + .route( + "/{id}/clone", + web::post().to(ContractController::clone_contract), + ) + .route( + "/{id}/signed/{signer_id}", + web::get().to(ContractController::view_signed_document), + ) + .route( + "/{id}/share", + web::post().to(ContractController::share_contract), + ), ) // Asset routes .service( diff --git a/actix_mvc_app/src/utils/mod.rs b/actix_mvc_app/src/utils/mod.rs index dcc2732..5342488 100644 --- a/actix_mvc_app/src/utils/mod.rs +++ b/actix_mvc_app/src/utils/mod.rs @@ -1,5 +1,6 @@ use actix_web::{Error, HttpResponse}; use chrono::{DateTime, Utc}; +use pulldown_cmark::{Options, Parser, html}; use std::error::Error as StdError; use tera::{self, Context, Function, Tera, Value}; @@ -224,6 +225,26 @@ pub fn truncate_string(s: &str, max_length: usize) -> String { } } +/// Parses markdown content and returns HTML +pub fn parse_markdown(markdown_content: &str) -> String { + // Set up markdown parser options + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_SMART_PUNCTUATION); + + // Create parser + let parser = Parser::new_ext(markdown_content, options); + + // Render to HTML + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + + html_output +} + /// Renders a template with error handling /// /// This function attempts to render a template and handles any errors by rendering diff --git a/actix_mvc_app/src/views/contracts/add_signer.html b/actix_mvc_app/src/views/contracts/add_signer.html new file mode 100644 index 0000000..cdf3b24 --- /dev/null +++ b/actix_mvc_app/src/views/contracts/add_signer.html @@ -0,0 +1,200 @@ +{% extends "base.html" %} + +{% block title %}Add Signer - {{ contract.title }}{% endblock %} + +{% block content %} +
+ +
+
+ +
+
+

Add Signer

+

Add a new signer to "{{ contract.title }}"

+
+ +
+
+
+ +
+ +
+
+
+
+ Signer Information +
+
+
+ {% if error %} + + {% endif %} + +
+
+
+
+ + +
The full legal name of the person who will sign
+
+
+
+
+ + +
Email where signing instructions will be sent
+
+
+
+ +
+
+ + Note: The signer will receive an email with a secure link to sign the contract once you send it for signatures. +
+
+ +
+ + + Cancel + +
+
+
+
+
+ + +
+
+
+
Contract Summary
+
+
+
{{ contract.title }}
+

{{ contract.description }}

+ +
+ +
+
+
+
{{ contract.signers|length }}
+ Current Signers +
+
+
+
{{ contract.signed_signers }}
+ Signed +
+
+
+
+ + + {% if contract.signers|length > 0 %} +
+
+
Current Signers
+
+
+
    + {% for signer in contract.signers %} +
  • +
    +
    {{ signer.name }}
    + {{ signer.email }} +
    + + {{ signer.status }} + +
  • + {% endfor %} +
+
+
+ {% endif %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/actix_mvc_app/src/views/contracts/all_activities.html b/actix_mvc_app/src/views/contracts/all_activities.html new file mode 100644 index 0000000..693456b --- /dev/null +++ b/actix_mvc_app/src/views/contracts/all_activities.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} + +{% block title %}All Contract Activities{% endblock %} + +{% block content %} +
+
+
+ +
+
+

Contract Activities

+

Complete history of contract actions and events across your organization.

+
+
+ + +
+
+
+ Contract Activity History +
+
+
+ {% if activities %} +
+
+
+ + + + + + + + + + + + {% for activity in activities %} + + + + + + + + {% endfor %} + +
TypeUserActionContractDate
+ + + {{ activity.user }} + + {{ activity.action }} + + + {{ activity.contract_title }} + + + + {{ activity.created_at | date(format="%Y-%m-%d %H:%M") }} + +
+
+
+
+ {% else %} +
+ +

No Activities Yet

+

+ Contract activities will appear here as users create contracts and add signers. +

+ + Create First Contract + +
+ {% endif %} +
+
+ + + {% if activities %} +
+
+
+
+
{{ activities | length }}
+

Total Activities

+
+
+
+
+
+
+
+ +
+

Contract Timeline

+
+
+
+
+
+
+
+ +
+

Team Collaboration

+
+
+
+
+ {% endif %} + + + +
+
+
+{% endblock %} diff --git a/actix_mvc_app/src/views/contracts/contract_detail.html b/actix_mvc_app/src/views/contracts/contract_detail.html index 707c947..2594fed 100644 --- a/actix_mvc_app/src/views/contracts/contract_detail.html +++ b/actix_mvc_app/src/views/contracts/contract_detail.html @@ -27,24 +27,28 @@ -