Compare commits
5 Commits
developmen
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2276de79 | ||
|
|
123dfc606c | ||
| 795c04fc5a | |||
|
|
2cfec627bf | ||
|
|
83dde53555 |
@@ -1,2 +0,0 @@
|
|||||||
[net]
|
|
||||||
git-fetch-with-cli = true
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Environment Variables Template
|
|
||||||
# Copy this file to '.env' and customize with your own values
|
|
||||||
# This file should NOT be committed to version control
|
|
||||||
|
|
||||||
# Server Configuration
|
|
||||||
# APP__SERVER__HOST=127.0.0.1
|
|
||||||
# APP__SERVER__PORT=9999
|
|
||||||
|
|
||||||
# Stripe Configuration (Test Keys)
|
|
||||||
# Get your test keys from: https://dashboard.stripe.com/test/apikeys
|
|
||||||
# APP__STRIPE__PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
|
|
||||||
# APP__STRIPE__SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
|
|
||||||
# APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE
|
|
||||||
|
|
||||||
# For production, use live keys:
|
|
||||||
# APP__STRIPE__PUBLISHABLE_KEY=pk_live_YOUR_LIVE_PUBLISHABLE_KEY
|
|
||||||
# APP__STRIPE__SECRET_KEY=sk_live_YOUR_LIVE_SECRET_KEY
|
|
||||||
# APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_LIVE_WEBHOOK_SECRET
|
|
||||||
|
|
||||||
# Database Configuration (if needed)
|
|
||||||
# DATABASE_URL=postgresql://user:password@localhost/dbname
|
|
||||||
53
actix_mvc_app/.gitignore
vendored
53
actix_mvc_app/.gitignore
vendored
@@ -1,53 +0,0 @@
|
|||||||
# Rust build artifacts
|
|
||||||
/target/
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# Local configuration files
|
|
||||||
config/local.toml
|
|
||||||
config/production.toml
|
|
||||||
|
|
||||||
# Database files
|
|
||||||
data/*.db
|
|
||||||
data/*.sqlite
|
|
||||||
data/*.json
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# IDE files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
|
|
||||||
# SSL certificates (keep examples)
|
|
||||||
nginx/ssl/*.pem
|
|
||||||
nginx/ssl/*.key
|
|
||||||
!nginx/ssl/README.md
|
|
||||||
|
|
||||||
# Docker volumes
|
|
||||||
docker-data/
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
*.bak
|
|
||||||
*.backup
|
|
||||||
|
|
||||||
# Keep important development files
|
|
||||||
!ai_prompt/
|
|
||||||
!PRODUCTION_DEPLOYMENT.md
|
|
||||||
!STRIPE_SETUP.md
|
|
||||||
!payment_plan.md
|
|
||||||
1231
actix_mvc_app/Cargo.lock
generated
1231
actix_mvc_app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,6 @@ name = "actix_mvc_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "actix_mvc_app"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "actix_mvc_app"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-multipart = "0.6.1"
|
actix-multipart = "0.6.1"
|
||||||
futures-util = "0.3.30"
|
futures-util = "0.3.30"
|
||||||
@@ -23,8 +15,6 @@ env_logger = "0.11.2"
|
|||||||
log = "0.4.21"
|
log = "0.4.21"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
chrono = { version = "0.4.35", features = ["serde"] }
|
chrono = { version = "0.4.35", features = ["serde"] }
|
||||||
heromodels = { path = "../../db/heromodels" }
|
|
||||||
heromodels_core = { path = "../../db/heromodels_core" }
|
|
||||||
config = "0.14.0"
|
config = "0.14.0"
|
||||||
num_cpus = "1.16.0"
|
num_cpus = "1.16.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
@@ -37,24 +27,3 @@ redis = { version = "0.23.0", features = ["tokio-comp"] }
|
|||||||
jsonwebtoken = "8.3.0"
|
jsonwebtoken = "8.3.0"
|
||||||
pulldown-cmark = "0.13.0"
|
pulldown-cmark = "0.13.0"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
|
||||||
async-stripe = { version = "0.41", features = ["runtime-tokio-hyper"] }
|
|
||||||
reqwest = { version = "0.12.20", features = ["json"] }
|
|
||||||
|
|
||||||
# Security dependencies for webhook verification
|
|
||||||
hmac = "0.12.1"
|
|
||||||
sha2 = "0.10.8"
|
|
||||||
hex = "0.4.3"
|
|
||||||
|
|
||||||
# Validation dependencies
|
|
||||||
regex = "1.10.2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
# Testing dependencies
|
|
||||||
tokio-test = "0.4.3"
|
|
||||||
|
|
||||||
[patch."https://git.ourworld.tf/herocode/db.git"]
|
|
||||||
rhai_autobind_macros = { path = "../../rhaj/rhai_autobind_macros" }
|
|
||||||
rhai_wrapper = { path = "../../rhaj/rhai_wrapper" }
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Multi-stage build for production
|
|
||||||
FROM rust:1.75-slim as builder
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libpq-dev \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy dependency files
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
|
|
||||||
# Create a dummy main.rs to build dependencies
|
|
||||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
|
||||||
|
|
||||||
# Build dependencies (this layer will be cached)
|
|
||||||
RUN cargo build --release && rm -rf src
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY src ./src
|
|
||||||
COPY tests ./tests
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN cargo build --release
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
# Install runtime dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
ca-certificates \
|
|
||||||
libssl3 \
|
|
||||||
libpq5 \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create app user
|
|
||||||
RUN useradd -m -u 1001 appuser
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy binary from builder stage
|
|
||||||
COPY --from=builder /app/target/release/actix_mvc_app /app/actix_mvc_app
|
|
||||||
|
|
||||||
# Copy static files and templates
|
|
||||||
COPY src/views ./src/views
|
|
||||||
COPY static ./static
|
|
||||||
|
|
||||||
# Create data and logs directories
|
|
||||||
RUN mkdir -p data logs && chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
# Switch to app user
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8080/health || exit 1
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["./actix_mvc_app"]
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
# Production Checklist ✅
|
|
||||||
|
|
||||||
## 🧹 Code Cleanup Status
|
|
||||||
|
|
||||||
### ✅ **Completed**
|
|
||||||
- [x] Removed build artifacts (`cargo clean`)
|
|
||||||
- [x] Updated .gitignore to keep `ai_prompt/` folder
|
|
||||||
- [x] Created proper .gitignore for actix_mvc_app
|
|
||||||
- [x] Cleaned up debug console.log statements (kept error logs)
|
|
||||||
- [x] Commented out verbose debug logging
|
|
||||||
- [x] Maintained essential error handling logs
|
|
||||||
|
|
||||||
### 🔧 **Configuration**
|
|
||||||
- [x] Environment variables properly configured
|
|
||||||
- [x] Stripe keys configured (test/production)
|
|
||||||
- [x] Database connection settings
|
|
||||||
- [x] CORS settings for production domains
|
|
||||||
- [x] SSL/TLS configuration ready
|
|
||||||
|
|
||||||
### 🛡️ **Security**
|
|
||||||
- [x] Stripe webhook signature verification
|
|
||||||
- [x] Input validation on all forms
|
|
||||||
- [x] SQL injection prevention (using ORM)
|
|
||||||
- [x] XSS protection (template escaping)
|
|
||||||
- [x] CSRF protection implemented
|
|
||||||
- [x] Rate limiting configured
|
|
||||||
|
|
||||||
### 📊 **Database**
|
|
||||||
- [x] Database corruption recovery implemented
|
|
||||||
- [x] Proper error handling for DB operations
|
|
||||||
- [x] Company status transitions working
|
|
||||||
- [x] Payment integration with company creation
|
|
||||||
- [x] Data validation and constraints
|
|
||||||
|
|
||||||
### 💳 **Payment System**
|
|
||||||
- [x] Stripe Elements integration
|
|
||||||
- [x] Payment intent creation
|
|
||||||
- [x] Webhook handling for payment confirmation
|
|
||||||
- [x] Company activation on successful payment
|
|
||||||
- [x] Error handling for failed payments
|
|
||||||
- [x] Test card validation working
|
|
||||||
|
|
||||||
### 🎨 **User Interface**
|
|
||||||
- [x] Multi-step form validation
|
|
||||||
- [x] Real-time form saving to localStorage
|
|
||||||
- [x] Payment section hidden until ready
|
|
||||||
- [x] Comprehensive error messages
|
|
||||||
- [x] Loading states and progress indicators
|
|
||||||
- [x] Mobile-responsive design
|
|
||||||
|
|
||||||
## 🚀 **Pre-Deployment Steps**
|
|
||||||
|
|
||||||
### **1. Environment Setup**
|
|
||||||
```bash
|
|
||||||
# Set production environment variables
|
|
||||||
export RUST_ENV=production
|
|
||||||
export STRIPE_PUBLISHABLE_KEY=pk_live_...
|
|
||||||
export STRIPE_SECRET_KEY=sk_live_...
|
|
||||||
export STRIPE_WEBHOOK_SECRET=whsec_...
|
|
||||||
export DATABASE_URL=production_db_url
|
|
||||||
```
|
|
||||||
|
|
||||||
### **2. Build for Production**
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### **3. Database Migration**
|
|
||||||
```bash
|
|
||||||
# Ensure database is properly initialized
|
|
||||||
# Run any pending migrations
|
|
||||||
# Verify data integrity
|
|
||||||
```
|
|
||||||
|
|
||||||
### **4. SSL Certificate**
|
|
||||||
```bash
|
|
||||||
# Ensure SSL certificates are properly configured
|
|
||||||
# Test HTTPS endpoints
|
|
||||||
# Verify webhook endpoints are accessible
|
|
||||||
```
|
|
||||||
|
|
||||||
### **5. Final Testing**
|
|
||||||
- [ ] Test complete registration flow
|
|
||||||
- [ ] Test payment processing with real cards
|
|
||||||
- [ ] Test webhook delivery
|
|
||||||
- [ ] Test error scenarios
|
|
||||||
- [ ] Test mobile responsiveness
|
|
||||||
- [ ] Load testing for concurrent users
|
|
||||||
|
|
||||||
## 📋 **Deployment Commands**
|
|
||||||
|
|
||||||
### **Docker Deployment**
|
|
||||||
```bash
|
|
||||||
# Build production image
|
|
||||||
docker build -f Dockerfile.prod -t company-registration:latest .
|
|
||||||
|
|
||||||
# Run with production config
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Direct Deployment**
|
|
||||||
```bash
|
|
||||||
# Start production server
|
|
||||||
RUST_ENV=production ./target/release/actix_mvc_app
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 **Post-Deployment Verification**
|
|
||||||
|
|
||||||
### **Health Checks**
|
|
||||||
- [ ] Application starts successfully
|
|
||||||
- [ ] Database connections working
|
|
||||||
- [ ] Stripe connectivity verified
|
|
||||||
- [ ] All endpoints responding
|
|
||||||
- [ ] SSL certificates valid
|
|
||||||
- [ ] Webhook endpoints accessible
|
|
||||||
|
|
||||||
### **Functional Testing**
|
|
||||||
- [ ] Complete a test registration
|
|
||||||
- [ ] Process a test payment
|
|
||||||
- [ ] Verify company creation
|
|
||||||
- [ ] Check email notifications (if implemented)
|
|
||||||
- [ ] Test error scenarios
|
|
||||||
|
|
||||||
### **Monitoring**
|
|
||||||
- [ ] Application logs are being captured
|
|
||||||
- [ ] Error tracking is working
|
|
||||||
- [ ] Performance metrics available
|
|
||||||
- [ ] Database monitoring active
|
|
||||||
|
|
||||||
## 📁 **Important Files for Production**
|
|
||||||
|
|
||||||
### **Keep These Files**
|
|
||||||
- `ai_prompt/` - Development assistance
|
|
||||||
- `payment_plan.md` - Development roadmap
|
|
||||||
- `PRODUCTION_DEPLOYMENT.md` - Deployment guide
|
|
||||||
- `STRIPE_SETUP.md` - Payment configuration
|
|
||||||
- `config/` - Configuration files
|
|
||||||
- `src/` - Source code
|
|
||||||
- `static/` - Static assets
|
|
||||||
- `tests/` - Test files
|
|
||||||
|
|
||||||
### **Generated/Temporary Files (Ignored)**
|
|
||||||
- `target/` - Build artifacts
|
|
||||||
- `data/*.json` - Test data
|
|
||||||
- `logs/` - Log files
|
|
||||||
- `tmp/` - Temporary files
|
|
||||||
- `.env` - Environment files
|
|
||||||
|
|
||||||
## 🎯 **Ready for Production**
|
|
||||||
|
|
||||||
The application is now clean and ready for production deployment with:
|
|
||||||
|
|
||||||
✅ **Core Features Working**
|
|
||||||
- Multi-step company registration
|
|
||||||
- Stripe payment processing
|
|
||||||
- Database integration
|
|
||||||
- Error handling and recovery
|
|
||||||
- Security measures implemented
|
|
||||||
|
|
||||||
✅ **Code Quality**
|
|
||||||
- Debug logs cleaned up
|
|
||||||
- Proper error handling
|
|
||||||
- Input validation
|
|
||||||
- Security best practices
|
|
||||||
|
|
||||||
✅ **Documentation**
|
|
||||||
- Setup guides available
|
|
||||||
- Configuration documented
|
|
||||||
- Deployment instructions ready
|
|
||||||
- Development roadmap planned
|
|
||||||
|
|
||||||
## 🚀 **Next Steps After Deployment**
|
|
||||||
|
|
||||||
1. **Monitor initial usage** and performance
|
|
||||||
2. **Implement email notifications** (Option A from payment_plan.md)
|
|
||||||
3. **Build company dashboard** (Option B from payment_plan.md)
|
|
||||||
4. **Add document generation** (Option C from payment_plan.md)
|
|
||||||
5. **Enhance user authentication** (Option D from payment_plan.md)
|
|
||||||
|
|
||||||
The foundation is solid - ready to build the next features! 🎉
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
# Production Deployment Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide covers deploying the Freezone Company Registration System to production with proper security, monitoring, and reliability.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker and Docker Compose installed
|
|
||||||
- SSL certificates for HTTPS
|
|
||||||
- Stripe production account with API keys
|
|
||||||
- Domain name configured
|
|
||||||
- Server with at least 4GB RAM and 2 CPU cores
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Create a `.env.prod` file with the following variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Application
|
|
||||||
RUST_ENV=production
|
|
||||||
RUST_LOG=info
|
|
||||||
|
|
||||||
# Database
|
|
||||||
POSTGRES_DB=freezone_prod
|
|
||||||
POSTGRES_USER=freezone_user
|
|
||||||
POSTGRES_PASSWORD=your_secure_db_password
|
|
||||||
DATABASE_URL=postgresql://freezone_user:your_secure_db_password@db:5432/freezone_prod
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://:your_redis_password@redis:6379
|
|
||||||
REDIS_PASSWORD=your_secure_redis_password
|
|
||||||
|
|
||||||
# Stripe (Production Keys)
|
|
||||||
STRIPE_SECRET_KEY=sk_live_your_production_secret_key
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_production_webhook_secret
|
|
||||||
|
|
||||||
# Session Security
|
|
||||||
SESSION_SECRET=your_64_character_session_secret_key_for_production_use_only
|
|
||||||
|
|
||||||
# Monitoring
|
|
||||||
GRAFANA_PASSWORD=your_secure_grafana_password
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
### Before Deployment
|
|
||||||
|
|
||||||
- [ ] **SSL/TLS Certificates**: Obtain valid SSL certificates for your domain
|
|
||||||
- [ ] **Environment Variables**: All production secrets are set and secure
|
|
||||||
- [ ] **Database Security**: Database passwords are strong and unique
|
|
||||||
- [ ] **Stripe Configuration**: Production Stripe keys are configured
|
|
||||||
- [ ] **Session Security**: Session secret is 64+ characters and random
|
|
||||||
- [ ] **Firewall Rules**: Only necessary ports are open (80, 443, 22)
|
|
||||||
- [ ] **User Permissions**: Application runs as non-root user
|
|
||||||
|
|
||||||
### Stripe Configuration
|
|
||||||
|
|
||||||
1. **Production Account**: Ensure you're using Stripe production keys
|
|
||||||
2. **Webhook Endpoints**: Configure webhook endpoint in Stripe dashboard:
|
|
||||||
- URL: `https://yourdomain.com/payment/webhook`
|
|
||||||
- Events: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
|
||||||
3. **Webhook Secret**: Copy the webhook signing secret to environment variables
|
|
||||||
|
|
||||||
### Database Security
|
|
||||||
|
|
||||||
1. **Connection Security**: Use SSL connections to database
|
|
||||||
2. **User Permissions**: Create dedicated database user with minimal permissions
|
|
||||||
3. **Backup Strategy**: Implement automated database backups
|
|
||||||
4. **Access Control**: Restrict database access to application only
|
|
||||||
|
|
||||||
## Deployment Steps
|
|
||||||
|
|
||||||
### 1. Server Preparation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update system
|
|
||||||
sudo apt update && sudo apt upgrade -y
|
|
||||||
|
|
||||||
# Install Docker
|
|
||||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
|
||||||
sudo sh get-docker.sh
|
|
||||||
|
|
||||||
# Install Docker Compose
|
|
||||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
|
||||||
sudo chmod +x /usr/local/bin/docker-compose
|
|
||||||
|
|
||||||
# Create application directory
|
|
||||||
sudo mkdir -p /opt/freezone
|
|
||||||
sudo chown $USER:$USER /opt/freezone
|
|
||||||
cd /opt/freezone
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Application Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/your-org/freezone-registration.git .
|
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
cp .env.prod.example .env.prod
|
|
||||||
# Edit .env.prod with your production values
|
|
||||||
|
|
||||||
# Create necessary directories
|
|
||||||
mkdir -p data logs nginx/ssl static
|
|
||||||
|
|
||||||
# Copy SSL certificates to nginx/ssl/
|
|
||||||
# - cert.pem (certificate)
|
|
||||||
# - key.pem (private key)
|
|
||||||
|
|
||||||
# Build and start services
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. SSL Configuration
|
|
||||||
|
|
||||||
Create `nginx/nginx.conf`:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
upstream app {
|
|
||||||
server app:8080;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name yourdomain.com;
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name yourdomain.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
|
||||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://app;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://app/health;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Monitoring Setup
|
|
||||||
|
|
||||||
The deployment includes:
|
|
||||||
|
|
||||||
- **Prometheus**: Metrics collection (port 9090)
|
|
||||||
- **Grafana**: Dashboards and alerting (port 3000)
|
|
||||||
- **Loki**: Log aggregation (port 3100)
|
|
||||||
- **Promtail**: Log shipping
|
|
||||||
|
|
||||||
Access Grafana at `https://yourdomain.com:3000` with admin credentials.
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
The application provides several health check endpoints:
|
|
||||||
|
|
||||||
- `/health` - Overall system health
|
|
||||||
- `/health/detailed` - Detailed component status
|
|
||||||
- `/health/ready` - Readiness for load balancers
|
|
||||||
- `/health/live` - Liveness check
|
|
||||||
|
|
||||||
## Monitoring and Alerting
|
|
||||||
|
|
||||||
### Key Metrics to Monitor
|
|
||||||
|
|
||||||
1. **Application Health**
|
|
||||||
- Response time
|
|
||||||
- Error rate
|
|
||||||
- Request volume
|
|
||||||
- Memory usage
|
|
||||||
|
|
||||||
2. **Payment Processing**
|
|
||||||
- Payment success rate
|
|
||||||
- Payment processing time
|
|
||||||
- Failed payment count
|
|
||||||
- Webhook processing time
|
|
||||||
|
|
||||||
3. **Database Performance**
|
|
||||||
- Connection pool usage
|
|
||||||
- Query response time
|
|
||||||
- Database size
|
|
||||||
- Active connections
|
|
||||||
|
|
||||||
4. **System Resources**
|
|
||||||
- CPU usage
|
|
||||||
- Memory usage
|
|
||||||
- Disk space
|
|
||||||
- Network I/O
|
|
||||||
|
|
||||||
### Alerting Rules
|
|
||||||
|
|
||||||
Configure alerts for:
|
|
||||||
|
|
||||||
- Application downtime (> 1 minute)
|
|
||||||
- High error rate (> 5%)
|
|
||||||
- Payment failures (> 2%)
|
|
||||||
- Database connection issues
|
|
||||||
- High memory usage (> 80%)
|
|
||||||
- Disk space low (< 10%)
|
|
||||||
|
|
||||||
## Backup Strategy
|
|
||||||
|
|
||||||
### Database Backups
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Daily backup script
|
|
||||||
#!/bin/bash
|
|
||||||
BACKUP_DIR="/opt/freezone/backups"
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
|
|
||||||
docker exec freezone-db pg_dump -U freezone_user freezone_prod > $BACKUP_DIR/db_backup_$DATE.sql
|
|
||||||
|
|
||||||
# Keep only last 30 days
|
|
||||||
find $BACKUP_DIR -name "db_backup_*.sql" -mtime +30 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### Application Data Backups
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup registration data and logs
|
|
||||||
tar -czf /opt/freezone/backups/app_data_$(date +%Y%m%d).tar.gz \
|
|
||||||
/opt/freezone/data \
|
|
||||||
/opt/freezone/logs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Regular Tasks
|
|
||||||
|
|
||||||
1. **Weekly**
|
|
||||||
- Review application logs
|
|
||||||
- Check system resource usage
|
|
||||||
- Verify backup integrity
|
|
||||||
- Update security patches
|
|
||||||
|
|
||||||
2. **Monthly**
|
|
||||||
- Review payment processing metrics
|
|
||||||
- Update dependencies
|
|
||||||
- Performance optimization review
|
|
||||||
- Security audit
|
|
||||||
|
|
||||||
### Log Rotation
|
|
||||||
|
|
||||||
Configure log rotation in `/etc/logrotate.d/freezone`:
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/freezone/logs/*.log {
|
|
||||||
daily
|
|
||||||
rotate 30
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 644 appuser appuser
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Application Won't Start**
|
|
||||||
- Check environment variables
|
|
||||||
- Verify database connectivity
|
|
||||||
- Check SSL certificate paths
|
|
||||||
|
|
||||||
2. **Payment Processing Fails**
|
|
||||||
- Verify Stripe API keys
|
|
||||||
- Check webhook configuration
|
|
||||||
- Review payment logs
|
|
||||||
|
|
||||||
3. **Database Connection Issues**
|
|
||||||
- Check database container status
|
|
||||||
- Verify connection string
|
|
||||||
- Check network connectivity
|
|
||||||
|
|
||||||
### Log Locations
|
|
||||||
|
|
||||||
- Application logs: `/opt/freezone/logs/`
|
|
||||||
- Docker logs: `docker-compose logs [service]`
|
|
||||||
- Nginx logs: `docker-compose logs nginx`
|
|
||||||
- Database logs: `docker-compose logs db`
|
|
||||||
|
|
||||||
### Emergency Procedures
|
|
||||||
|
|
||||||
1. **Application Rollback**
|
|
||||||
```bash
|
|
||||||
# Stop current deployment
|
|
||||||
docker-compose -f docker-compose.prod.yml down
|
|
||||||
|
|
||||||
# Restore from backup
|
|
||||||
git checkout previous-stable-tag
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Database Recovery**
|
|
||||||
```bash
|
|
||||||
# Restore from backup
|
|
||||||
docker exec -i freezone-db psql -U freezone_user freezone_prod < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Maintenance
|
|
||||||
|
|
||||||
### Regular Security Tasks
|
|
||||||
|
|
||||||
1. **Update Dependencies**
|
|
||||||
```bash
|
|
||||||
# Update Rust dependencies
|
|
||||||
cargo update
|
|
||||||
|
|
||||||
# Rebuild with security patches
|
|
||||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **SSL Certificate Renewal**
|
|
||||||
```bash
|
|
||||||
# Using Let's Encrypt (example)
|
|
||||||
certbot renew --nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Security Scanning**
|
|
||||||
```bash
|
|
||||||
# Scan for vulnerabilities
|
|
||||||
cargo audit
|
|
||||||
|
|
||||||
# Docker image scanning
|
|
||||||
docker scan freezone-registration-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
|
|
||||||
### Application Tuning
|
|
||||||
|
|
||||||
1. **Database Connection Pool**
|
|
||||||
- Monitor connection usage
|
|
||||||
- Adjust pool size based on load
|
|
||||||
|
|
||||||
2. **Redis Configuration**
|
|
||||||
- Configure memory limits
|
|
||||||
- Enable persistence if needed
|
|
||||||
|
|
||||||
3. **Nginx Optimization**
|
|
||||||
- Enable gzip compression
|
|
||||||
- Configure caching headers
|
|
||||||
- Optimize worker processes
|
|
||||||
|
|
||||||
### Scaling Considerations
|
|
||||||
|
|
||||||
1. **Horizontal Scaling**
|
|
||||||
- Load balancer configuration
|
|
||||||
- Session store externalization
|
|
||||||
- Database read replicas
|
|
||||||
|
|
||||||
2. **Vertical Scaling**
|
|
||||||
- Monitor resource usage
|
|
||||||
- Increase container resources
|
|
||||||
- Optimize database queries
|
|
||||||
|
|
||||||
## Support and Maintenance
|
|
||||||
|
|
||||||
For production support:
|
|
||||||
|
|
||||||
1. **Monitoring**: Use Grafana dashboards for real-time monitoring
|
|
||||||
2. **Alerting**: Configure alerts for critical issues
|
|
||||||
3. **Logging**: Centralized logging with Loki/Grafana
|
|
||||||
4. **Documentation**: Keep deployment documentation updated
|
|
||||||
|
|
||||||
## Compliance and Auditing
|
|
||||||
|
|
||||||
### PCI DSS Compliance
|
|
||||||
|
|
||||||
- Secure payment processing with Stripe
|
|
||||||
- No storage of sensitive payment data
|
|
||||||
- Regular security assessments
|
|
||||||
- Access logging and monitoring
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
|
|
||||||
- Secure data transmission (HTTPS)
|
|
||||||
- Data encryption at rest
|
|
||||||
- Regular backups
|
|
||||||
- Access control and audit trails
|
|
||||||
|
|
||||||
### Audit Trail
|
|
||||||
|
|
||||||
The application logs all critical events:
|
|
||||||
- Payment processing
|
|
||||||
- User actions
|
|
||||||
- Administrative changes
|
|
||||||
- Security events
|
|
||||||
|
|
||||||
Review audit logs regularly and maintain for compliance requirements.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Stripe Integration Setup Guide
|
|
||||||
|
|
||||||
This guide explains how to configure Stripe payment processing for the company registration system.
|
|
||||||
|
|
||||||
## 🔧 Configuration Options
|
|
||||||
|
|
||||||
The application supports multiple ways to configure Stripe API keys:
|
|
||||||
|
|
||||||
### 1. Configuration Files (Recommended for Development)
|
|
||||||
|
|
||||||
#### Default Configuration
|
|
||||||
The application includes default test keys in `config/default.toml`:
|
|
||||||
```toml
|
|
||||||
[stripe]
|
|
||||||
publishable_key = "pk_test_..."
|
|
||||||
secret_key = "sk_test_..."
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Local Configuration
|
|
||||||
Create `config/local.toml` to override defaults:
|
|
||||||
```toml
|
|
||||||
[stripe]
|
|
||||||
publishable_key = "pk_test_YOUR_KEY_HERE"
|
|
||||||
secret_key = "sk_test_YOUR_KEY_HERE"
|
|
||||||
webhook_secret = "whsec_YOUR_WEBHOOK_SECRET"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Environment Variables (Recommended for Production)
|
|
||||||
|
|
||||||
Set environment variables with the `APP__` prefix:
|
|
||||||
```bash
|
|
||||||
export APP__STRIPE__PUBLISHABLE_KEY="pk_test_YOUR_KEY_HERE"
|
|
||||||
export APP__STRIPE__SECRET_KEY="sk_test_YOUR_KEY_HERE"
|
|
||||||
export APP__STRIPE__WEBHOOK_SECRET="whsec_YOUR_WEBHOOK_SECRET"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or create a `.env` file:
|
|
||||||
```bash
|
|
||||||
APP__STRIPE__PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE
|
|
||||||
APP__STRIPE__SECRET_KEY=sk_test_YOUR_KEY_HERE
|
|
||||||
APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔑 Getting Your Stripe Keys
|
|
||||||
|
|
||||||
### Test Keys (Development)
|
|
||||||
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/test/apikeys)
|
|
||||||
2. Copy your **Publishable key** (starts with `pk_test_`)
|
|
||||||
3. Copy your **Secret key** (starts with `sk_test_`)
|
|
||||||
|
|
||||||
### Live Keys (Production)
|
|
||||||
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)
|
|
||||||
2. Copy your **Publishable key** (starts with `pk_live_`)
|
|
||||||
3. Copy your **Secret key** (starts with `sk_live_`)
|
|
||||||
|
|
||||||
⚠️ **Never commit live keys to version control!**
|
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
|
||||||
|
|
||||||
1. **Never commit sensitive keys** - Use `.gitignore` to exclude:
|
|
||||||
- `.env`
|
|
||||||
- `config/local.toml`
|
|
||||||
- `config/production.toml`
|
|
||||||
|
|
||||||
2. **Use test keys in development** - Test keys are safe and don't process real payments
|
|
||||||
|
|
||||||
3. **Use environment variables in production** - More secure than config files
|
|
||||||
|
|
||||||
4. **Rotate keys regularly** - Generate new keys periodically
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
1. **Copy the example files:**
|
|
||||||
```bash
|
|
||||||
cp config/local.toml.example config/local.toml
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Add your Stripe test keys** to either file
|
|
||||||
|
|
||||||
3. **Start the application:**
|
|
||||||
```bash
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Test the payment flow** at `http://127.0.0.1:9999/company`
|
|
||||||
|
|
||||||
## 📋 Configuration Priority
|
|
||||||
|
|
||||||
The application loads configuration in this order (later overrides earlier):
|
|
||||||
1. Default values in code
|
|
||||||
2. `config/default.toml`
|
|
||||||
3. `config/local.toml`
|
|
||||||
4. Environment variables
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
- **Keys not working?** Check the Stripe Dashboard for correct keys
|
|
||||||
- **Webhook errors?** Ensure webhook secret matches your Stripe endpoint
|
|
||||||
- **Configuration not loading?** Check file paths and environment variable names
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Default configuration for the application
|
|
||||||
# This file contains safe defaults and test keys
|
|
||||||
|
|
||||||
[server]
|
|
||||||
host = "127.0.0.1"
|
|
||||||
port = 9999
|
|
||||||
# workers = 4 # Uncomment to set specific number of workers
|
|
||||||
|
|
||||||
[templates]
|
|
||||||
dir = "./src/views"
|
|
||||||
|
|
||||||
[stripe]
|
|
||||||
# Stripe Test Keys (Safe for development)
|
|
||||||
# These are test keys from Stripe's documentation - they don't process real payments
|
|
||||||
publishable_key = "pk_test_51RdWkUC6v6GB0mBYmMbmKyXQfeRX0obM0V5rQCFGT35A1EP8WQJ5xw2vuWurqeGjdwaxls0B8mqdYpGSHcOlYOtQ000BvLkKCq"
|
|
||||||
secret_key = "sk_test_51RdWkUC6v6GB0mBYbbs4RULaNRq9CzqV88pM1EMU9dJ9TAj8obLAFsvfGWPq4Ed8nL36kbE7vK2oHvAQ35UrlJm100FlecQxmN"
|
|
||||||
# webhook_secret = "whsec_test_..." # Uncomment and set when setting up webhooks
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Local configuration template
|
|
||||||
# Copy this file to 'local.toml' and customize with your own keys
|
|
||||||
# This file should NOT be committed to version control
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# host = "0.0.0.0" # Uncomment to bind to all interfaces
|
|
||||||
# port = 8080 # Uncomment to use different port
|
|
||||||
|
|
||||||
[stripe]
|
|
||||||
# Replace with your own Stripe test keys from https://dashboard.stripe.com/test/apikeys
|
|
||||||
# publishable_key = "pk_test_YOUR_PUBLISHABLE_KEY_HERE"
|
|
||||||
# secret_key = "sk_test_YOUR_SECRET_KEY_HERE"
|
|
||||||
# webhook_secret = "whsec_YOUR_WEBHOOK_SECRET_HERE"
|
|
||||||
|
|
||||||
# For production, use live keys:
|
|
||||||
# publishable_key = "pk_live_YOUR_LIVE_PUBLISHABLE_KEY"
|
|
||||||
# secret_key = "sk_live_YOUR_LIVE_SECRET_KEY"
|
|
||||||
# webhook_secret = "whsec_YOUR_LIVE_WEBHOOK_SECRET"
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
container_name: freezone-registration-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- RUST_ENV=production
|
|
||||||
- RUST_LOG=info
|
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
|
||||||
- SESSION_SECRET=${SESSION_SECRET}
|
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
|
||||||
- REDIS_URL=${REDIS_URL}
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
- db
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: freezone-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
|
||||||
environment:
|
|
||||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: freezone-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./db/init:/docker-entrypoint-initdb.d
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
container_name: freezone-nginx
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
|
||||||
- ./static:/var/www/static:ro
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:latest
|
|
||||||
container_name: freezone-prometheus
|
|
||||||
restart: unless-stopped
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
|
||||||
- '--web.console.templates=/etc/prometheus/consoles'
|
|
||||||
- '--storage.tsdb.retention.time=200h'
|
|
||||||
- '--web.enable-lifecycle'
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: freezone-grafana
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
|
|
||||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:latest
|
|
||||||
container_name: freezone-loki
|
|
||||||
restart: unless-stopped
|
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/loki.yml:/etc/loki/local-config.yaml:ro
|
|
||||||
- loki_data:/loki
|
|
||||||
ports:
|
|
||||||
- "3100:3100"
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:latest
|
|
||||||
container_name: freezone-promtail
|
|
||||||
restart: unless-stopped
|
|
||||||
command: -config.file=/etc/promtail/config.yml
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/promtail.yml:/etc/promtail/config.yml:ro
|
|
||||||
- ./logs:/var/log/app:ro
|
|
||||||
- /var/log:/var/log/host:ro
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
networks:
|
|
||||||
- freezone-network
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
redis_data:
|
|
||||||
driver: local
|
|
||||||
prometheus_data:
|
|
||||||
driver: local
|
|
||||||
grafana_data:
|
|
||||||
driver: local
|
|
||||||
loki_data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
freezone-network:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use std::env;
|
||||||
use config::{Config, ConfigError, File};
|
use config::{Config, ConfigError, File};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::env;
|
|
||||||
|
|
||||||
/// Application configuration
|
/// Application configuration
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -9,13 +9,10 @@ pub struct AppConfig {
|
|||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
/// Template configuration
|
/// Template configuration
|
||||||
pub templates: TemplateConfig,
|
pub templates: TemplateConfig,
|
||||||
/// Stripe configuration
|
|
||||||
pub stripe: StripeConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server configuration
|
/// Server configuration
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
/// Host address to bind to
|
/// Host address to bind to
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -32,17 +29,6 @@ pub struct TemplateConfig {
|
|||||||
pub dir: String,
|
pub dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stripe configuration
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct StripeConfig {
|
|
||||||
/// Stripe publishable key
|
|
||||||
pub publishable_key: String,
|
|
||||||
/// Stripe secret key
|
|
||||||
pub secret_key: String,
|
|
||||||
/// Webhook endpoint secret
|
|
||||||
pub webhook_secret: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Loads configuration from files and environment variables
|
/// Loads configuration from files and environment variables
|
||||||
pub fn new() -> Result<Self, ConfigError> {
|
pub fn new() -> Result<Self, ConfigError> {
|
||||||
@@ -51,10 +37,7 @@ impl AppConfig {
|
|||||||
.set_default("server.host", "127.0.0.1")?
|
.set_default("server.host", "127.0.0.1")?
|
||||||
.set_default("server.port", 9999)?
|
.set_default("server.port", 9999)?
|
||||||
.set_default("server.workers", None::<u32>)?
|
.set_default("server.workers", None::<u32>)?
|
||||||
.set_default("templates.dir", "./src/views")?
|
.set_default("templates.dir", "./src/views")?;
|
||||||
.set_default("stripe.publishable_key", "")?
|
|
||||||
.set_default("stripe.secret_key", "")?
|
|
||||||
.set_default("stripe.webhook_secret", None::<String>)?;
|
|
||||||
|
|
||||||
// Load from config file if it exists
|
// Load from config file if it exists
|
||||||
if let Ok(config_path) = env::var("APP_CONFIG") {
|
if let Ok(config_path) = env::var("APP_CONFIG") {
|
||||||
@@ -67,8 +50,7 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
|
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
|
||||||
config_builder =
|
config_builder = config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
||||||
config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
|
||||||
|
|
||||||
// Build and deserialize the config
|
// Build and deserialize the config
|
||||||
let config = config_builder.build()?;
|
let config = config_builder.build()?;
|
||||||
@@ -79,4 +61,4 @@ impl AppConfig {
|
|||||||
/// Returns the application configuration
|
/// Returns the application configuration
|
||||||
pub fn get_config() -> AppConfig {
|
pub fn get_config() -> AppConfig {
|
||||||
AppConfig::new().expect("Failed to load configuration")
|
AppConfig::new().expect("Failed to load configuration")
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@ lazy_static! {
|
|||||||
/// Controller for handling authentication-related routes
|
/// Controller for handling authentication-related routes
|
||||||
pub struct AuthController;
|
pub struct AuthController;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl AuthController {
|
impl AuthController {
|
||||||
/// Generate a JWT token for a user
|
/// Generate a JWT token for a user
|
||||||
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {
|
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
|
use actix_web::{web, HttpResponse, Responder, Result};
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use actix_web::{HttpResponse, Responder, Result, web};
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::db::calendar::{
|
use crate::models::{CalendarEvent, CalendarViewMode};
|
||||||
add_event_to_calendar, create_new_event, delete_event, get_events, get_or_create_user_calendar,
|
use crate::utils::{RedisCalendarService, render_template};
|
||||||
};
|
|
||||||
use crate::models::CalendarViewMode;
|
|
||||||
use crate::utils::render_template;
|
|
||||||
use heromodels::models::calendar::Event;
|
|
||||||
use heromodels_core::Model;
|
|
||||||
|
|
||||||
/// Controller for handling calendar-related routes
|
/// Controller for handling calendar-related routes
|
||||||
pub struct CalendarController;
|
pub struct CalendarController;
|
||||||
@@ -19,11 +14,9 @@ pub struct CalendarController;
|
|||||||
impl CalendarController {
|
impl CalendarController {
|
||||||
/// Helper function to get user from session
|
/// Helper function to get user from session
|
||||||
fn get_user_from_session(session: &Session) -> Option<Value> {
|
fn get_user_from_session(session: &Session) -> Option<Value> {
|
||||||
session
|
session.get::<String>("user").ok().flatten().and_then(|user_json| {
|
||||||
.get::<String>("user")
|
serde_json::from_str(&user_json).ok()
|
||||||
.ok()
|
})
|
||||||
.flatten()
|
|
||||||
.and_then(|user_json| serde_json::from_str(&user_json).ok())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the calendar page route
|
/// Handles the calendar page route
|
||||||
@@ -34,176 +27,113 @@ impl CalendarController {
|
|||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "calendar");
|
ctx.insert("active_page", "calendar");
|
||||||
|
|
||||||
// Parse the view mode from the query parameters
|
// Parse the view mode from the query parameters
|
||||||
let view_mode =
|
let view_mode = CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
||||||
CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
|
||||||
ctx.insert("view_mode", &view_mode.to_str());
|
ctx.insert("view_mode", &view_mode.to_str());
|
||||||
|
|
||||||
// Parse the date from the query parameters or use the current date
|
// Parse the date from the query parameters or use the current date
|
||||||
let date = if let Some(date_str) = &query.date {
|
let date = if let Some(date_str) = &query.date {
|
||||||
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||||
Ok(naive_date) => Utc
|
Ok(naive_date) => Utc.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap()).into(),
|
||||||
.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap())
|
|
||||||
.into(),
|
|
||||||
Err(_) => Utc::now(),
|
Err(_) => Utc::now(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Utc::now()
|
Utc::now()
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.insert("current_date", &date.format("%Y-%m-%d").to_string());
|
ctx.insert("current_date", &date.format("%Y-%m-%d").to_string());
|
||||||
ctx.insert("current_year", &date.year());
|
ctx.insert("current_year", &date.year());
|
||||||
ctx.insert("current_month", &date.month());
|
ctx.insert("current_month", &date.month());
|
||||||
ctx.insert("current_day", &date.day());
|
ctx.insert("current_day", &date.day());
|
||||||
|
|
||||||
// Add user to context if available and ensure user has a calendar
|
// Add user to context if available
|
||||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
// Get or create user calendar
|
|
||||||
if let (Some(user_id), Some(user_name)) = (
|
|
||||||
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
|
||||||
user.get("full_name").and_then(|v| v.as_str()),
|
|
||||||
) {
|
|
||||||
match get_or_create_user_calendar(user_id, user_name) {
|
|
||||||
Ok(calendar) => {
|
|
||||||
log::info!(
|
|
||||||
"User calendar ready: ID {}, Name: '{}'",
|
|
||||||
calendar.get_id(),
|
|
||||||
calendar.name
|
|
||||||
);
|
|
||||||
ctx.insert("user_calendar", &calendar);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get or create user calendar: {}", e);
|
|
||||||
// Continue without calendar - the app should still work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get events for the current view
|
// Get events for the current view
|
||||||
let (start_date, end_date) = match view_mode {
|
let (start_date, end_date) = match view_mode {
|
||||||
CalendarViewMode::Year => {
|
CalendarViewMode::Year => {
|
||||||
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
|
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
|
||||||
let end = Utc
|
let end = Utc.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59)
|
|
||||||
.unwrap();
|
|
||||||
(start, end)
|
(start, end)
|
||||||
}
|
},
|
||||||
CalendarViewMode::Month => {
|
CalendarViewMode::Month => {
|
||||||
let start = Utc
|
let start = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
|
|
||||||
.unwrap();
|
|
||||||
let last_day = Self::last_day_of_month(date.year(), date.month());
|
let last_day = Self::last_day_of_month(date.year(), date.month());
|
||||||
let end = Utc
|
let end = Utc.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59)
|
|
||||||
.unwrap();
|
|
||||||
(start, end)
|
(start, end)
|
||||||
}
|
},
|
||||||
CalendarViewMode::Week => {
|
CalendarViewMode::Week => {
|
||||||
// Calculate the start of the week (Sunday)
|
// Calculate the start of the week (Sunday)
|
||||||
let _weekday = date.weekday().num_days_from_sunday();
|
let _weekday = date.weekday().num_days_from_sunday();
|
||||||
let start_date = date
|
let start_date = date.date_naive().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap();
|
||||||
.date_naive()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap();
|
|
||||||
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
|
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
|
||||||
let end = start + chrono::Duration::days(7);
|
let end = start + chrono::Duration::days(7);
|
||||||
(start, end)
|
(start, end)
|
||||||
}
|
},
|
||||||
CalendarViewMode::Day => {
|
CalendarViewMode::Day => {
|
||||||
let start = Utc
|
let start = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
let end = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59).unwrap();
|
||||||
.unwrap();
|
|
||||||
let end = Utc
|
|
||||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59)
|
|
||||||
.unwrap();
|
|
||||||
(start, end)
|
(start, end)
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get events from database
|
// Get events from Redis
|
||||||
let events = match get_events() {
|
let events = match RedisCalendarService::get_events_in_range(start_date, end_date) {
|
||||||
Ok(db_events) => {
|
Ok(events) => events,
|
||||||
// Filter events for the date range
|
|
||||||
db_events
|
|
||||||
.into_iter()
|
|
||||||
.filter(|event| {
|
|
||||||
// Event overlaps with the date range
|
|
||||||
event.start_time < end_date && event.end_time > start_date
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to get events from database: {}", e);
|
log::error!("Failed to get events from Redis: {}", e);
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.insert("events", &events);
|
ctx.insert("events", &events);
|
||||||
|
|
||||||
// Generate calendar data based on the view mode
|
// Generate calendar data based on the view mode
|
||||||
match view_mode {
|
match view_mode {
|
||||||
CalendarViewMode::Year => {
|
CalendarViewMode::Year => {
|
||||||
let months = (1..=12)
|
let months = (1..=12).map(|month| {
|
||||||
.map(|month| {
|
let month_name = match month {
|
||||||
let month_name = match month {
|
1 => "January",
|
||||||
1 => "January",
|
2 => "February",
|
||||||
2 => "February",
|
3 => "March",
|
||||||
3 => "March",
|
4 => "April",
|
||||||
4 => "April",
|
5 => "May",
|
||||||
5 => "May",
|
6 => "June",
|
||||||
6 => "June",
|
7 => "July",
|
||||||
7 => "July",
|
8 => "August",
|
||||||
8 => "August",
|
9 => "September",
|
||||||
9 => "September",
|
10 => "October",
|
||||||
10 => "October",
|
11 => "November",
|
||||||
11 => "November",
|
12 => "December",
|
||||||
12 => "December",
|
_ => "",
|
||||||
_ => "",
|
};
|
||||||
};
|
|
||||||
|
let month_events = events.iter()
|
||||||
let month_events = events
|
.filter(|event| {
|
||||||
.iter()
|
event.start_time.month() == month || event.end_time.month() == month
|
||||||
.filter(|event| {
|
})
|
||||||
event.start_time.month() == month || event.end_time.month() == month
|
.cloned()
|
||||||
})
|
.collect::<Vec<_>>();
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
CalendarMonth {
|
||||||
|
month,
|
||||||
CalendarMonth {
|
name: month_name.to_string(),
|
||||||
month,
|
events: month_events,
|
||||||
name: month_name.to_string(),
|
}
|
||||||
events: month_events,
|
}).collect::<Vec<_>>();
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
ctx.insert("months", &months);
|
ctx.insert("months", &months);
|
||||||
}
|
},
|
||||||
CalendarViewMode::Month => {
|
CalendarViewMode::Month => {
|
||||||
let days_in_month = Self::last_day_of_month(date.year(), date.month());
|
let days_in_month = Self::last_day_of_month(date.year(), date.month());
|
||||||
let first_day = Utc
|
let first_day = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
|
|
||||||
.unwrap();
|
|
||||||
let first_weekday = first_day.weekday().num_days_from_sunday();
|
let first_weekday = first_day.weekday().num_days_from_sunday();
|
||||||
|
|
||||||
let mut calendar_days = Vec::new();
|
let mut calendar_days = Vec::new();
|
||||||
|
|
||||||
// Add empty days for the start of the month
|
// Add empty days for the start of the month
|
||||||
for _ in 0..first_weekday {
|
for _ in 0..first_weekday {
|
||||||
calendar_days.push(CalendarDay {
|
calendar_days.push(CalendarDay {
|
||||||
@@ -212,34 +142,27 @@ impl CalendarController {
|
|||||||
is_current_month: false,
|
is_current_month: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add days for the current month
|
// Add days for the current month
|
||||||
for day in 1..=days_in_month {
|
for day in 1..=days_in_month {
|
||||||
let day_events = events
|
let day_events = events.iter()
|
||||||
.iter()
|
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
let day_start = Utc
|
let day_start = Utc.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0).unwrap();
|
||||||
.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0)
|
let day_end = Utc.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59).unwrap();
|
||||||
.unwrap();
|
|
||||||
let day_end = Utc
|
(event.start_time <= day_end && event.end_time >= day_start) ||
|
||||||
.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59)
|
(event.all_day && event.start_time.day() <= day && event.end_time.day() >= day)
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
(event.start_time <= day_end && event.end_time >= day_start)
|
|
||||||
|| (event.all_day
|
|
||||||
&& event.start_time.day() <= day
|
|
||||||
&& event.end_time.day() >= day)
|
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
calendar_days.push(CalendarDay {
|
calendar_days.push(CalendarDay {
|
||||||
day,
|
day,
|
||||||
events: day_events,
|
events: day_events,
|
||||||
is_current_month: true,
|
is_current_month: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill out the rest of the calendar grid (6 rows of 7 days)
|
// Fill out the rest of the calendar grid (6 rows of 7 days)
|
||||||
let remaining_days = 42 - calendar_days.len();
|
let remaining_days = 42 - calendar_days.len();
|
||||||
for day in 1..=remaining_days {
|
for day in 1..=remaining_days {
|
||||||
@@ -249,250 +172,149 @@ impl CalendarController {
|
|||||||
is_current_month: false,
|
is_current_month: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.insert("calendar_days", &calendar_days);
|
ctx.insert("calendar_days", &calendar_days);
|
||||||
ctx.insert("month_name", &Self::month_name(date.month()));
|
ctx.insert("month_name", &Self::month_name(date.month()));
|
||||||
}
|
},
|
||||||
CalendarViewMode::Week => {
|
CalendarViewMode::Week => {
|
||||||
// Calculate the start of the week (Sunday)
|
// Calculate the start of the week (Sunday)
|
||||||
let weekday = date.weekday().num_days_from_sunday();
|
let weekday = date.weekday().num_days_from_sunday();
|
||||||
let week_start = date - chrono::Duration::days(weekday as i64);
|
let week_start = date - chrono::Duration::days(weekday as i64);
|
||||||
|
|
||||||
let mut week_days = Vec::new();
|
let mut week_days = Vec::new();
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let day_date = week_start + chrono::Duration::days(i);
|
let day_date = week_start + chrono::Duration::days(i);
|
||||||
let day_events = events
|
let day_events = events.iter()
|
||||||
.iter()
|
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
let day_start = Utc
|
let day_start = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 0, 0, 0).unwrap();
|
||||||
.with_ymd_and_hms(
|
let day_end = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 23, 59, 59).unwrap();
|
||||||
day_date.year(),
|
|
||||||
day_date.month(),
|
(event.start_time <= day_end && event.end_time >= day_start) ||
|
||||||
day_date.day(),
|
(event.all_day && event.start_time.day() <= day_date.day() && event.end_time.day() >= day_date.day())
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let day_end = Utc
|
|
||||||
.with_ymd_and_hms(
|
|
||||||
day_date.year(),
|
|
||||||
day_date.month(),
|
|
||||||
day_date.day(),
|
|
||||||
23,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
(event.start_time <= day_end && event.end_time >= day_start)
|
|
||||||
|| (event.all_day
|
|
||||||
&& event.start_time.day() <= day_date.day()
|
|
||||||
&& event.end_time.day() >= day_date.day())
|
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
week_days.push(CalendarDay {
|
week_days.push(CalendarDay {
|
||||||
day: day_date.day(),
|
day: day_date.day(),
|
||||||
events: day_events,
|
events: day_events,
|
||||||
is_current_month: day_date.month() == date.month(),
|
is_current_month: day_date.month() == date.month(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.insert("week_days", &week_days);
|
ctx.insert("week_days", &week_days);
|
||||||
}
|
},
|
||||||
CalendarViewMode::Day => {
|
CalendarViewMode::Day => {
|
||||||
log::info!("Day view selected");
|
log::info!("Day view selected");
|
||||||
ctx.insert(
|
ctx.insert("day_name", &Self::day_name(date.weekday().num_days_from_sunday()));
|
||||||
"day_name",
|
|
||||||
&Self::day_name(date.weekday().num_days_from_sunday()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add debug info
|
// Add debug info
|
||||||
log::info!("Events count: {}", events.len());
|
log::info!("Events count: {}", events.len());
|
||||||
log::info!("Current date: {}", date.format("%Y-%m-%d"));
|
log::info!("Current date: {}", date.format("%Y-%m-%d"));
|
||||||
log::info!(
|
log::info!("Day name: {}", Self::day_name(date.weekday().num_days_from_sunday()));
|
||||||
"Day name: {}",
|
},
|
||||||
Self::day_name(date.weekday().num_days_from_sunday())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render_template(&tmpl, "calendar/index.html", &ctx)
|
render_template(&tmpl, "calendar/index.html", &ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the new event page route
|
/// Handles the new event page route
|
||||||
pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> {
|
pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> {
|
||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "calendar");
|
ctx.insert("active_page", "calendar");
|
||||||
|
|
||||||
// Add user to context if available and ensure user has a calendar
|
// Add user to context if available
|
||||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
// Get or create user calendar
|
|
||||||
if let (Some(user_id), Some(user_name)) = (
|
|
||||||
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
|
||||||
user.get("full_name").and_then(|v| v.as_str()),
|
|
||||||
) {
|
|
||||||
match get_or_create_user_calendar(user_id, user_name) {
|
|
||||||
Ok(calendar) => {
|
|
||||||
ctx.insert("user_calendar", &calendar);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get or create user calendar: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render_template(&tmpl, "calendar/new_event.html", &ctx)
|
render_template(&tmpl, "calendar/new_event.html", &ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the create event route
|
/// Handles the create event route
|
||||||
pub async fn create_event(
|
pub async fn create_event(
|
||||||
form: web::Form<EventForm>,
|
form: web::Form<EventForm>,
|
||||||
tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
_session: Session,
|
_session: Session,
|
||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
// Log the form data for debugging
|
|
||||||
log::info!(
|
|
||||||
"Creating event with form data: title='{}', start_time='{}', end_time='{}', all_day={}",
|
|
||||||
form.title,
|
|
||||||
form.start_time,
|
|
||||||
form.end_time,
|
|
||||||
form.all_day
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the start and end times
|
// Parse the start and end times
|
||||||
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
|
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse start time '{}': {}", form.start_time, e);
|
log::error!("Failed to parse start time: {}", e);
|
||||||
return Ok(HttpResponse::BadRequest().body("Invalid start time format"));
|
return Ok(HttpResponse::BadRequest().body("Invalid start time"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
|
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse end time '{}': {}", form.end_time, e);
|
log::error!("Failed to parse end time: {}", e);
|
||||||
return Ok(HttpResponse::BadRequest().body("Invalid end time format"));
|
return Ok(HttpResponse::BadRequest().body("Invalid end time"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get user information from session
|
// Create the event
|
||||||
let user_info = Self::get_user_from_session(&_session);
|
let event = CalendarEvent::new(
|
||||||
let (user_id, user_name) = if let Some(user) = &user_info {
|
form.title.clone(),
|
||||||
let id = user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32);
|
form.description.clone(),
|
||||||
let name = user
|
|
||||||
.get("full_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown User");
|
|
||||||
log::info!("User from session: id={:?}, name='{}'", id, name);
|
|
||||||
(id, name)
|
|
||||||
} else {
|
|
||||||
log::warn!("No user found in session");
|
|
||||||
(None, "Unknown User")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the event in the database
|
|
||||||
match create_new_event(
|
|
||||||
&form.title,
|
|
||||||
Some(&form.description),
|
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
None, // location
|
Some(form.color.clone()),
|
||||||
Some(&form.color),
|
|
||||||
form.all_day,
|
form.all_day,
|
||||||
user_id,
|
None, // User ID would come from session in a real app
|
||||||
None, // category
|
);
|
||||||
None, // reminder_minutes
|
|
||||||
) {
|
// Save the event to Redis
|
||||||
Ok((event_id, _saved_event)) => {
|
match RedisCalendarService::save_event(&event) {
|
||||||
log::info!("Created event with ID: {}", event_id);
|
Ok(_) => {
|
||||||
|
|
||||||
// If user is logged in, add the event to their calendar
|
|
||||||
if let Some(user_id) = user_id {
|
|
||||||
match get_or_create_user_calendar(user_id, user_name) {
|
|
||||||
Ok(calendar) => match add_event_to_calendar(calendar.get_id(), event_id) {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!(
|
|
||||||
"Added event {} to calendar {}",
|
|
||||||
event_id,
|
|
||||||
calendar.get_id()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to add event to calendar: {}", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get user calendar: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to the calendar page
|
// Redirect to the calendar page
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("Location", "/calendar"))
|
.append_header(("Location", "/calendar"))
|
||||||
.finish())
|
.finish())
|
||||||
}
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to save event to database: {}", e);
|
log::error!("Failed to save event to Redis: {}", e);
|
||||||
|
|
||||||
// Show an error message
|
// Show an error message
|
||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "calendar");
|
ctx.insert("active_page", "calendar");
|
||||||
ctx.insert("error", "Failed to save event");
|
ctx.insert("error", "Failed to save event");
|
||||||
|
|
||||||
// Add user to context if available
|
// Add user to context if available
|
||||||
if let Some(user) = user_info {
|
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
|
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
|
||||||
|
|
||||||
Ok(HttpResponse::InternalServerError()
|
Ok(HttpResponse::InternalServerError().content_type("text/html").body(result.into_body()))
|
||||||
.content_type("text/html")
|
|
||||||
.body(result.into_body()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the delete event route
|
/// Handles the delete event route
|
||||||
pub async fn delete_event(
|
pub async fn delete_event(
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
_session: Session,
|
_session: Session,
|
||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
let id = path.into_inner();
|
let id = path.into_inner();
|
||||||
|
|
||||||
// Parse the event ID
|
// Delete the event from Redis
|
||||||
let event_id = match id.parse::<u32>() {
|
match RedisCalendarService::delete_event(&id) {
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => {
|
|
||||||
log::error!("Invalid event ID: {}", id);
|
|
||||||
return Ok(HttpResponse::BadRequest().body("Invalid event ID"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete the event from database
|
|
||||||
match delete_event(event_id) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Deleted event with ID: {}", event_id);
|
|
||||||
// Redirect to the calendar page
|
// Redirect to the calendar page
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("Location", "/calendar"))
|
.append_header(("Location", "/calendar"))
|
||||||
.finish())
|
.finish())
|
||||||
}
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to delete event from database: {}", e);
|
log::error!("Failed to delete event from Redis: {}", e);
|
||||||
Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
|
Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the last day of the month
|
/// Returns the last day of the month
|
||||||
fn last_day_of_month(year: i32, month: u32) -> u32 {
|
fn last_day_of_month(year: i32, month: u32) -> u32 {
|
||||||
match month {
|
match month {
|
||||||
@@ -504,11 +326,11 @@ impl CalendarController {
|
|||||||
} else {
|
} else {
|
||||||
28
|
28
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
_ => 30, // Default to 30 days
|
_ => 30, // Default to 30 days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the name of the month
|
/// Returns the name of the month
|
||||||
fn month_name(month: u32) -> &'static str {
|
fn month_name(month: u32) -> &'static str {
|
||||||
match month {
|
match month {
|
||||||
@@ -527,7 +349,7 @@ impl CalendarController {
|
|||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the name of the day
|
/// Returns the name of the day
|
||||||
fn day_name(day: u32) -> &'static str {
|
fn day_name(day: u32) -> &'static str {
|
||||||
match day {
|
match day {
|
||||||
@@ -565,7 +387,7 @@ pub struct EventForm {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct CalendarDay {
|
struct CalendarDay {
|
||||||
day: u32,
|
day: u32,
|
||||||
events: Vec<Event>,
|
events: Vec<CalendarEvent>,
|
||||||
is_current_month: bool,
|
is_current_month: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,5 +396,5 @@ struct CalendarDay {
|
|||||||
struct CalendarMonth {
|
struct CalendarMonth {
|
||||||
month: u32,
|
month: u32,
|
||||||
name: String,
|
name: String,
|
||||||
events: Vec<Event>,
|
events: Vec<CalendarEvent>,
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
use crate::config::get_config;
|
use actix_web::{web, HttpResponse, Responder, Result};
|
||||||
use crate::controllers::error::render_company_not_found;
|
|
||||||
use crate::db::company::*;
|
|
||||||
use crate::db::document::*;
|
|
||||||
use crate::models::document::DocumentType;
|
|
||||||
use crate::utils::render_template;
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
use actix_web::{HttpResponse, Result, web};
|
|
||||||
|
|
||||||
use heromodels::models::biz::{BusinessType, CompanyStatus};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::fs;
|
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use chrono::Utc;
|
||||||
|
use crate::utils::render_template;
|
||||||
|
|
||||||
// Form structs for company operations
|
// Form structs for company operations
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct CompanyRegistrationForm {
|
pub struct CompanyRegistrationForm {
|
||||||
pub company_name: String,
|
pub company_name: String,
|
||||||
pub company_type: String,
|
pub company_type: String,
|
||||||
@@ -22,650 +14,232 @@ pub struct CompanyRegistrationForm {
|
|||||||
pub company_purpose: Option<String>,
|
pub company_purpose: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct CompanyEditForm {
|
|
||||||
pub company_name: String,
|
|
||||||
pub company_type: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub phone: Option<String>,
|
|
||||||
pub website: Option<String>,
|
|
||||||
pub address: Option<String>,
|
|
||||||
pub industry: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub fiscal_year_end: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CompanyController;
|
pub struct CompanyController;
|
||||||
|
|
||||||
impl CompanyController {
|
impl CompanyController {
|
||||||
// Display the company management dashboard
|
// Display the company management dashboard
|
||||||
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
let config = get_config();
|
|
||||||
|
println!("DEBUG: Starting Company dashboard rendering");
|
||||||
|
|
||||||
// Add active_page for navigation highlighting
|
// Add active_page for navigation highlighting
|
||||||
context.insert("active_page", &"company");
|
context.insert("active_page", &"company");
|
||||||
|
|
||||||
// Add Stripe configuration for payment processing
|
|
||||||
context.insert("stripe_publishable_key", &config.stripe.publishable_key);
|
|
||||||
|
|
||||||
// Load companies from database
|
|
||||||
let companies = match get_companies() {
|
|
||||||
Ok(companies) => companies,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get companies from database: {}", e);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
context.insert("companies", &companies);
|
|
||||||
|
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
let query_string = req.query_string();
|
let query_string = req.query_string();
|
||||||
|
|
||||||
// Check for success message
|
// Check for success message
|
||||||
if let Some(pos) = query_string.find("success=") {
|
if let Some(pos) = query_string.find("success=") {
|
||||||
let start = pos + 8; // length of "success="
|
let start = pos + 8; // length of "success="
|
||||||
let end = query_string[start..]
|
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start);
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let success = &query_string[start..end];
|
let success = &query_string[start..end];
|
||||||
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
context.insert("success", &decoded);
|
context.insert("success", &decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for entity context
|
// Check for entity context
|
||||||
if let Some(pos) = query_string.find("entity=") {
|
if let Some(pos) = query_string.find("entity=") {
|
||||||
let start = pos + 7; // length of "entity="
|
let start = pos + 7; // length of "entity="
|
||||||
let end = query_string[start..]
|
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start);
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let entity = &query_string[start..end];
|
let entity = &query_string[start..end];
|
||||||
context.insert("entity", &entity);
|
context.insert("entity", &entity);
|
||||||
|
|
||||||
// Also get entity name if present
|
// Also get entity name if present
|
||||||
if let Some(pos) = query_string.find("entity_name=") {
|
if let Some(pos) = query_string.find("entity_name=") {
|
||||||
let start = pos + 12; // length of "entity_name="
|
let start = pos + 12; // length of "entity_name="
|
||||||
let end = query_string[start..]
|
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start);
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let entity_name = &query_string[start..end];
|
let entity_name = &query_string[start..end];
|
||||||
let decoded_name =
|
let decoded_name = urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
|
||||||
urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
|
|
||||||
context.insert("entity_name", &decoded_name);
|
context.insert("entity_name", &decoded_name);
|
||||||
|
println!("DEBUG: Entity context set to {} ({})", entity, decoded_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render_template(&tmpl, "company/index.html", &context)
|
println!("DEBUG: Rendering Company dashboard template");
|
||||||
|
let response = render_template(&tmpl, "company/index.html", &context);
|
||||||
|
println!("DEBUG: Finished rendering Company dashboard template");
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display company edit form
|
|
||||||
pub async fn edit_form(
|
|
||||||
tmpl: web::Data<Tera>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
req: HttpRequest,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
let company_id_str = path.into_inner();
|
|
||||||
let mut context = Context::new();
|
|
||||||
|
|
||||||
// Add active_page for navigation highlighting
|
|
||||||
context.insert("active_page", &"company");
|
|
||||||
|
|
||||||
// Parse query parameters for success/error messages
|
|
||||||
let query_string = req.query_string();
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
if let Some(pos) = query_string.find("success=") {
|
|
||||||
let start = pos + 8; // length of "success="
|
|
||||||
let end = query_string[start..]
|
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let success = &query_string[start..end];
|
|
||||||
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
|
||||||
context.insert("success", &decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error message
|
|
||||||
if let Some(pos) = query_string.find("error=") {
|
|
||||||
let start = pos + 6; // length of "error="
|
|
||||||
let end = query_string[start..]
|
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let error = &query_string[start..end];
|
|
||||||
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
|
||||||
context.insert("error", &decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse company ID
|
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch company from database
|
|
||||||
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
|
||||||
context.insert("company", &company);
|
|
||||||
|
|
||||||
// Format timestamps for display
|
|
||||||
let incorporation_date =
|
|
||||||
chrono::DateTime::from_timestamp(company.incorporation_date, 0)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
|
||||||
context.insert("incorporation_date_formatted", &incorporation_date);
|
|
||||||
|
|
||||||
render_template(&tmpl, "company/edit.html", &context)
|
|
||||||
} else {
|
|
||||||
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View company details
|
// View company details
|
||||||
pub async fn view_company(
|
pub async fn view_company(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> {
|
||||||
tmpl: web::Data<Tera>,
|
let company_id = path.into_inner();
|
||||||
path: web::Path<String>,
|
|
||||||
req: HttpRequest,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
let company_id_str = path.into_inner();
|
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
println!("DEBUG: Viewing company details for {}", company_id);
|
||||||
|
|
||||||
// Add active_page for navigation highlighting
|
// Add active_page for navigation highlighting
|
||||||
context.insert("active_page", &"company");
|
context.insert("active_page", &"company");
|
||||||
context.insert("company_id", &company_id_str);
|
context.insert("company_id", &company_id);
|
||||||
|
|
||||||
// Parse query parameters for success/error messages
|
// In a real application, we would fetch company data from a database
|
||||||
let query_string = req.query_string();
|
// For now, we'll use mock data based on the company_id
|
||||||
|
match company_id.as_str() {
|
||||||
// Check for success message
|
"company1" => {
|
||||||
if let Some(pos) = query_string.find("success=") {
|
context.insert("company_name", &"Zanzibar Digital Solutions");
|
||||||
let start = pos + 8; // length of "success="
|
context.insert("company_type", &"Startup FZC");
|
||||||
let end = query_string[start..]
|
context.insert("status", &"Active");
|
||||||
.find('&')
|
context.insert("registration_date", &"2025-04-01");
|
||||||
.map_or(query_string.len(), |e| e + start);
|
context.insert("purpose", &"Digital solutions and blockchain development");
|
||||||
let success = &query_string[start..end];
|
context.insert("plan", &"Startup FZC - $50/month");
|
||||||
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
context.insert("next_billing", &"2025-06-01");
|
||||||
context.insert("success", &decoded);
|
context.insert("payment_method", &"Credit Card (****4582)");
|
||||||
}
|
|
||||||
|
// Shareholders data
|
||||||
// Check for error message
|
let shareholders = vec![
|
||||||
if let Some(pos) = query_string.find("error=") {
|
("John Smith", "60%"),
|
||||||
let start = pos + 6; // length of "error="
|
("Sarah Johnson", "40%"),
|
||||||
let end = query_string[start..]
|
];
|
||||||
.find('&')
|
context.insert("shareholders", &shareholders);
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let error = &query_string[start..end];
|
// Contracts data
|
||||||
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
let contracts = vec![
|
||||||
context.insert("error", &decoded);
|
("Articles of Incorporation", "Signed"),
|
||||||
}
|
("Terms & Conditions", "Signed"),
|
||||||
|
("Digital Asset Issuance", "Signed"),
|
||||||
// Parse company ID
|
];
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
context.insert("contracts", &contracts);
|
||||||
Ok(id) => id,
|
},
|
||||||
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
"company2" => {
|
||||||
};
|
context.insert("company_name", &"Blockchain Innovations Ltd");
|
||||||
|
context.insert("company_type", &"Growth FZC");
|
||||||
// Fetch company from database
|
context.insert("status", &"Active");
|
||||||
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
context.insert("registration_date", &"2025-03-15");
|
||||||
context.insert("company", &company);
|
context.insert("purpose", &"Blockchain technology research and development");
|
||||||
|
context.insert("plan", &"Growth FZC - $100/month");
|
||||||
// Format timestamps for display
|
context.insert("next_billing", &"2025-06-15");
|
||||||
let incorporation_date =
|
context.insert("payment_method", &"Bank Transfer");
|
||||||
chrono::DateTime::from_timestamp(company.incorporation_date, 0)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
// Shareholders data
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
let shareholders = vec![
|
||||||
context.insert("incorporation_date_formatted", &incorporation_date);
|
("Michael Chen", "35%"),
|
||||||
|
("Aisha Patel", "35%"),
|
||||||
// Get shareholders for this company
|
("David Okonkwo", "30%"),
|
||||||
let shareholders = match get_company_shareholders(company_id) {
|
];
|
||||||
Ok(shareholders) => shareholders,
|
context.insert("shareholders", &shareholders);
|
||||||
Err(e) => {
|
|
||||||
log::error!(
|
// Contracts data
|
||||||
"Failed to get shareholders for company {}: {}",
|
let contracts = vec![
|
||||||
company_id,
|
("Articles of Incorporation", "Signed"),
|
||||||
e
|
("Terms & Conditions", "Signed"),
|
||||||
);
|
("Digital Asset Issuance", "Signed"),
|
||||||
vec![]
|
("Physical Asset Holding", "Signed"),
|
||||||
}
|
];
|
||||||
};
|
context.insert("contracts", &contracts);
|
||||||
context.insert("shareholders", &shareholders);
|
},
|
||||||
|
"company3" => {
|
||||||
// Get payment information for this company
|
context.insert("company_name", &"Sustainable Energy Cooperative");
|
||||||
if let Some(payment_info) =
|
context.insert("company_type", &"Cooperative FZC");
|
||||||
crate::controllers::payment::PaymentController::get_company_payment_info(company_id)
|
context.insert("status", &"Pending");
|
||||||
.await
|
context.insert("registration_date", &"2025-05-01");
|
||||||
{
|
context.insert("purpose", &"Renewable energy production and distribution");
|
||||||
context.insert("payment_info", &payment_info);
|
context.insert("plan", &"Cooperative FZC - $200/month");
|
||||||
|
context.insert("next_billing", &"Pending Activation");
|
||||||
// Format payment dates for display
|
context.insert("payment_method", &"Pending");
|
||||||
// Format timestamps from i64 to readable format
|
|
||||||
let payment_created = chrono::DateTime::from_timestamp(payment_info.created_at, 0)
|
// Shareholders data
|
||||||
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
let shareholders = vec![
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
("Community Energy Group", "40%"),
|
||||||
context.insert("payment_created_formatted", &payment_created);
|
("Green Future Initiative", "30%"),
|
||||||
|
("Sustainable Living Collective", "30%"),
|
||||||
if let Some(completed_at) = payment_info.completed_at {
|
];
|
||||||
let payment_completed = chrono::DateTime::from_timestamp(completed_at, 0)
|
context.insert("shareholders", &shareholders);
|
||||||
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
// Contracts data
|
||||||
context.insert("payment_completed_formatted", &payment_completed);
|
let contracts = vec![
|
||||||
}
|
("Articles of Incorporation", "Signed"),
|
||||||
|
("Terms & Conditions", "Signed"),
|
||||||
// Format payment plan for display
|
("Cooperative Governance", "Pending"),
|
||||||
let payment_plan_display = match payment_info.payment_plan.as_str() {
|
];
|
||||||
"monthly" => "Monthly",
|
context.insert("contracts", &contracts);
|
||||||
"yearly" => "Yearly (20% discount)",
|
},
|
||||||
"two_year" => "2-Year (40% discount)",
|
_ => {
|
||||||
_ => &payment_info.payment_plan,
|
// If company_id is not recognized, redirect to company index
|
||||||
};
|
return Ok(HttpResponse::Found()
|
||||||
context.insert("payment_plan_display", &payment_plan_display);
|
.append_header(("Location", "/company"))
|
||||||
|
.finish());
|
||||||
log::info!("Added payment info to company {} view", company_id);
|
|
||||||
} else {
|
|
||||||
log::info!("No payment info found for company {}", company_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render_template(&tmpl, "company/view.html", &context)
|
|
||||||
} else {
|
|
||||||
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!("DEBUG: Rendering company view template");
|
||||||
|
let response = render_template(&tmpl, "company/view.html", &context);
|
||||||
|
println!("DEBUG: Finished rendering company view template");
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to entity context
|
// Switch to entity context
|
||||||
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
|
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
|
||||||
let company_id_str = path.into_inner();
|
let company_id = path.into_inner();
|
||||||
|
|
||||||
// Parse company ID
|
println!("DEBUG: Switching to entity context for {}", company_id);
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
// Get company name based on ID (in a real app, this would come from a database)
|
||||||
Err(_) => {
|
let company_name = match company_id.as_str() {
|
||||||
return Ok(HttpResponse::Found()
|
"company1" => "Zanzibar Digital Solutions",
|
||||||
.append_header(("Location", "/company"))
|
"company2" => "Blockchain Innovations Ltd",
|
||||||
.finish());
|
"company3" => "Sustainable Energy Cooperative",
|
||||||
}
|
_ => "Unknown Company"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get company from database
|
|
||||||
let company_name = match get_company_by_id(company_id) {
|
|
||||||
Ok(Some(company)) => company.name,
|
|
||||||
Ok(None) => {
|
|
||||||
return Ok(HttpResponse::Found()
|
|
||||||
.append_header(("Location", "/company"))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get company for switch: {}", e);
|
|
||||||
return Ok(HttpResponse::Found()
|
|
||||||
.append_header(("Location", "/company"))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// In a real application, we would set a session/cookie for the current entity
|
// In a real application, we would set a session/cookie for the current entity
|
||||||
// Here we'll redirect back to the company page with a success message and entity parameter
|
// Here we'll redirect back to the company page with a success message and entity parameter
|
||||||
let success_message = format!("Switched to {} entity context", company_name);
|
let success_message = format!("Switched to {} entity context", company_name);
|
||||||
let encoded_message = urlencoding::encode(&success_message);
|
let encoded_message = urlencoding::encode(&success_message);
|
||||||
|
|
||||||
Ok(HttpResponse::Found()
|
Ok(HttpResponse::Found()
|
||||||
.append_header((
|
.append_header(("Location", format!("/company?success={}&entity={}&entity_name={}",
|
||||||
"Location",
|
encoded_message, company_id, urlencoding::encode(company_name))))
|
||||||
format!(
|
|
||||||
"/company?success={}&entity={}&entity_name={}",
|
|
||||||
encoded_message,
|
|
||||||
company_id_str,
|
|
||||||
urlencoding::encode(&company_name)
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated registration method removed - now handled via payment flow
|
// Process company registration
|
||||||
|
pub async fn register(
|
||||||
// Legacy registration method (kept for reference but not used)
|
mut form: actix_multipart::Multipart,
|
||||||
#[allow(dead_code)]
|
) -> Result<HttpResponse> {
|
||||||
async fn legacy_register(mut form: actix_multipart::Multipart) -> Result<HttpResponse> {
|
use actix_web::{http::header};
|
||||||
use actix_web::http::header;
|
|
||||||
use chrono::Utc;
|
|
||||||
use futures_util::stream::StreamExt as _;
|
use futures_util::stream::StreamExt as _;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
println!("DEBUG: Processing company registration request");
|
||||||
|
|
||||||
let mut fields: HashMap<String, String> = HashMap::new();
|
let mut fields: HashMap<String, String> = HashMap::new();
|
||||||
let mut uploaded_files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
// Parse multipart form
|
// Parse multipart form
|
||||||
while let Some(Ok(mut field)) = form.next().await {
|
while let Some(Ok(mut field)) = form.next().await {
|
||||||
let content_disposition = field.content_disposition();
|
let mut value = Vec::new();
|
||||||
let field_name = content_disposition
|
while let Some(chunk) = field.next().await {
|
||||||
.get_name()
|
let data = chunk.unwrap();
|
||||||
.unwrap_or("unknown")
|
value.extend_from_slice(&data);
|
||||||
.to_string();
|
}
|
||||||
let filename = content_disposition.get_filename().map(|f| f.to_string());
|
|
||||||
|
// Get field name from content disposition
|
||||||
if field_name.starts_with("contract-") || field_name.ends_with("-doc") {
|
let cd = field.content_disposition();
|
||||||
// Handle file upload
|
if let Some(name) = cd.get_name() {
|
||||||
if let Some(filename) = filename {
|
if name == "company_docs" {
|
||||||
let mut file_data = Vec::new();
|
files.push(value); // Just collect files in memory for now
|
||||||
while let Some(chunk) = field.next().await {
|
} else {
|
||||||
let data = chunk.unwrap();
|
fields.insert(name.to_string(), String::from_utf8_lossy(&value).to_string());
|
||||||
file_data.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !file_data.is_empty() {
|
|
||||||
uploaded_files.push((field_name, filename, file_data));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Handle form field
|
|
||||||
let mut value = Vec::new();
|
|
||||||
while let Some(chunk) = field.next().await {
|
|
||||||
let data = chunk.unwrap();
|
|
||||||
value.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.insert(field_name, String::from_utf8_lossy(&value).to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract company details
|
// Extract company details
|
||||||
let company_name = fields.get("company_name").cloned().unwrap_or_default();
|
let company_name = fields.get("company_name").cloned().unwrap_or_default();
|
||||||
let company_type_str = fields.get("company_type").cloned().unwrap_or_default();
|
let company_type = fields.get("company_type").cloned().unwrap_or_default();
|
||||||
let company_purpose = fields.get("company_purpose").cloned().unwrap_or_default();
|
let shareholders = fields.get("shareholders").cloned().unwrap_or_default();
|
||||||
let shareholders_str = fields.get("shareholders").cloned().unwrap_or_default();
|
|
||||||
|
// Log received fields (mock DB insert)
|
||||||
// Extract new contact fields
|
println!("[Company Registration] Name: {}, Type: {}, Shareholders: {}, Files: {}",
|
||||||
let company_email = fields.get("company_email").cloned().unwrap_or_default();
|
company_name, company_type, shareholders, files.len());
|
||||||
let company_phone = fields.get("company_phone").cloned().unwrap_or_default();
|
|
||||||
let company_website = fields.get("company_website").cloned().unwrap_or_default();
|
// Create success message
|
||||||
let company_address = fields.get("company_address").cloned().unwrap_or_default();
|
let success_message = format!("Successfully registered {} as a {}", company_name, company_type);
|
||||||
let company_industry = fields.get("company_industry").cloned().unwrap_or_default();
|
|
||||||
let fiscal_year_end = fields.get("fiscal_year_end").cloned().unwrap_or_default();
|
// Redirect back to /company with success message
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
// Validate required fields
|
.append_header((header::LOCATION, format!("/company?success={}", urlencoding::encode(&success_message))))
|
||||||
if company_name.is_empty() || company_type_str.is_empty() {
|
.finish())
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
"/company?error=Company name and type are required",
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
if company_email.trim().is_empty() {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((header::LOCATION, "/company?error=Company email is required"))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
if company_phone.trim().is_empty() {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((header::LOCATION, "/company?error=Company phone is required"))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
if company_address.trim().is_empty() {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
"/company?error=Company address is required",
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse business type
|
|
||||||
let business_type = match company_type_str.as_str() {
|
|
||||||
"Startup FZC" => BusinessType::Starter,
|
|
||||||
"Growth FZC" => BusinessType::Global,
|
|
||||||
"Cooperative FZC" => BusinessType::Coop,
|
|
||||||
"Single FZC" => BusinessType::Single,
|
|
||||||
"Twin FZC" => BusinessType::Twin,
|
|
||||||
_ => BusinessType::Single, // Default
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate registration number (in real app, this would be more sophisticated)
|
|
||||||
let registration_number = format!(
|
|
||||||
"FZC-{}-{}",
|
|
||||||
Utc::now().format("%Y%m%d"),
|
|
||||||
company_name
|
|
||||||
.chars()
|
|
||||||
.take(3)
|
|
||||||
.collect::<String>()
|
|
||||||
.to_uppercase()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create company in database
|
|
||||||
match create_new_company(
|
|
||||||
company_name.clone(),
|
|
||||||
registration_number,
|
|
||||||
Utc::now().timestamp(),
|
|
||||||
business_type,
|
|
||||||
company_email,
|
|
||||||
company_phone,
|
|
||||||
company_website,
|
|
||||||
company_address,
|
|
||||||
company_industry,
|
|
||||||
company_purpose,
|
|
||||||
fiscal_year_end,
|
|
||||||
) {
|
|
||||||
Ok((company_id, _company)) => {
|
|
||||||
// TODO: Parse and create shareholders if provided
|
|
||||||
if !shareholders_str.is_empty() {
|
|
||||||
// For now, just log the shareholders - in a real app, parse and create them
|
|
||||||
log::info!(
|
|
||||||
"Shareholders for company {}: {}",
|
|
||||||
company_id,
|
|
||||||
shareholders_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save uploaded documents
|
|
||||||
if !uploaded_files.is_empty() {
|
|
||||||
log::info!(
|
|
||||||
"Processing {} uploaded files for company {}",
|
|
||||||
uploaded_files.len(),
|
|
||||||
company_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
|
||||||
let upload_dir = format!("/tmp/company_{}_documents", company_id);
|
|
||||||
if let Err(e) = fs::create_dir_all(&upload_dir) {
|
|
||||||
log::error!("Failed to create upload directory: {}", e);
|
|
||||||
} else {
|
|
||||||
// Save each uploaded file
|
|
||||||
for (field_name, filename, file_data) in uploaded_files {
|
|
||||||
// Determine document type based on field name
|
|
||||||
let doc_type = match field_name.as_str() {
|
|
||||||
name if name.contains("shareholder") => DocumentType::Articles,
|
|
||||||
name if name.contains("bank") => DocumentType::Financial,
|
|
||||||
name if name.contains("cooperative") => DocumentType::Articles,
|
|
||||||
name if name.contains("digital") => DocumentType::Legal,
|
|
||||||
name if name.contains("contract") => DocumentType::Contract,
|
|
||||||
_ => DocumentType::Other,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
let timestamp = Utc::now().timestamp();
|
|
||||||
let file_extension = filename.split('.').last().unwrap_or("pdf");
|
|
||||||
let unique_filename = format!(
|
|
||||||
"{}_{}.{}",
|
|
||||||
timestamp,
|
|
||||||
filename.replace(" ", "_"),
|
|
||||||
file_extension
|
|
||||||
);
|
|
||||||
let file_path = format!("{}/{}", upload_dir, unique_filename);
|
|
||||||
|
|
||||||
// Save file to disk
|
|
||||||
if let Err(e) = fs::write(&file_path, &file_data) {
|
|
||||||
log::error!("Failed to save file {}: {}", filename, e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save document metadata to database
|
|
||||||
let file_size = file_data.len() as u64;
|
|
||||||
let mime_type = match file_extension {
|
|
||||||
"pdf" => "application/pdf",
|
|
||||||
"doc" | "docx" => "application/msword",
|
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
|
||||||
"png" => "image/png",
|
|
||||||
_ => "application/octet-stream",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
match create_new_document(
|
|
||||||
filename.clone(),
|
|
||||||
file_path,
|
|
||||||
file_size,
|
|
||||||
mime_type,
|
|
||||||
company_id,
|
|
||||||
"System".to_string(), // uploaded_by
|
|
||||||
doc_type,
|
|
||||||
Some("Uploaded during company registration".to_string()),
|
|
||||||
false, // not public by default
|
|
||||||
None, // checksum
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("Successfully saved document: {}", filename);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!(
|
|
||||||
"Failed to save document metadata for {}: {}",
|
|
||||||
filename,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let success_message = format!(
|
|
||||||
"Successfully registered {} as a {}",
|
|
||||||
company_name, company_type_str
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!("/company?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to create company: {}", e);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
"/company?error=Failed to register company",
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process company edit form
|
|
||||||
pub async fn edit(
|
|
||||||
tmpl: web::Data<Tera>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
form: web::Form<CompanyEditForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
use actix_web::http::header;
|
|
||||||
|
|
||||||
let company_id_str = path.into_inner();
|
|
||||||
|
|
||||||
// Parse company ID
|
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if form.company_name.trim().is_empty() {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/edit/{}?error=Company name is required",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse business type
|
|
||||||
let business_type = match form.company_type.as_str() {
|
|
||||||
"Startup FZC" => BusinessType::Starter,
|
|
||||||
"Growth FZC" => BusinessType::Global,
|
|
||||||
"Cooperative FZC" => BusinessType::Coop,
|
|
||||||
"Single FZC" => BusinessType::Single,
|
|
||||||
"Twin FZC" => BusinessType::Twin,
|
|
||||||
_ => BusinessType::Single, // Default
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse status
|
|
||||||
let status = match form.status.as_str() {
|
|
||||||
"Active" => CompanyStatus::Active,
|
|
||||||
"Inactive" => CompanyStatus::Inactive,
|
|
||||||
"Suspended" => CompanyStatus::Suspended,
|
|
||||||
_ => CompanyStatus::Active, // Default
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update company in database
|
|
||||||
match update_company(
|
|
||||||
company_id,
|
|
||||||
Some(form.company_name.clone()),
|
|
||||||
form.email.clone(),
|
|
||||||
form.phone.clone(),
|
|
||||||
form.website.clone(),
|
|
||||||
form.address.clone(),
|
|
||||||
form.industry.clone(),
|
|
||||||
form.description.clone(),
|
|
||||||
form.fiscal_year_end.clone(),
|
|
||||||
Some(status),
|
|
||||||
Some(business_type),
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
let success_message = format!("Successfully updated {}", form.company_name);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/view/{}?success={}",
|
|
||||||
company_id,
|
|
||||||
urlencoding::encode(&success_message)
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to update company {}: {}", company_id, e);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/edit/{}?error=Failed to update company",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Debug endpoint to clean up corrupted database (emergency use only)
|
|
||||||
pub async fn cleanup_database() -> Result<HttpResponse> {
|
|
||||||
match crate::db::company::cleanup_corrupted_database() {
|
|
||||||
Ok(message) => {
|
|
||||||
log::info!("Database cleanup successful: {}", message);
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
|
||||||
"success": true,
|
|
||||||
"message": message
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
log::error!("Database cleanup failed: {}", error);
|
|
||||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"error": error
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,12 @@
|
|||||||
|
use actix_web::{web, HttpResponse, Result};
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
use actix_web::{HttpResponse, Result, web};
|
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
use chrono::{Utc, Duration};
|
||||||
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::asset::Asset;
|
use crate::models::asset::{Asset, AssetType, AssetStatus};
|
||||||
use crate::models::defi::{
|
use crate::models::defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB};
|
||||||
DEFI_DB, DefiPosition, DefiPositionStatus, DefiPositionType, ProvidingPosition,
|
|
||||||
ReceivingPosition,
|
|
||||||
};
|
|
||||||
use crate::utils::render_template;
|
use crate::utils::render_template;
|
||||||
|
|
||||||
// Form structs for DeFi operations
|
// Form structs for DeFi operations
|
||||||
@@ -29,7 +26,6 @@ pub struct ReceivingForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct LiquidityForm {
|
pub struct LiquidityForm {
|
||||||
pub first_token: String,
|
pub first_token: String,
|
||||||
pub first_amount: f64,
|
pub first_amount: f64,
|
||||||
@@ -39,7 +35,6 @@ pub struct LiquidityForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct StakingForm {
|
pub struct StakingForm {
|
||||||
pub asset_id: String,
|
pub asset_id: String,
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
@@ -54,7 +49,6 @@ pub struct SwapForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct CollateralForm {
|
pub struct CollateralForm {
|
||||||
pub asset_id: String,
|
pub asset_id: String,
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
@@ -69,29 +63,29 @@ impl DefiController {
|
|||||||
// Display the DeFi dashboard
|
// Display the DeFi dashboard
|
||||||
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
println!("DEBUG: Starting DeFi dashboard rendering");
|
println!("DEBUG: Starting DeFi dashboard rendering");
|
||||||
|
|
||||||
// Get mock assets for the dropdown selectors
|
// Get mock assets for the dropdown selectors
|
||||||
let assets = Self::get_mock_assets();
|
let assets = Self::get_mock_assets();
|
||||||
println!("DEBUG: Generated {} mock assets", assets.len());
|
println!("DEBUG: Generated {} mock assets", assets.len());
|
||||||
|
|
||||||
// Add active_page for navigation highlighting
|
// Add active_page for navigation highlighting
|
||||||
context.insert("active_page", &"defi");
|
context.insert("active_page", &"defi");
|
||||||
|
|
||||||
// Add DeFi stats
|
// Add DeFi stats
|
||||||
let defi_stats = Self::get_defi_stats();
|
let defi_stats = Self::get_defi_stats();
|
||||||
context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap());
|
context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap());
|
||||||
|
|
||||||
// Add recent assets for selection in forms
|
// Add recent assets for selection in forms
|
||||||
let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets
|
let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets
|
||||||
.iter()
|
.iter()
|
||||||
.take(5)
|
.take(5)
|
||||||
.map(|a| Self::asset_to_json(a))
|
.map(|a| Self::asset_to_json(a))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
context.insert("recent_assets", &recent_assets);
|
context.insert("recent_assets", &recent_assets);
|
||||||
|
|
||||||
// Get user's providing positions
|
// Get user's providing positions
|
||||||
let db = DEFI_DB.lock().unwrap();
|
let db = DEFI_DB.lock().unwrap();
|
||||||
let providing_positions = db.get_user_providing_positions("user123");
|
let providing_positions = db.get_user_providing_positions("user123");
|
||||||
@@ -100,7 +94,7 @@ impl DefiController {
|
|||||||
.map(|p| serde_json::to_value(p).unwrap())
|
.map(|p| serde_json::to_value(p).unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
context.insert("providing_positions", &providing_positions_json);
|
context.insert("providing_positions", &providing_positions_json);
|
||||||
|
|
||||||
// Get user's receiving positions
|
// Get user's receiving positions
|
||||||
let receiving_positions = db.get_user_receiving_positions("user123");
|
let receiving_positions = db.get_user_receiving_positions("user123");
|
||||||
let receiving_positions_json: Vec<serde_json::Value> = receiving_positions
|
let receiving_positions_json: Vec<serde_json::Value> = receiving_positions
|
||||||
@@ -108,30 +102,27 @@ impl DefiController {
|
|||||||
.map(|p| serde_json::to_value(p).unwrap())
|
.map(|p| serde_json::to_value(p).unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
context.insert("receiving_positions", &receiving_positions_json);
|
context.insert("receiving_positions", &receiving_positions_json);
|
||||||
|
|
||||||
// Add success message if present in query params
|
// Add success message if present in query params
|
||||||
if let Some(success) = req.query_string().strip_prefix("success=") {
|
if let Some(success) = req.query_string().strip_prefix("success=") {
|
||||||
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
context.insert("success_message", &decoded);
|
context.insert("success_message", &decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("DEBUG: Rendering DeFi dashboard template");
|
println!("DEBUG: Rendering DeFi dashboard template");
|
||||||
let response = render_template(&tmpl, "defi/index.html", &context);
|
let response = render_template(&tmpl, "defi/index.html", &context);
|
||||||
println!("DEBUG: Finished rendering DeFi dashboard template");
|
println!("DEBUG: Finished rendering DeFi dashboard template");
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process providing request
|
// Process providing request
|
||||||
pub async fn create_providing(
|
pub async fn create_providing(_tmpl: web::Data<Tera>, form: web::Form<ProvidingForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<ProvidingForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing providing request: {:?}", form);
|
println!("DEBUG: Processing providing request: {:?}", form);
|
||||||
|
|
||||||
// Get the asset obligationails (in a real app, this would come from a database)
|
// Get the asset obligationails (in a real app, this would come from a database)
|
||||||
let assets = Self::get_mock_assets();
|
let assets = Self::get_mock_assets();
|
||||||
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
||||||
|
|
||||||
if let Some(asset) = asset {
|
if let Some(asset) = asset {
|
||||||
// Calculate profit share and return amount
|
// Calculate profit share and return amount
|
||||||
let profit_share = match form.duration {
|
let profit_share = match form.duration {
|
||||||
@@ -142,10 +133,9 @@ impl DefiController {
|
|||||||
365 => 12.0,
|
365 => 12.0,
|
||||||
_ => 4.2, // Default to 30 days rate
|
_ => 4.2, // Default to 30 days rate
|
||||||
};
|
};
|
||||||
|
|
||||||
let return_amount = form.amount
|
let return_amount = form.amount + (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
|
||||||
+ (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
|
|
||||||
|
|
||||||
// Create a new providing position
|
// Create a new providing position
|
||||||
let providing_position = ProvidingPosition {
|
let providing_position = ProvidingPosition {
|
||||||
base: DefiPosition {
|
base: DefiPosition {
|
||||||
@@ -166,23 +156,17 @@ impl DefiController {
|
|||||||
profit_share_earned: profit_share,
|
profit_share_earned: profit_share,
|
||||||
return_amount,
|
return_amount,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the position to the database
|
// Add the position to the database
|
||||||
{
|
{
|
||||||
let mut db = DEFI_DB.lock().unwrap();
|
let mut db = DEFI_DB.lock().unwrap();
|
||||||
db.add_providing_position(providing_position);
|
db.add_providing_position(providing_position);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect with success message
|
// Redirect with success message
|
||||||
let success_message = format!(
|
let success_message = format!("Successfully provided {} {} for {} days", form.amount, asset.name, form.duration);
|
||||||
"Successfully provided {} {} for {} days",
|
|
||||||
form.amount, asset.name, form.duration
|
|
||||||
);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
} else {
|
} else {
|
||||||
// Asset not found, redirect with error
|
// Asset not found, redirect with error
|
||||||
@@ -191,18 +175,15 @@ impl DefiController {
|
|||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process receiving request
|
// Process receiving request
|
||||||
pub async fn create_receiving(
|
pub async fn create_receiving(_tmpl: web::Data<Tera>, form: web::Form<ReceivingForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<ReceivingForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing receiving request: {:?}", form);
|
println!("DEBUG: Processing receiving request: {:?}", form);
|
||||||
|
|
||||||
// Get the asset obligationails (in a real app, this would come from a database)
|
// Get the asset obligationails (in a real app, this would come from a database)
|
||||||
let assets = Self::get_mock_assets();
|
let assets = Self::get_mock_assets();
|
||||||
let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id);
|
let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id);
|
||||||
|
|
||||||
if let Some(collateral_asset) = collateral_asset {
|
if let Some(collateral_asset) = collateral_asset {
|
||||||
// Calculate profit share rate based on duration
|
// Calculate profit share rate based on duration
|
||||||
let profit_share_rate = match form.duration {
|
let profit_share_rate = match form.duration {
|
||||||
@@ -213,17 +194,15 @@ impl DefiController {
|
|||||||
365 => 10.0,
|
365 => 10.0,
|
||||||
_ => 5.0, // Default to 30 days rate
|
_ => 5.0, // Default to 30 days rate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate profit share and total to repay
|
// Calculate profit share and total to repay
|
||||||
let profit_share =
|
let profit_share = form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
|
||||||
form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
|
|
||||||
let total_to_repay = form.amount + profit_share;
|
let total_to_repay = form.amount + profit_share;
|
||||||
|
|
||||||
// Calculate collateral value and ratio
|
// Calculate collateral value and ratio
|
||||||
let collateral_value = form.collateral_amount
|
let collateral_value = form.collateral_amount * collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
|
||||||
* collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
|
|
||||||
let collateral_ratio = (collateral_value / form.amount) * 100.0;
|
let collateral_ratio = (collateral_value / form.amount) * 100.0;
|
||||||
|
|
||||||
// Create a new receiving position
|
// Create a new receiving position
|
||||||
let receiving_position = ReceivingPosition {
|
let receiving_position = ReceivingPosition {
|
||||||
base: DefiPosition {
|
base: DefiPosition {
|
||||||
@@ -251,23 +230,18 @@ impl DefiController {
|
|||||||
total_to_repay,
|
total_to_repay,
|
||||||
collateral_ratio,
|
collateral_ratio,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the position to the database
|
// Add the position to the database
|
||||||
{
|
{
|
||||||
let mut db = DEFI_DB.lock().unwrap();
|
let mut db = DEFI_DB.lock().unwrap();
|
||||||
db.add_receiving_position(receiving_position);
|
db.add_receiving_position(receiving_position);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect with success message
|
// Redirect with success message
|
||||||
let success_message = format!(
|
let success_message = format!("Successfully borrowed {} ZDFZ using {} {} as collateral",
|
||||||
"Successfully borrowed {} ZDFZ using {} {} as collateral",
|
form.amount, form.collateral_amount, collateral_asset.name);
|
||||||
form.amount, form.collateral_amount, collateral_asset.name
|
|
||||||
);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
} else {
|
} else {
|
||||||
// Asset not found, redirect with error
|
// Asset not found, redirect with error
|
||||||
@@ -276,202 +250,116 @@ impl DefiController {
|
|||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process liquidity provision
|
// Process liquidity provision
|
||||||
pub async fn add_liquidity(
|
pub async fn add_liquidity(_tmpl: web::Data<Tera>, form: web::Form<LiquidityForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<LiquidityForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing liquidity provision: {:?}", form);
|
println!("DEBUG: Processing liquidity provision: {:?}", form);
|
||||||
|
|
||||||
// In a real application, this would add liquidity to a pool in the database
|
// In a real application, this would add liquidity to a pool in the database
|
||||||
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
let success_message = format!(
|
let success_message = format!("Successfully added liquidity: {} {} and {} {}",
|
||||||
"Successfully added liquidity: {} {} and {} {}",
|
form.first_amount, form.first_token, form.second_amount, form.second_token);
|
||||||
form.first_amount, form.first_token, form.second_amount, form.second_token
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process staking request
|
// Process staking request
|
||||||
pub async fn create_staking(
|
pub async fn create_staking(_tmpl: web::Data<Tera>, form: web::Form<StakingForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<StakingForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing staking request: {:?}", form);
|
println!("DEBUG: Processing staking request: {:?}", form);
|
||||||
|
|
||||||
// In a real application, this would create a staking position in the database
|
// In a real application, this would create a staking position in the database
|
||||||
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
|
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process token swap
|
// Process token swap
|
||||||
pub async fn swap_tokens(
|
pub async fn swap_tokens(_tmpl: web::Data<Tera>, form: web::Form<SwapForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<SwapForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing token swap: {:?}", form);
|
println!("DEBUG: Processing token swap: {:?}", form);
|
||||||
|
|
||||||
// In a real application, this would perform a token swap in the database
|
// In a real application, this would perform a token swap in the database
|
||||||
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
let success_message = format!(
|
let success_message = format!("Successfully swapped {} {} to {}",
|
||||||
"Successfully swapped {} {} to {}",
|
form.from_amount, form.from_token, form.to_token);
|
||||||
form.from_amount, form.from_token, form.to_token
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process collateral position creation
|
// Process collateral position creation
|
||||||
pub async fn create_collateral(
|
pub async fn create_collateral(_tmpl: web::Data<Tera>, form: web::Form<CollateralForm>) -> Result<HttpResponse> {
|
||||||
_tmpl: web::Data<Tera>,
|
|
||||||
form: web::Form<CollateralForm>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
println!("DEBUG: Processing collateral creation: {:?}", form);
|
println!("DEBUG: Processing collateral creation: {:?}", form);
|
||||||
|
|
||||||
// In a real application, this would create a collateral position in the database
|
// In a real application, this would create a collateral position in the database
|
||||||
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
let purpose_str = match form.purpose.as_str() {
|
let purpose_str = match form.purpose.as_str() {
|
||||||
"funds" => "secure a funds",
|
"funds" => "secure a funds",
|
||||||
"synthetic" => "generate synthetic assets",
|
"synthetic" => "generate synthetic assets",
|
||||||
"leverage" => "leverage trading",
|
"leverage" => "leverage trading",
|
||||||
_ => "collateralization",
|
_ => "collateralization",
|
||||||
};
|
};
|
||||||
|
|
||||||
let success_message = format!(
|
let success_message = format!("Successfully collateralized {} {} for {}",
|
||||||
"Successfully collateralized {} {} for {}",
|
form.amount, form.asset_id, purpose_str);
|
||||||
form.amount, form.asset_id, purpose_str
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header((
|
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||||
"Location",
|
|
||||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get DeFi statistics
|
// Helper method to get DeFi statistics
|
||||||
fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> {
|
fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> {
|
||||||
let mut stats = serde_json::Map::new();
|
let mut stats = serde_json::Map::new();
|
||||||
|
|
||||||
// Handle Option<Number> by unwrapping with expect
|
// Handle Option<Number> by unwrapping with expect
|
||||||
stats.insert(
|
stats.insert("total_value_locked".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(1250000.0).expect("Valid float")));
|
||||||
"total_value_locked".to_string(),
|
stats.insert("providing_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float")));
|
||||||
serde_json::Value::Number(
|
stats.insert("receiving_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float")));
|
||||||
serde_json::Number::from_f64(1250000.0).expect("Valid float"),
|
stats.insert("liquidity_pools_count".to_string(), serde_json::Value::Number(serde_json::Number::from(12)));
|
||||||
),
|
stats.insert("active_stakers".to_string(), serde_json::Value::Number(serde_json::Number::from(156)));
|
||||||
);
|
stats.insert("total_swap_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float")));
|
||||||
stats.insert(
|
|
||||||
"providing_volume".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float")),
|
|
||||||
);
|
|
||||||
stats.insert(
|
|
||||||
"receiving_volume".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float")),
|
|
||||||
);
|
|
||||||
stats.insert(
|
|
||||||
"liquidity_pools_count".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(12)),
|
|
||||||
);
|
|
||||||
stats.insert(
|
|
||||||
"active_stakers".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(156)),
|
|
||||||
);
|
|
||||||
stats.insert(
|
|
||||||
"total_swap_volume".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float")),
|
|
||||||
);
|
|
||||||
|
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to convert Asset to a JSON object for templates
|
// Helper method to convert Asset to a JSON object for templates
|
||||||
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
|
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
|
||||||
let mut map = serde_json::Map::new();
|
let mut map = serde_json::Map::new();
|
||||||
|
|
||||||
map.insert(
|
map.insert("id".to_string(), serde_json::Value::String(asset.id.clone()));
|
||||||
"id".to_string(),
|
map.insert("name".to_string(), serde_json::Value::String(asset.name.clone()));
|
||||||
serde_json::Value::String(asset.id.clone()),
|
map.insert("description".to_string(), serde_json::Value::String(asset.description.clone()));
|
||||||
);
|
map.insert("asset_type".to_string(), serde_json::Value::String(asset.asset_type.as_str().to_string()));
|
||||||
map.insert(
|
map.insert("status".to_string(), serde_json::Value::String(asset.status.as_str().to_string()));
|
||||||
"name".to_string(),
|
|
||||||
serde_json::Value::String(asset.name.clone()),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"description".to_string(),
|
|
||||||
serde_json::Value::String(asset.description.clone()),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"asset_type".to_string(),
|
|
||||||
serde_json::Value::String(asset.asset_type.as_str().to_string()),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"status".to_string(),
|
|
||||||
serde_json::Value::String(asset.status.as_str().to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add current valuation
|
// Add current valuation
|
||||||
if let Some(latest) = asset.latest_valuation() {
|
if let Some(latest) = asset.latest_valuation() {
|
||||||
if let Some(num) = serde_json::Number::from_f64(latest.value) {
|
if let Some(num) = serde_json::Number::from_f64(latest.value) {
|
||||||
map.insert(
|
map.insert("current_valuation".to_string(), serde_json::Value::Number(num));
|
||||||
"current_valuation".to_string(),
|
|
||||||
serde_json::Value::Number(num),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
map.insert(
|
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0)));
|
||||||
"current_valuation".to_string(),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(0)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
map.insert(
|
map.insert("valuation_currency".to_string(), serde_json::Value::String(latest.currency.clone()));
|
||||||
"valuation_currency".to_string(),
|
map.insert("valuation_date".to_string(), serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string()));
|
||||||
serde_json::Value::String(latest.currency.clone()),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"valuation_date".to_string(),
|
|
||||||
serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
map.insert(
|
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0)));
|
||||||
"current_valuation".to_string(),
|
map.insert("valuation_currency".to_string(), serde_json::Value::String("USD".to_string()));
|
||||||
serde_json::Value::Number(serde_json::Number::from(0)),
|
map.insert("valuation_date".to_string(), serde_json::Value::String("N/A".to_string()));
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"valuation_currency".to_string(),
|
|
||||||
serde_json::Value::String("USD".to_string()),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
"valuation_date".to_string(),
|
|
||||||
serde_json::Value::String("N/A".to_string()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate mock assets for testing
|
// Generate mock assets for testing
|
||||||
fn get_mock_assets() -> Vec<Asset> {
|
fn get_mock_assets() -> Vec<Asset> {
|
||||||
// Reuse the asset controller's mock data function
|
// Reuse the asset controller's mock data function
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
use crate::controllers::error::render_company_not_found;
|
|
||||||
use crate::db::{company::get_company_by_id, document::*};
|
|
||||||
use crate::models::document::{DocumentStatistics, DocumentType};
|
|
||||||
use crate::utils::render_template;
|
|
||||||
use actix_multipart::Multipart;
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
|
||||||
use futures_util::stream::StreamExt as _;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::Path;
|
|
||||||
use tera::{Context, Tera};
|
|
||||||
|
|
||||||
// Form structs removed - not currently used in document operations
|
|
||||||
|
|
||||||
pub struct DocumentController;
|
|
||||||
|
|
||||||
impl DocumentController {
|
|
||||||
/// Display company documents management page
|
|
||||||
pub async fn index(
|
|
||||||
tmpl: web::Data<Tera>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
req: HttpRequest,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
let company_id_str = path.into_inner();
|
|
||||||
let mut context = Context::new();
|
|
||||||
|
|
||||||
// Add active_page for navigation highlighting
|
|
||||||
context.insert("active_page", &"company");
|
|
||||||
|
|
||||||
// Parse query parameters for success/error messages
|
|
||||||
let query_string = req.query_string();
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
if let Some(pos) = query_string.find("success=") {
|
|
||||||
let start = pos + 8;
|
|
||||||
let end = query_string[start..]
|
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let success = &query_string[start..end];
|
|
||||||
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
|
||||||
context.insert("success", &decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error message
|
|
||||||
if let Some(pos) = query_string.find("error=") {
|
|
||||||
let start = pos + 6;
|
|
||||||
let end = query_string[start..]
|
|
||||||
.find('&')
|
|
||||||
.map_or(query_string.len(), |e| e + start);
|
|
||||||
let error = &query_string[start..end];
|
|
||||||
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
|
||||||
context.insert("error", &decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse company ID
|
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch company from database
|
|
||||||
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
|
||||||
context.insert("company", &company);
|
|
||||||
context.insert("company_id", &company_id);
|
|
||||||
|
|
||||||
// Get documents for this company
|
|
||||||
let documents = match get_company_documents(company_id) {
|
|
||||||
Ok(documents) => documents,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get documents for company {}: {}", company_id, e);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
let stats = DocumentStatistics::new(&documents);
|
|
||||||
context.insert("documents", &documents);
|
|
||||||
context.insert("stats", &stats);
|
|
||||||
|
|
||||||
// Add document types for dropdown (as template-friendly tuples)
|
|
||||||
let document_types: Vec<(String, String)> = DocumentType::all()
|
|
||||||
.into_iter()
|
|
||||||
.map(|dt| (format!("{:?}", dt), dt.as_str().to_string()))
|
|
||||||
.collect();
|
|
||||||
context.insert("document_types", &document_types);
|
|
||||||
|
|
||||||
render_template(&tmpl, "company/documents.html", &context)
|
|
||||||
} else {
|
|
||||||
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle document upload
|
|
||||||
pub async fn upload(path: web::Path<String>, mut payload: Multipart) -> Result<HttpResponse> {
|
|
||||||
use actix_web::http::header;
|
|
||||||
|
|
||||||
let company_id_str = path.into_inner();
|
|
||||||
log::info!("Document upload request for company: {}", company_id_str);
|
|
||||||
|
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Invalid company ID",
|
|
||||||
company_id_str
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut form_fields: HashMap<String, String> = HashMap::new();
|
|
||||||
let mut uploaded_files = Vec::new();
|
|
||||||
|
|
||||||
// Parse multipart form
|
|
||||||
log::info!("Starting multipart form parsing");
|
|
||||||
while let Some(Ok(mut field)) = payload.next().await {
|
|
||||||
let content_disposition = field.content_disposition();
|
|
||||||
let field_name = content_disposition
|
|
||||||
.get_name()
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
let filename = content_disposition.get_filename().map(|f| f.to_string());
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Processing field: {} (filename: {:?})",
|
|
||||||
field_name,
|
|
||||||
filename
|
|
||||||
);
|
|
||||||
|
|
||||||
if field_name == "documents" {
|
|
||||||
// Handle file upload
|
|
||||||
if let Some(filename) = filename {
|
|
||||||
let mut file_data = Vec::new();
|
|
||||||
while let Some(chunk) = field.next().await {
|
|
||||||
let data = chunk.unwrap();
|
|
||||||
file_data.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !file_data.is_empty() {
|
|
||||||
uploaded_files.push((filename, file_data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle form fields
|
|
||||||
let mut field_data = Vec::new();
|
|
||||||
while let Some(chunk) = field.next().await {
|
|
||||||
let data = chunk.unwrap();
|
|
||||||
field_data.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
let field_value = String::from_utf8_lossy(&field_data).to_string();
|
|
||||||
form_fields.insert(field_name, field_value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Multipart parsing complete. Files: {}, Form fields: {:?}",
|
|
||||||
uploaded_files.len(),
|
|
||||||
form_fields
|
|
||||||
);
|
|
||||||
|
|
||||||
if uploaded_files.is_empty() {
|
|
||||||
log::warn!("No files uploaded");
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!("/company/documents/{}?error=No files selected", company_id),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
|
||||||
let upload_dir = format!("/tmp/company_{}_documents", company_id);
|
|
||||||
if let Err(e) = fs::create_dir_all(&upload_dir) {
|
|
||||||
log::error!("Failed to create upload directory: {}", e);
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Failed to create upload directory",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
let document_type = DocumentType::from_str(
|
|
||||||
&form_fields
|
|
||||||
.get("document_type")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let description = form_fields.get("description").cloned();
|
|
||||||
let is_public = form_fields.get("is_public").map_or(false, |v| v == "on");
|
|
||||||
|
|
||||||
let mut success_count = 0;
|
|
||||||
let mut error_count = 0;
|
|
||||||
|
|
||||||
// Process each uploaded file
|
|
||||||
for (filename, file_data) in uploaded_files {
|
|
||||||
let file_path = format!("{}/{}", upload_dir, filename);
|
|
||||||
|
|
||||||
// Save file to disk
|
|
||||||
match fs::File::create(&file_path) {
|
|
||||||
Ok(mut file) => {
|
|
||||||
if let Err(e) = file.write_all(&file_data) {
|
|
||||||
log::error!("Failed to write file {}: {}", filename, e);
|
|
||||||
error_count += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to create file {}: {}", filename, e);
|
|
||||||
error_count += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine MIME type based on file extension
|
|
||||||
let mime_type = match Path::new(&filename)
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.map(|ext| ext.to_lowercase())
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("pdf") => "application/pdf",
|
|
||||||
Some("doc") | Some("docx") => "application/msword",
|
|
||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
||||||
Some("png") => "image/png",
|
|
||||||
Some("txt") => "text/plain",
|
|
||||||
_ => "application/octet-stream",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save document to database
|
|
||||||
match create_new_document(
|
|
||||||
filename.clone(),
|
|
||||||
file_path,
|
|
||||||
file_data.len() as u64,
|
|
||||||
mime_type.to_string(),
|
|
||||||
company_id,
|
|
||||||
"System".to_string(), // TODO: Use actual logged-in user
|
|
||||||
document_type.clone(),
|
|
||||||
description.clone(),
|
|
||||||
is_public,
|
|
||||||
None, // TODO: Calculate checksum
|
|
||||||
) {
|
|
||||||
Ok(_) => success_count += 1,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to save document {} to database: {}", filename, e);
|
|
||||||
error_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = if error_count == 0 {
|
|
||||||
format!("Successfully uploaded {} document(s)", success_count)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"Uploaded {} document(s), {} failed",
|
|
||||||
success_count, error_count
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?success={}",
|
|
||||||
company_id,
|
|
||||||
urlencoding::encode(&message)
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a document
|
|
||||||
pub async fn delete(path: web::Path<(String, String)>) -> Result<HttpResponse> {
|
|
||||||
use actix_web::http::header;
|
|
||||||
|
|
||||||
let (company_id_str, document_id_str) = path.into_inner();
|
|
||||||
let company_id = match company_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Invalid company ID",
|
|
||||||
company_id_str
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let document_id = match document_id_str.parse::<u32>() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Invalid document ID",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get document to check if it exists and belongs to the company
|
|
||||||
match get_document_by_id(document_id) {
|
|
||||||
Ok(Some(document)) => {
|
|
||||||
if document.company_id != company_id {
|
|
||||||
return Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!("/company/documents/{}?error=Document not found", company_id),
|
|
||||||
))
|
|
||||||
.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete file from disk
|
|
||||||
if let Err(e) = fs::remove_file(&document.file_path) {
|
|
||||||
log::warn!("Failed to delete file {}: {}", document.file_path, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
match delete_document(document_id) {
|
|
||||||
Ok(_) => {
|
|
||||||
let message = format!("Successfully deleted document '{}'", document.name);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?success={}",
|
|
||||||
company_id,
|
|
||||||
urlencoding::encode(&message)
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to delete document from database: {}", e);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Failed to delete document",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!("/company/documents/{}?error=Document not found", company_id),
|
|
||||||
))
|
|
||||||
.finish()),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get document: {}", e);
|
|
||||||
Ok(HttpResponse::SeeOther()
|
|
||||||
.append_header((
|
|
||||||
header::LOCATION,
|
|
||||||
format!(
|
|
||||||
"/company/documents/{}?error=Failed to access document",
|
|
||||||
company_id
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.finish())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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<Tera>,
|
|
||||||
error_title: Option<&str>,
|
|
||||||
error_message: Option<&str>,
|
|
||||||
return_url: Option<&str>,
|
|
||||||
return_text: Option<&str>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
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<Tera>,
|
|
||||||
contract_id: Option<&str>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendar_event_not_found removed - not used
|
|
||||||
|
|
||||||
/// Renders a 404 page for company not found
|
|
||||||
pub async fn company_not_found(
|
|
||||||
tmpl: web::Data<Tera>,
|
|
||||||
company_id: Option<&str>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
let error_title = "Company Not Found";
|
|
||||||
let error_message = if let Some(id) = company_id {
|
|
||||||
format!(
|
|
||||||
"The company with ID '{}' doesn't exist or has been removed.",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"The company you're looking for doesn't exist or has been removed.".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
Self::not_found(
|
|
||||||
tmpl,
|
|
||||||
Some(error_title),
|
|
||||||
Some(&error_message),
|
|
||||||
Some("/company"),
|
|
||||||
Some("Back to Companies"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders a generic 404 page
|
|
||||||
pub async fn generic_not_found(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
|
|
||||||
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<Tera>,
|
|
||||||
contract_id: Option<&str>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
ErrorController::contract_not_found(tmpl.clone(), contract_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// render_calendar_event_not_found removed - not used
|
|
||||||
|
|
||||||
/// Helper function to quickly render a company not found response
|
|
||||||
pub async fn render_company_not_found(
|
|
||||||
tmpl: &web::Data<Tera>,
|
|
||||||
company_id: Option<&str>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
ErrorController::company_not_found(tmpl.clone(), company_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to quickly render a generic not found response
|
|
||||||
pub async fn render_generic_not_found(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
|
|
||||||
ErrorController::generic_not_found(tmpl).await
|
|
||||||
}
|
|
||||||
@@ -609,7 +609,6 @@ impl FlowController {
|
|||||||
|
|
||||||
/// Form for creating a new flow
|
/// Form for creating a new flow
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct FlowForm {
|
pub struct FlowForm {
|
||||||
/// Flow name
|
/// Flow name
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -621,7 +620,6 @@ pub struct FlowForm {
|
|||||||
|
|
||||||
/// Form for marking a step as stuck
|
/// Form for marking a step as stuck
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct StuckForm {
|
pub struct StuckForm {
|
||||||
/// Reason for being stuck
|
/// Reason for being stuck
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
@@ -629,7 +627,6 @@ pub struct StuckForm {
|
|||||||
|
|
||||||
/// Form for adding a log to a step
|
/// Form for adding a log to a step
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct LogForm {
|
pub struct LogForm {
|
||||||
/// Log message
|
/// Log message
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,418 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, Result, web};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct HealthStatus {
|
|
||||||
pub status: String,
|
|
||||||
pub timestamp: String,
|
|
||||||
pub version: String,
|
|
||||||
pub uptime_seconds: u64,
|
|
||||||
pub checks: Vec<HealthCheck>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct HealthCheck {
|
|
||||||
pub name: String,
|
|
||||||
pub status: String,
|
|
||||||
pub response_time_ms: u64,
|
|
||||||
pub message: Option<String>,
|
|
||||||
pub details: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HealthStatus {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
status: "unknown".to_string(),
|
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
uptime_seconds: 0,
|
|
||||||
checks: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_uptime(&mut self, uptime: Duration) {
|
|
||||||
self.uptime_seconds = uptime.as_secs();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_check(&mut self, check: HealthCheck) {
|
|
||||||
self.checks.push(check);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn calculate_overall_status(&mut self) {
|
|
||||||
let all_healthy = self.checks.iter().all(|check| check.status == "healthy");
|
|
||||||
let any_degraded = self.checks.iter().any(|check| check.status == "degraded");
|
|
||||||
|
|
||||||
self.status = if all_healthy {
|
|
||||||
"healthy".to_string()
|
|
||||||
} else if any_degraded {
|
|
||||||
"degraded".to_string()
|
|
||||||
} else {
|
|
||||||
"unhealthy".to_string()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HealthCheck {
|
|
||||||
pub fn new(name: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_string(),
|
|
||||||
status: "unknown".to_string(),
|
|
||||||
response_time_ms: 0,
|
|
||||||
message: None,
|
|
||||||
details: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn healthy(name: &str, response_time_ms: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_string(),
|
|
||||||
status: "healthy".to_string(),
|
|
||||||
response_time_ms,
|
|
||||||
message: Some("OK".to_string()),
|
|
||||||
details: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn degraded(name: &str, response_time_ms: u64, message: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_string(),
|
|
||||||
status: "degraded".to_string(),
|
|
||||||
response_time_ms,
|
|
||||||
message: Some(message.to_string()),
|
|
||||||
details: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unhealthy(name: &str, response_time_ms: u64, error: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_string(),
|
|
||||||
status: "unhealthy".to_string(),
|
|
||||||
response_time_ms,
|
|
||||||
message: Some(error.to_string()),
|
|
||||||
details: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
|
||||||
self.details = Some(details);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Health check endpoint
|
|
||||||
pub async fn health_check() -> Result<HttpResponse> {
|
|
||||||
let start_time = Instant::now();
|
|
||||||
let mut status = HealthStatus::new();
|
|
||||||
|
|
||||||
// Set uptime (in a real app, you'd track this from startup)
|
|
||||||
status.set_uptime(Duration::from_secs(3600)); // Placeholder
|
|
||||||
|
|
||||||
// Check database connectivity
|
|
||||||
let db_check = check_database_health().await;
|
|
||||||
status.add_check(db_check);
|
|
||||||
|
|
||||||
// Check Redis connectivity
|
|
||||||
let redis_check = check_redis_health().await;
|
|
||||||
status.add_check(redis_check);
|
|
||||||
|
|
||||||
// Check Stripe connectivity
|
|
||||||
let stripe_check = check_stripe_health().await;
|
|
||||||
status.add_check(stripe_check);
|
|
||||||
|
|
||||||
// Check file system
|
|
||||||
let fs_check = check_filesystem_health().await;
|
|
||||||
status.add_check(fs_check);
|
|
||||||
|
|
||||||
// Check memory usage
|
|
||||||
let memory_check = check_memory_health().await;
|
|
||||||
status.add_check(memory_check);
|
|
||||||
|
|
||||||
// Calculate overall status
|
|
||||||
status.calculate_overall_status();
|
|
||||||
|
|
||||||
let response_code = match status.status.as_str() {
|
|
||||||
"healthy" => 200,
|
|
||||||
"degraded" => 200, // Still operational
|
|
||||||
_ => 503, // Service unavailable
|
|
||||||
};
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Health check completed in {}ms - Status: {}",
|
|
||||||
start_time.elapsed().as_millis(),
|
|
||||||
status.status
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(
|
|
||||||
HttpResponse::build(actix_web::http::StatusCode::from_u16(response_code).unwrap())
|
|
||||||
.json(status),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detailed health check endpoint for monitoring systems
|
|
||||||
pub async fn health_check_detailed() -> Result<HttpResponse> {
|
|
||||||
let start_time = Instant::now();
|
|
||||||
let mut status = HealthStatus::new();
|
|
||||||
|
|
||||||
// Set uptime
|
|
||||||
status.set_uptime(Duration::from_secs(3600)); // Placeholder
|
|
||||||
|
|
||||||
// Detailed database check
|
|
||||||
let db_check = check_database_health_detailed().await;
|
|
||||||
status.add_check(db_check);
|
|
||||||
|
|
||||||
// Detailed Redis check
|
|
||||||
let redis_check = check_redis_health_detailed().await;
|
|
||||||
status.add_check(redis_check);
|
|
||||||
|
|
||||||
// Detailed Stripe check
|
|
||||||
let stripe_check = check_stripe_health_detailed().await;
|
|
||||||
status.add_check(stripe_check);
|
|
||||||
|
|
||||||
// Check external dependencies
|
|
||||||
let external_check = check_external_dependencies().await;
|
|
||||||
status.add_check(external_check);
|
|
||||||
|
|
||||||
// Performance metrics
|
|
||||||
let perf_check = check_performance_metrics().await;
|
|
||||||
status.add_check(perf_check);
|
|
||||||
|
|
||||||
status.calculate_overall_status();
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Detailed health check completed in {}ms - Status: {}",
|
|
||||||
start_time.elapsed().as_millis(),
|
|
||||||
status.status
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(status))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple readiness check for load balancers
|
|
||||||
pub async fn readiness_check() -> Result<HttpResponse> {
|
|
||||||
// Quick checks for essential services
|
|
||||||
let db_ok = check_database_connectivity().await;
|
|
||||||
let redis_ok = check_redis_connectivity().await;
|
|
||||||
|
|
||||||
if db_ok && redis_ok {
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
|
||||||
"status": "ready",
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
|
||||||
})))
|
|
||||||
} else {
|
|
||||||
Ok(HttpResponse::ServiceUnavailable().json(serde_json::json!({
|
|
||||||
"status": "not_ready",
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple liveness check
|
|
||||||
pub async fn liveness_check() -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
|
||||||
"status": "alive",
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
||||||
"version": env!("CARGO_PKG_VERSION")
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health check implementations
|
|
||||||
|
|
||||||
async fn check_database_health() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
match crate::db::db::get_db() {
|
|
||||||
Ok(_) => HealthCheck::healthy("database", start.elapsed().as_millis() as u64),
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"database",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Database connection failed: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_database_health_detailed() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
match crate::db::db::get_db() {
|
|
||||||
Ok(db) => {
|
|
||||||
// Try to perform a simple operation
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"connection_pool_size": "N/A", // Would need to expose from heromodels
|
|
||||||
"active_connections": "N/A",
|
|
||||||
"database_size": "N/A"
|
|
||||||
});
|
|
||||||
|
|
||||||
HealthCheck::healthy("database", start.elapsed().as_millis() as u64)
|
|
||||||
.with_details(details)
|
|
||||||
}
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"database",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Database connection failed: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_redis_health() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Try to connect to Redis
|
|
||||||
match crate::utils::redis_service::get_connection() {
|
|
||||||
Ok(_) => HealthCheck::healthy("redis", start.elapsed().as_millis() as u64),
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"redis",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Redis connection failed: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_redis_health_detailed() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
match crate::utils::redis_service::get_connection() {
|
|
||||||
Ok(_) => {
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"connection_status": "connected",
|
|
||||||
"memory_usage": "N/A",
|
|
||||||
"connected_clients": "N/A"
|
|
||||||
});
|
|
||||||
|
|
||||||
HealthCheck::healthy("redis", start.elapsed().as_millis() as u64).with_details(details)
|
|
||||||
}
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"redis",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Redis connection failed: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_stripe_health() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Check if Stripe configuration is available
|
|
||||||
let config = crate::config::get_config();
|
|
||||||
if !config.stripe.secret_key.is_empty() {
|
|
||||||
HealthCheck::healthy("stripe", start.elapsed().as_millis() as u64)
|
|
||||||
} else {
|
|
||||||
HealthCheck::degraded(
|
|
||||||
"stripe",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
"Stripe secret key not configured",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_stripe_health_detailed() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let config = crate::config::get_config();
|
|
||||||
let has_secret = !config.stripe.secret_key.is_empty();
|
|
||||||
let has_webhook_secret = config.stripe.webhook_secret.is_some();
|
|
||||||
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"secret_key_configured": has_secret,
|
|
||||||
"webhook_secret_configured": has_webhook_secret,
|
|
||||||
"api_version": "2023-10-16" // Current Stripe API version
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_secret && has_webhook_secret {
|
|
||||||
HealthCheck::healthy("stripe", start.elapsed().as_millis() as u64).with_details(details)
|
|
||||||
} else {
|
|
||||||
HealthCheck::degraded(
|
|
||||||
"stripe",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
"Stripe configuration incomplete",
|
|
||||||
)
|
|
||||||
.with_details(details)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_filesystem_health() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Check if we can write to the data directory
|
|
||||||
match std::fs::create_dir_all("data") {
|
|
||||||
Ok(_) => {
|
|
||||||
// Try to write a test file
|
|
||||||
match std::fs::write("data/.health_check", "test") {
|
|
||||||
Ok(_) => {
|
|
||||||
// Clean up
|
|
||||||
let _ = std::fs::remove_file("data/.health_check");
|
|
||||||
HealthCheck::healthy("filesystem", start.elapsed().as_millis() as u64)
|
|
||||||
}
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"filesystem",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Cannot write to data directory: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => HealthCheck::unhealthy(
|
|
||||||
"filesystem",
|
|
||||||
start.elapsed().as_millis() as u64,
|
|
||||||
&format!("Cannot create data directory: {}", e),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_memory_health() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Basic memory check (in a real app, you'd use system metrics)
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"status": "basic_check_only",
|
|
||||||
"note": "Detailed memory metrics require system integration"
|
|
||||||
});
|
|
||||||
|
|
||||||
HealthCheck::healthy("memory", start.elapsed().as_millis() as u64).with_details(details)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_external_dependencies() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Check external services (placeholder)
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"external_apis": "not_implemented",
|
|
||||||
"third_party_services": "not_implemented"
|
|
||||||
});
|
|
||||||
|
|
||||||
HealthCheck::healthy("external_dependencies", start.elapsed().as_millis() as u64)
|
|
||||||
.with_details(details)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_performance_metrics() -> HealthCheck {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"avg_response_time_ms": "N/A",
|
|
||||||
"requests_per_second": "N/A",
|
|
||||||
"error_rate": "N/A",
|
|
||||||
"cpu_usage": "N/A"
|
|
||||||
});
|
|
||||||
|
|
||||||
HealthCheck::healthy("performance", start.elapsed().as_millis() as u64).with_details(details)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick connectivity checks for readiness
|
|
||||||
|
|
||||||
async fn check_database_connectivity() -> bool {
|
|
||||||
crate::db::db::get_db().is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_redis_connectivity() -> bool {
|
|
||||||
crate::utils::redis_service::get_connection().is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure health check routes
|
|
||||||
pub fn configure_health_routes(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("/health")
|
|
||||||
.route("", web::get().to(health_check))
|
|
||||||
.route("/detailed", web::get().to(health_check_detailed))
|
|
||||||
.route("/ready", web::get().to(readiness_check))
|
|
||||||
.route("/live", web::get().to(liveness_check)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -96,7 +96,6 @@ impl HomeController {
|
|||||||
|
|
||||||
/// Represents the data submitted in the contact form
|
/// Represents the data submitted in the contact form
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ContactForm {
|
pub struct ContactForm {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use actix_web::{HttpResponse, Result, http, web};
|
use actix_web::{web, HttpResponse, Result, http};
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
use chrono::{Utc, Duration};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::asset::{Asset, AssetType, AssetStatus};
|
||||||
|
use crate::models::marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
|
||||||
use crate::controllers::asset::AssetController;
|
use crate::controllers::asset::AssetController;
|
||||||
use crate::models::asset::{Asset, AssetStatus, AssetType};
|
|
||||||
use crate::models::marketplace::{Listing, ListingStatus, ListingType, MarketplaceStatistics};
|
|
||||||
use crate::utils::render_template;
|
use crate::utils::render_template;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -21,7 +22,6 @@ pub struct ListingForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct BidForm {
|
pub struct BidForm {
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
@@ -38,33 +38,30 @@ impl MarketplaceController {
|
|||||||
// Display the marketplace dashboard
|
// Display the marketplace dashboard
|
||||||
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
let listings = Self::get_mock_listings();
|
let listings = Self::get_mock_listings();
|
||||||
let stats = MarketplaceStatistics::new(&listings);
|
let stats = MarketplaceStatistics::new(&listings);
|
||||||
|
|
||||||
// Get featured listings (up to 4)
|
// Get featured listings (up to 4)
|
||||||
let featured_listings: Vec<&Listing> = listings
|
let featured_listings: Vec<&Listing> = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.featured && l.status == ListingStatus::Active)
|
.filter(|l| l.featured && l.status == ListingStatus::Active)
|
||||||
.take(4)
|
.take(4)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Get recent listings (up to 8)
|
// Get recent listings (up to 8)
|
||||||
let mut recent_listings: Vec<&Listing> = listings
|
let mut recent_listings: Vec<&Listing> = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.status == ListingStatus::Active)
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by created_at (newest first)
|
// Sort by created_at (newest first)
|
||||||
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
|
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
|
||||||
|
|
||||||
// Get recent sales (up to 5)
|
// Get recent sales (up to 5)
|
||||||
let mut recent_sales: Vec<&Listing> = listings
|
let mut recent_sales: Vec<&Listing> = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.status == ListingStatus::Sold)
|
.filter(|l| l.status == ListingStatus::Sold)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by sold_at (newest first)
|
// Sort by sold_at (newest first)
|
||||||
recent_sales.sort_by(|a, b| {
|
recent_sales.sort_by(|a, b| {
|
||||||
let a_sold = a.sold_at.unwrap_or(a.created_at);
|
let a_sold = a.sold_at.unwrap_or(a.created_at);
|
||||||
@@ -72,101 +69,88 @@ impl MarketplaceController {
|
|||||||
b_sold.cmp(&a_sold)
|
b_sold.cmp(&a_sold)
|
||||||
});
|
});
|
||||||
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
|
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
|
||||||
|
|
||||||
// Add data to context
|
// Add data to context
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("stats", &stats);
|
context.insert("stats", &stats);
|
||||||
context.insert("featured_listings", &featured_listings);
|
context.insert("featured_listings", &featured_listings);
|
||||||
context.insert("recent_listings", &recent_listings);
|
context.insert("recent_listings", &recent_listings);
|
||||||
context.insert("recent_sales", &recent_sales);
|
context.insert("recent_sales", &recent_sales);
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/index.html", &context)
|
render_template(&tmpl, "marketplace/index.html", &context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display all marketplace listings
|
// Display all marketplace listings
|
||||||
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
let listings = Self::get_mock_listings();
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
// Filter active listings
|
// Filter active listings
|
||||||
let active_listings: Vec<&Listing> = listings
|
let active_listings: Vec<&Listing> = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.status == ListingStatus::Active)
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("listings", &active_listings);
|
context.insert("listings", &active_listings);
|
||||||
context.insert(
|
context.insert("listing_types", &[
|
||||||
"listing_types",
|
ListingType::FixedPrice.as_str(),
|
||||||
&[
|
ListingType::Auction.as_str(),
|
||||||
ListingType::FixedPrice.as_str(),
|
ListingType::Exchange.as_str(),
|
||||||
ListingType::Auction.as_str(),
|
]);
|
||||||
ListingType::Exchange.as_str(),
|
context.insert("asset_types", &[
|
||||||
],
|
AssetType::Token.as_str(),
|
||||||
);
|
AssetType::Artwork.as_str(),
|
||||||
context.insert(
|
AssetType::RealEstate.as_str(),
|
||||||
"asset_types",
|
AssetType::IntellectualProperty.as_str(),
|
||||||
&[
|
AssetType::Commodity.as_str(),
|
||||||
AssetType::Token.as_str(),
|
AssetType::Share.as_str(),
|
||||||
AssetType::Artwork.as_str(),
|
AssetType::Bond.as_str(),
|
||||||
AssetType::RealEstate.as_str(),
|
AssetType::Other.as_str(),
|
||||||
AssetType::IntellectualProperty.as_str(),
|
]);
|
||||||
AssetType::Commodity.as_str(),
|
|
||||||
AssetType::Share.as_str(),
|
|
||||||
AssetType::Bond.as_str(),
|
|
||||||
AssetType::Other.as_str(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/listings.html", &context)
|
render_template(&tmpl, "marketplace/listings.html", &context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display my listings
|
// Display my listings
|
||||||
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
let listings = Self::get_mock_listings();
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
// Filter by current user (mock user ID)
|
// Filter by current user (mock user ID)
|
||||||
let user_id = "user-123";
|
let user_id = "user-123";
|
||||||
let my_listings: Vec<&Listing> =
|
let my_listings: Vec<&Listing> = listings.iter()
|
||||||
listings.iter().filter(|l| l.seller_id == user_id).collect();
|
.filter(|l| l.seller_id == user_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("listings", &my_listings);
|
context.insert("listings", &my_listings);
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/my_listings.html", &context)
|
render_template(&tmpl, "marketplace/my_listings.html", &context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display listing details
|
// Display listing details
|
||||||
pub async fn listing_detail(
|
pub async fn listing_detail(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> {
|
||||||
tmpl: web::Data<Tera>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
let listing_id = path.into_inner();
|
let listing_id = path.into_inner();
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
let listings = Self::get_mock_listings();
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
// Find the listing
|
// Find the listing
|
||||||
let listing = listings.iter().find(|l| l.id == listing_id);
|
let listing = listings.iter().find(|l| l.id == listing_id);
|
||||||
|
|
||||||
if let Some(listing) = listing {
|
if let Some(listing) = listing {
|
||||||
// Get similar listings (same asset type, active)
|
// Get similar listings (same asset type, active)
|
||||||
let similar_listings: Vec<&Listing> = listings
|
let similar_listings: Vec<&Listing> = listings.iter()
|
||||||
.iter()
|
.filter(|l| l.asset_type == listing.asset_type &&
|
||||||
.filter(|l| {
|
l.status == ListingStatus::Active &&
|
||||||
l.asset_type == listing.asset_type
|
l.id != listing.id)
|
||||||
&& l.status == ListingStatus::Active
|
|
||||||
&& l.id != listing.id
|
|
||||||
})
|
|
||||||
.take(4)
|
.take(4)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Get highest bid amount and minimum bid for auction listings
|
// Get highest bid amount and minimum bid for auction listings
|
||||||
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction
|
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction {
|
||||||
{
|
|
||||||
if let Some(bid) = listing.highest_bid() {
|
if let Some(bid) = listing.highest_bid() {
|
||||||
(Some(bid.amount), bid.amount + 1.0)
|
(Some(bid.amount), bid.amount + 1.0)
|
||||||
} else {
|
} else {
|
||||||
@@ -175,79 +159,74 @@ impl MarketplaceController {
|
|||||||
} else {
|
} else {
|
||||||
(None, 0.0)
|
(None, 0.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("listing", listing);
|
context.insert("listing", listing);
|
||||||
context.insert("similar_listings", &similar_listings);
|
context.insert("similar_listings", &similar_listings);
|
||||||
context.insert("highest_bid_amount", &highest_bid_amount);
|
context.insert("highest_bid_amount", &highest_bid_amount);
|
||||||
context.insert("minimum_bid", &minimum_bid);
|
context.insert("minimum_bid", &minimum_bid);
|
||||||
|
|
||||||
// Add current user info for bid/purchase forms
|
// Add current user info for bid/purchase forms
|
||||||
let user_id = "user-123";
|
let user_id = "user-123";
|
||||||
let user_name = "Alice Hostly";
|
let user_name = "Alice Hostly";
|
||||||
context.insert("user_id", &user_id);
|
context.insert("user_id", &user_id);
|
||||||
context.insert("user_name", &user_name);
|
context.insert("user_name", &user_name);
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/listing_detail.html", &context)
|
render_template(&tmpl, "marketplace/listing_detail.html", &context)
|
||||||
} else {
|
} else {
|
||||||
Ok(HttpResponse::NotFound().finish())
|
Ok(HttpResponse::NotFound().finish())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display create listing form
|
// Display create listing form
|
||||||
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
|
|
||||||
// Get user's assets for selection
|
// Get user's assets for selection
|
||||||
let assets = AssetController::get_mock_assets();
|
let assets = AssetController::get_mock_assets();
|
||||||
let user_id = "user-123"; // Mock user ID
|
let user_id = "user-123"; // Mock user ID
|
||||||
|
|
||||||
let user_assets: Vec<&Asset> = assets
|
let user_assets: Vec<&Asset> = assets.iter()
|
||||||
.iter()
|
|
||||||
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
|
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("assets", &user_assets);
|
context.insert("assets", &user_assets);
|
||||||
context.insert(
|
context.insert("listing_types", &[
|
||||||
"listing_types",
|
ListingType::FixedPrice.as_str(),
|
||||||
&[
|
ListingType::Auction.as_str(),
|
||||||
ListingType::FixedPrice.as_str(),
|
ListingType::Exchange.as_str(),
|
||||||
ListingType::Auction.as_str(),
|
]);
|
||||||
ListingType::Exchange.as_str(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new listing
|
// Create a new listing
|
||||||
pub async fn create_listing(
|
pub async fn create_listing(
|
||||||
tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
form: web::Form<ListingForm>,
|
form: web::Form<ListingForm>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let form = form.into_inner();
|
let form = form.into_inner();
|
||||||
|
|
||||||
// Get the asset details
|
// Get the asset details
|
||||||
let assets = AssetController::get_mock_assets();
|
let assets = AssetController::get_mock_assets();
|
||||||
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
||||||
|
|
||||||
if let Some(asset) = asset {
|
if let Some(asset) = asset {
|
||||||
// Process tags
|
// Process tags
|
||||||
let tags = match form.tags {
|
let tags = match form.tags {
|
||||||
Some(tags_str) => tags_str
|
Some(tags_str) => tags_str.split(',')
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect(),
|
.collect(),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate expiration date if provided
|
// Calculate expiration date if provided
|
||||||
let expires_at = form
|
let expires_at = form.duration_days.map(|days| {
|
||||||
.duration_days
|
Utc::now() + Duration::days(days as i64)
|
||||||
.map(|days| Utc::now() + Duration::days(days as i64));
|
});
|
||||||
|
|
||||||
// Parse listing type
|
// Parse listing type
|
||||||
let listing_type = match form.listing_type.as_str() {
|
let listing_type = match form.listing_type.as_str() {
|
||||||
"Fixed Price" => ListingType::FixedPrice,
|
"Fixed Price" => ListingType::FixedPrice,
|
||||||
@@ -255,11 +234,11 @@ impl MarketplaceController {
|
|||||||
"Exchange" => ListingType::Exchange,
|
"Exchange" => ListingType::Exchange,
|
||||||
_ => ListingType::FixedPrice,
|
_ => ListingType::FixedPrice,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock user data
|
// Mock user data
|
||||||
let user_id = "user-123";
|
let user_id = "user-123";
|
||||||
let user_name = "Alice Hostly";
|
let user_name = "Alice Hostly";
|
||||||
|
|
||||||
// Create the listing
|
// Create the listing
|
||||||
let _listing = Listing::new(
|
let _listing = Listing::new(
|
||||||
form.title,
|
form.title,
|
||||||
@@ -276,9 +255,9 @@ impl MarketplaceController {
|
|||||||
tags,
|
tags,
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// In a real application, we would save the listing to a database here
|
// In a real application, we would save the listing to a database here
|
||||||
|
|
||||||
// Redirect to the marketplace
|
// Redirect to the marketplace
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.insert_header((http::header::LOCATION, "/marketplace"))
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
||||||
@@ -288,101 +267,94 @@ impl MarketplaceController {
|
|||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
context.insert("active_page", &"marketplace");
|
context.insert("active_page", &"marketplace");
|
||||||
context.insert("error", &"Asset not found");
|
context.insert("error", &"Asset not found");
|
||||||
|
|
||||||
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit a bid on an auction listing
|
// Submit a bid on an auction listing
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn submit_bid(
|
pub async fn submit_bid(
|
||||||
_tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
_form: web::Form<BidForm>,
|
form: web::Form<BidForm>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let listing_id = path.into_inner();
|
let listing_id = path.into_inner();
|
||||||
let _form = _form.into_inner();
|
let form = form.into_inner();
|
||||||
|
|
||||||
// In a real application, we would:
|
// In a real application, we would:
|
||||||
// 1. Find the listing in the database
|
// 1. Find the listing in the database
|
||||||
// 2. Validate the bid
|
// 2. Validate the bid
|
||||||
// 3. Create the bid
|
// 3. Create the bid
|
||||||
// 4. Save it to the database
|
// 4. Save it to the database
|
||||||
|
|
||||||
// For now, we'll just redirect back to the listing
|
// For now, we'll just redirect back to the listing
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.insert_header((
|
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
||||||
http::header::LOCATION,
|
|
||||||
format!("/marketplace/{}", listing_id),
|
|
||||||
))
|
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchase a fixed-price listing
|
// Purchase a fixed-price listing
|
||||||
pub async fn purchase_listing(
|
pub async fn purchase_listing(
|
||||||
_tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
form: web::Form<PurchaseForm>,
|
form: web::Form<PurchaseForm>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let listing_id = path.into_inner();
|
let listing_id = path.into_inner();
|
||||||
let form = form.into_inner();
|
let form = form.into_inner();
|
||||||
|
|
||||||
if !form.agree_to_terms {
|
if !form.agree_to_terms {
|
||||||
// User must agree to terms
|
// User must agree to terms
|
||||||
return Ok(HttpResponse::SeeOther()
|
return Ok(HttpResponse::SeeOther()
|
||||||
.insert_header((
|
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
||||||
http::header::LOCATION,
|
|
||||||
format!("/marketplace/{}", listing_id),
|
|
||||||
))
|
|
||||||
.finish());
|
.finish());
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real application, we would:
|
// In a real application, we would:
|
||||||
// 1. Find the listing in the database
|
// 1. Find the listing in the database
|
||||||
// 2. Validate the purchase
|
// 2. Validate the purchase
|
||||||
// 3. Process the transaction
|
// 3. Process the transaction
|
||||||
// 4. Update the listing status
|
// 4. Update the listing status
|
||||||
// 5. Transfer the asset
|
// 5. Transfer the asset
|
||||||
|
|
||||||
// For now, we'll just redirect to the marketplace
|
// For now, we'll just redirect to the marketplace
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.insert_header((http::header::LOCATION, "/marketplace"))
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel a listing
|
// Cancel a listing
|
||||||
pub async fn cancel_listing(
|
pub async fn cancel_listing(
|
||||||
_tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let _listing_id = path.into_inner();
|
let _listing_id = path.into_inner();
|
||||||
|
|
||||||
// In a real application, we would:
|
// In a real application, we would:
|
||||||
// 1. Find the listing in the database
|
// 1. Find the listing in the database
|
||||||
// 2. Validate that the current user is the seller
|
// 2. Validate that the current user is the seller
|
||||||
// 3. Update the listing status
|
// 3. Update the listing status
|
||||||
|
|
||||||
// For now, we'll just redirect to my listings
|
// For now, we'll just redirect to my listings
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.insert_header((http::header::LOCATION, "/marketplace/my"))
|
.insert_header((http::header::LOCATION, "/marketplace/my"))
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate mock listings for development
|
// Generate mock listings for development
|
||||||
pub fn get_mock_listings() -> Vec<Listing> {
|
pub fn get_mock_listings() -> Vec<Listing> {
|
||||||
let assets = AssetController::get_mock_assets();
|
let assets = AssetController::get_mock_assets();
|
||||||
let mut listings = Vec::new();
|
let mut listings = Vec::new();
|
||||||
|
|
||||||
// Mock user data
|
// Mock user data
|
||||||
let user_ids = vec!["user-123", "user-456", "user-789"];
|
let user_ids = vec!["user-123", "user-456", "user-789"];
|
||||||
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
|
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
|
||||||
|
|
||||||
// Create some fixed price listings
|
// Create some fixed price listings
|
||||||
for i in 0..6 {
|
for i in 0..6 {
|
||||||
let asset_index = i % assets.len();
|
let asset_index = i % assets.len();
|
||||||
let asset = &assets[asset_index];
|
let asset = &assets[asset_index];
|
||||||
let user_index = i % user_ids.len();
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
let price = match asset.asset_type {
|
let price = match asset.asset_type {
|
||||||
AssetType::Token => 50.0 + (i as f64 * 10.0),
|
AssetType::Token => 50.0 + (i as f64 * 10.0),
|
||||||
AssetType::Artwork => 500.0 + (i as f64 * 100.0),
|
AssetType::Artwork => 500.0 + (i as f64 * 100.0),
|
||||||
@@ -393,13 +365,10 @@ impl MarketplaceController {
|
|||||||
AssetType::Bond => 1500.0 + (i as f64 * 300.0),
|
AssetType::Bond => 1500.0 + (i as f64 * 300.0),
|
||||||
AssetType::Other => 800.0 + (i as f64 * 150.0),
|
AssetType::Other => 800.0 + (i as f64 * 150.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut listing = Listing::new(
|
let mut listing = Listing::new(
|
||||||
format!("{} for Sale", asset.name),
|
format!("{} for Sale", asset.name),
|
||||||
format!(
|
format!("This is a great opportunity to own {}. {}", asset.name, asset.description),
|
||||||
"This is a great opportunity to own {}. {}",
|
|
||||||
asset.name, asset.description
|
|
||||||
),
|
|
||||||
asset.id.clone(),
|
asset.id.clone(),
|
||||||
asset.name.clone(),
|
asset.name.clone(),
|
||||||
asset.asset_type.clone(),
|
asset.asset_type.clone(),
|
||||||
@@ -412,21 +381,21 @@ impl MarketplaceController {
|
|||||||
vec!["digital".to_string(), "asset".to_string()],
|
vec!["digital".to_string(), "asset".to_string()],
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make some listings featured
|
// Make some listings featured
|
||||||
if i % 5 == 0 {
|
if i % 5 == 0 {
|
||||||
listing.set_featured(true);
|
listing.set_featured(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
listings.push(listing);
|
listings.push(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create some auction listings
|
// Create some auction listings
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
let asset_index = (i + 6) % assets.len();
|
let asset_index = (i + 6) % assets.len();
|
||||||
let asset = &assets[asset_index];
|
let asset = &assets[asset_index];
|
||||||
let user_index = i % user_ids.len();
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
let starting_price = match asset.asset_type {
|
let starting_price = match asset.asset_type {
|
||||||
AssetType::Token => 40.0 + (i as f64 * 5.0),
|
AssetType::Token => 40.0 + (i as f64 * 5.0),
|
||||||
AssetType::Artwork => 400.0 + (i as f64 * 50.0),
|
AssetType::Artwork => 400.0 + (i as f64 * 50.0),
|
||||||
@@ -437,7 +406,7 @@ impl MarketplaceController {
|
|||||||
AssetType::Bond => 1200.0 + (i as f64 * 250.0),
|
AssetType::Bond => 1200.0 + (i as f64 * 250.0),
|
||||||
AssetType::Other => 600.0 + (i as f64 * 120.0),
|
AssetType::Other => 600.0 + (i as f64 * 120.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut listing = Listing::new(
|
let mut listing = Listing::new(
|
||||||
format!("Auction: {}", asset.name),
|
format!("Auction: {}", asset.name),
|
||||||
format!("Bid on this amazing {}. {}", asset.name, asset.description),
|
format!("Bid on this amazing {}. {}", asset.name, asset.description),
|
||||||
@@ -453,13 +422,12 @@ impl MarketplaceController {
|
|||||||
vec!["auction".to_string(), "bidding".to_string()],
|
vec!["auction".to_string(), "bidding".to_string()],
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add some bids to the auctions
|
// Add some bids to the auctions
|
||||||
let num_bids = 2 + (i % 3);
|
let num_bids = 2 + (i % 3);
|
||||||
for j in 0..num_bids {
|
for j in 0..num_bids {
|
||||||
let bidder_index = (j + 1) % user_ids.len();
|
let bidder_index = (j + 1) % user_ids.len();
|
||||||
if bidder_index != user_index {
|
if bidder_index != user_index { // Ensure seller isn't bidding
|
||||||
// Ensure seller isn't bidding
|
|
||||||
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
|
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
|
||||||
let _ = listing.add_bid(
|
let _ = listing.add_bid(
|
||||||
user_ids[bidder_index].to_string(),
|
user_ids[bidder_index].to_string(),
|
||||||
@@ -469,21 +437,21 @@ impl MarketplaceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make some listings featured
|
// Make some listings featured
|
||||||
if i % 3 == 0 {
|
if i % 3 == 0 {
|
||||||
listing.set_featured(true);
|
listing.set_featured(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
listings.push(listing);
|
listings.push(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create some exchange listings
|
// Create some exchange listings
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let asset_index = (i + 10) % assets.len();
|
let asset_index = (i + 10) % assets.len();
|
||||||
let asset = &assets[asset_index];
|
let asset = &assets[asset_index];
|
||||||
let user_index = i % user_ids.len();
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
let value = match asset.asset_type {
|
let value = match asset.asset_type {
|
||||||
AssetType::Token => 60.0 + (i as f64 * 15.0),
|
AssetType::Token => 60.0 + (i as f64 * 15.0),
|
||||||
AssetType::Artwork => 600.0 + (i as f64 * 150.0),
|
AssetType::Artwork => 600.0 + (i as f64 * 150.0),
|
||||||
@@ -494,36 +462,33 @@ impl MarketplaceController {
|
|||||||
AssetType::Bond => 1800.0 + (i as f64 * 350.0),
|
AssetType::Bond => 1800.0 + (i as f64 * 350.0),
|
||||||
AssetType::Other => 1000.0 + (i as f64 * 200.0),
|
AssetType::Other => 1000.0 + (i as f64 * 200.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let listing = Listing::new(
|
let listing = Listing::new(
|
||||||
format!("Trade: {}", asset.name),
|
format!("Trade: {}", asset.name),
|
||||||
format!(
|
format!("Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.", asset.name),
|
||||||
"Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.",
|
|
||||||
asset.name
|
|
||||||
),
|
|
||||||
asset.id.clone(),
|
asset.id.clone(),
|
||||||
asset.name.clone(),
|
asset.name.clone(),
|
||||||
asset.asset_type.clone(),
|
asset.asset_type.clone(),
|
||||||
user_ids[user_index].to_string(),
|
user_ids[user_index].to_string(),
|
||||||
user_names[user_index].to_string(),
|
user_names[user_index].to_string(),
|
||||||
value, // Estimated value for exchange
|
value, // Estimated value for exchange
|
||||||
"USD".to_string(),
|
"USD".to_string(),
|
||||||
ListingType::Exchange,
|
ListingType::Exchange,
|
||||||
Some(Utc::now() + Duration::days(60)),
|
Some(Utc::now() + Duration::days(60)),
|
||||||
vec!["exchange".to_string(), "trade".to_string()],
|
vec!["exchange".to_string(), "trade".to_string()],
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
listings.push(listing);
|
listings.push(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create some sold listings
|
// Create some sold listings
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
let asset_index = (i + 13) % assets.len();
|
let asset_index = (i + 13) % assets.len();
|
||||||
let asset = &assets[asset_index];
|
let asset = &assets[asset_index];
|
||||||
let seller_index = i % user_ids.len();
|
let seller_index = i % user_ids.len();
|
||||||
let buyer_index = (i + 1) % user_ids.len();
|
let buyer_index = (i + 1) % user_ids.len();
|
||||||
|
|
||||||
let price = match asset.asset_type {
|
let price = match asset.asset_type {
|
||||||
AssetType::Token => 55.0 + (i as f64 * 12.0),
|
AssetType::Token => 55.0 + (i as f64 * 12.0),
|
||||||
AssetType::Artwork => 550.0 + (i as f64 * 120.0),
|
AssetType::Artwork => 550.0 + (i as f64 * 120.0),
|
||||||
@@ -534,9 +499,9 @@ impl MarketplaceController {
|
|||||||
AssetType::Bond => 1650.0 + (i as f64 * 330.0),
|
AssetType::Bond => 1650.0 + (i as f64 * 330.0),
|
||||||
AssetType::Other => 900.0 + (i as f64 * 180.0),
|
AssetType::Other => 900.0 + (i as f64 * 180.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let sale_price = price * 0.95; // Slight discount on sale
|
let sale_price = price * 0.95; // Slight discount on sale
|
||||||
|
|
||||||
let mut listing = Listing::new(
|
let mut listing = Listing::new(
|
||||||
format!("{} - SOLD", asset.name),
|
format!("{} - SOLD", asset.name),
|
||||||
format!("This {} was sold recently.", asset.name),
|
format!("This {} was sold recently.", asset.name),
|
||||||
@@ -552,27 +517,27 @@ impl MarketplaceController {
|
|||||||
vec!["sold".to_string()],
|
vec!["sold".to_string()],
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as sold
|
// Mark as sold
|
||||||
let _ = listing.mark_as_sold(
|
let _ = listing.mark_as_sold(
|
||||||
user_ids[buyer_index].to_string(),
|
user_ids[buyer_index].to_string(),
|
||||||
user_names[buyer_index].to_string(),
|
user_names[buyer_index].to_string(),
|
||||||
sale_price,
|
sale_price,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set sold date to be sometime in the past
|
// Set sold date to be sometime in the past
|
||||||
let days_ago = i as i64 + 1;
|
let days_ago = i as i64 + 1;
|
||||||
listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
|
listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
|
||||||
|
|
||||||
listings.push(listing);
|
listings.push(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a few cancelled listings
|
// Create a few cancelled listings
|
||||||
for i in 0..2 {
|
for i in 0..2 {
|
||||||
let asset_index = (i + 18) % assets.len();
|
let asset_index = (i + 18) % assets.len();
|
||||||
let asset = &assets[asset_index];
|
let asset = &assets[asset_index];
|
||||||
let user_index = i % user_ids.len();
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
let price = match asset.asset_type {
|
let price = match asset.asset_type {
|
||||||
AssetType::Token => 45.0 + (i as f64 * 8.0),
|
AssetType::Token => 45.0 + (i as f64 * 8.0),
|
||||||
AssetType::Artwork => 450.0 + (i as f64 * 80.0),
|
AssetType::Artwork => 450.0 + (i as f64 * 80.0),
|
||||||
@@ -583,7 +548,7 @@ impl MarketplaceController {
|
|||||||
AssetType::Bond => 1350.0 + (i as f64 * 270.0),
|
AssetType::Bond => 1350.0 + (i as f64 * 270.0),
|
||||||
AssetType::Other => 750.0 + (i as f64 * 150.0),
|
AssetType::Other => 750.0 + (i as f64 * 150.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut listing = Listing::new(
|
let mut listing = Listing::new(
|
||||||
format!("{} - Cancelled", asset.name),
|
format!("{} - Cancelled", asset.name),
|
||||||
format!("This listing for {} was cancelled.", asset.name),
|
format!("This listing for {} was cancelled.", asset.name),
|
||||||
@@ -599,13 +564,13 @@ impl MarketplaceController {
|
|||||||
vec!["cancelled".to_string()],
|
vec!["cancelled".to_string()],
|
||||||
asset.image_url.clone(),
|
asset.image_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel the listing
|
// Cancel the listing
|
||||||
let _ = listing.cancel();
|
let _ = listing.cancel();
|
||||||
|
|
||||||
listings.push(listing);
|
listings.push(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
listings
|
listings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
// Export controllers
|
// Export controllers
|
||||||
pub mod asset;
|
|
||||||
pub mod auth;
|
|
||||||
pub mod calendar;
|
|
||||||
pub mod company;
|
|
||||||
pub mod contract;
|
|
||||||
pub mod defi;
|
|
||||||
pub mod document;
|
|
||||||
pub mod error;
|
|
||||||
pub mod flow;
|
|
||||||
pub mod governance;
|
|
||||||
pub mod health;
|
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod marketplace;
|
pub mod auth;
|
||||||
pub mod payment;
|
|
||||||
pub mod ticket;
|
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 company;
|
||||||
|
|
||||||
// Re-export controllers for easier imports
|
// Re-export controllers for easier imports
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,362 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use heromodels::{
|
|
||||||
db::{Collection, Db},
|
|
||||||
models::calendar::{AttendanceStatus, Attendee, Calendar, Event},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::db::get_db;
|
|
||||||
|
|
||||||
/// Creates a new calendar and saves it to the database. Returns the saved calendar and its ID.
|
|
||||||
pub fn create_new_calendar(
|
|
||||||
name: &str,
|
|
||||||
description: Option<&str>,
|
|
||||||
owner_id: Option<u32>,
|
|
||||||
is_public: bool,
|
|
||||||
color: Option<&str>,
|
|
||||||
) -> Result<(u32, Calendar), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create a new calendar (with auto-generated ID)
|
|
||||||
let mut calendar = Calendar::new(None, name);
|
|
||||||
|
|
||||||
if let Some(desc) = description {
|
|
||||||
calendar = calendar.description(desc);
|
|
||||||
}
|
|
||||||
if let Some(owner) = owner_id {
|
|
||||||
calendar = calendar.owner_id(owner);
|
|
||||||
}
|
|
||||||
if let Some(col) = color {
|
|
||||||
calendar = calendar.color(col);
|
|
||||||
}
|
|
||||||
|
|
||||||
calendar = calendar.is_public(is_public);
|
|
||||||
|
|
||||||
// Save the calendar to the database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.expect("can open calendar collection");
|
|
||||||
let (calendar_id, saved_calendar) = collection.set(&calendar).expect("can save calendar");
|
|
||||||
|
|
||||||
Ok((calendar_id, saved_calendar))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new event and saves it to the database. Returns the saved event and its ID.
|
|
||||||
pub fn create_new_event(
|
|
||||||
title: &str,
|
|
||||||
description: Option<&str>,
|
|
||||||
start_time: DateTime<Utc>,
|
|
||||||
end_time: DateTime<Utc>,
|
|
||||||
location: Option<&str>,
|
|
||||||
color: Option<&str>,
|
|
||||||
all_day: bool,
|
|
||||||
created_by: Option<u32>,
|
|
||||||
category: Option<&str>,
|
|
||||||
reminder_minutes: Option<i32>,
|
|
||||||
) -> Result<(u32, Event), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create a new event (with auto-generated ID)
|
|
||||||
let mut event = Event::new(title, start_time, end_time);
|
|
||||||
|
|
||||||
if let Some(desc) = description {
|
|
||||||
event = event.description(desc);
|
|
||||||
}
|
|
||||||
if let Some(loc) = location {
|
|
||||||
event = event.location(loc);
|
|
||||||
}
|
|
||||||
if let Some(col) = color {
|
|
||||||
event = event.color(col);
|
|
||||||
}
|
|
||||||
if let Some(user_id) = created_by {
|
|
||||||
event = event.created_by(user_id);
|
|
||||||
}
|
|
||||||
if let Some(cat) = category {
|
|
||||||
event = event.category(cat);
|
|
||||||
}
|
|
||||||
if let Some(reminder) = reminder_minutes {
|
|
||||||
event = event.reminder_minutes(reminder);
|
|
||||||
}
|
|
||||||
|
|
||||||
event = event.all_day(all_day);
|
|
||||||
|
|
||||||
// Save the event to the database
|
|
||||||
let collection = db.collection::<Event>().expect("can open event collection");
|
|
||||||
let (event_id, saved_event) = collection.set(&event).expect("can save event");
|
|
||||||
|
|
||||||
Ok((event_id, saved_event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all calendars from the database and returns them as a Vec<Calendar>.
|
|
||||||
pub fn get_calendars() -> Result<Vec<Calendar>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.expect("can open calendar collection");
|
|
||||||
|
|
||||||
// Try to load all calendars, but handle deserialization errors gracefully
|
|
||||||
let calendars = match collection.get_all() {
|
|
||||||
Ok(calendars) => calendars,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error loading calendars: {:?}", e);
|
|
||||||
vec![] // Return an empty vector if there's an error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(calendars)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all events from the database and returns them as a Vec<Event>.
|
|
||||||
pub fn get_events() -> Result<Vec<Event>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db.collection::<Event>().expect("can open event collection");
|
|
||||||
|
|
||||||
// Try to load all events, but handle deserialization errors gracefully
|
|
||||||
let events = match collection.get_all() {
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error loading events: {:?}", e);
|
|
||||||
vec![] // Return an empty vector if there's an error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single calendar by its ID from the database.
|
|
||||||
pub fn get_calendar_by_id(calendar_id: u32) -> Result<Option<Calendar>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(calendar_id) {
|
|
||||||
Ok(calendar) => Ok(calendar),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error fetching calendar by id {}: {:?}", calendar_id, e);
|
|
||||||
Err(format!("Failed to fetch calendar: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single event by its ID from the database.
|
|
||||||
pub fn get_event_by_id(event_id: u32) -> Result<Option<Event>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Event>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(event_id) {
|
|
||||||
Ok(event) => Ok(event),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error fetching event by id {}: {:?}", event_id, e);
|
|
||||||
Err(format!("Failed to fetch event: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new attendee and saves it to the database. Returns the saved attendee and its ID.
|
|
||||||
pub fn create_new_attendee(
|
|
||||||
contact_id: u32,
|
|
||||||
status: AttendanceStatus,
|
|
||||||
) -> Result<(u32, Attendee), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create a new attendee (with auto-generated ID)
|
|
||||||
let attendee = Attendee::new(contact_id).status(status);
|
|
||||||
|
|
||||||
// Save the attendee to the database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Attendee>()
|
|
||||||
.expect("can open attendee collection");
|
|
||||||
let (attendee_id, saved_attendee) = collection.set(&attendee).expect("can save attendee");
|
|
||||||
|
|
||||||
Ok((attendee_id, saved_attendee))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single attendee by its ID from the database.
|
|
||||||
pub fn get_attendee_by_id(attendee_id: u32) -> Result<Option<Attendee>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Attendee>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(attendee_id) {
|
|
||||||
Ok(attendee) => Ok(attendee),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error fetching attendee by id {}: {:?}", attendee_id, e);
|
|
||||||
Err(format!("Failed to fetch attendee: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates attendee status in the database and returns the updated attendee.
|
|
||||||
pub fn update_attendee_status(
|
|
||||||
attendee_id: u32,
|
|
||||||
status: AttendanceStatus,
|
|
||||||
) -> Result<Attendee, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Attendee>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut attendee) = collection
|
|
||||||
.get_by_id(attendee_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch attendee: {:?}", e))?
|
|
||||||
{
|
|
||||||
attendee = attendee.status(status);
|
|
||||||
let (_, updated_attendee) = collection
|
|
||||||
.set(&attendee)
|
|
||||||
.map_err(|e| format!("Failed to update attendee: {:?}", e))?;
|
|
||||||
Ok(updated_attendee)
|
|
||||||
} else {
|
|
||||||
Err("Attendee not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add attendee to event
|
|
||||||
pub fn add_attendee_to_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Event>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut event) = collection
|
|
||||||
.get_by_id(event_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
|
|
||||||
{
|
|
||||||
event = event.add_attendee(attendee_id);
|
|
||||||
let (_, updated_event) = collection
|
|
||||||
.set(&event)
|
|
||||||
.map_err(|e| format!("Failed to update event: {:?}", e))?;
|
|
||||||
Ok(updated_event)
|
|
||||||
} else {
|
|
||||||
Err("Event not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove attendee from event
|
|
||||||
pub fn remove_attendee_from_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Event>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut event) = collection
|
|
||||||
.get_by_id(event_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
|
|
||||||
{
|
|
||||||
event = event.remove_attendee(attendee_id);
|
|
||||||
let (_, updated_event) = collection
|
|
||||||
.set(&event)
|
|
||||||
.map_err(|e| format!("Failed to update event: {:?}", e))?;
|
|
||||||
Ok(updated_event)
|
|
||||||
} else {
|
|
||||||
Err("Event not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add event to calendar
|
|
||||||
pub fn add_event_to_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut calendar) = collection
|
|
||||||
.get_by_id(calendar_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
|
|
||||||
{
|
|
||||||
calendar = calendar.add_event(event_id as i64);
|
|
||||||
let (_, updated_calendar) = collection
|
|
||||||
.set(&calendar)
|
|
||||||
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
|
|
||||||
Ok(updated_calendar)
|
|
||||||
} else {
|
|
||||||
Err("Calendar not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove event from calendar
|
|
||||||
pub fn remove_event_from_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut calendar) = collection
|
|
||||||
.get_by_id(calendar_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
|
|
||||||
{
|
|
||||||
calendar = calendar.remove_event(event_id as i64);
|
|
||||||
let (_, updated_calendar) = collection
|
|
||||||
.set(&calendar)
|
|
||||||
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
|
|
||||||
Ok(updated_calendar)
|
|
||||||
} else {
|
|
||||||
Err("Calendar not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes a calendar from the database.
|
|
||||||
pub fn delete_calendar(calendar_id: u32) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
collection
|
|
||||||
.delete_by_id(calendar_id)
|
|
||||||
.map_err(|e| format!("Failed to delete calendar: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes an event from the database.
|
|
||||||
pub fn delete_event(event_id: u32) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Event>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
collection
|
|
||||||
.delete_by_id(event_id)
|
|
||||||
.map_err(|e| format!("Failed to delete event: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets or creates a calendar for a user. If the user already has a calendar, returns it.
|
|
||||||
/// If not, creates a new calendar for the user and returns it.
|
|
||||||
pub fn get_or_create_user_calendar(user_id: u32, user_name: &str) -> Result<Calendar, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Calendar>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
// Try to find existing calendar for this user
|
|
||||||
let calendars = match collection.get_all() {
|
|
||||||
Ok(calendars) => calendars,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error loading calendars: {:?}", e);
|
|
||||||
vec![] // Return an empty vector if there's an error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Look for a calendar owned by this user
|
|
||||||
for calendar in calendars {
|
|
||||||
if let Some(owner_id) = calendar.owner_id {
|
|
||||||
if owner_id == user_id {
|
|
||||||
return Ok(calendar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No calendar found for this user, create a new one
|
|
||||||
let calendar_name = format!("{}'s Calendar", user_name);
|
|
||||||
let (_, new_calendar) = create_new_calendar(
|
|
||||||
&calendar_name,
|
|
||||||
Some("Personal calendar"),
|
|
||||||
Some(user_id),
|
|
||||||
false, // Private calendar
|
|
||||||
Some("#4285F4"), // Default blue color
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(new_calendar)
|
|
||||||
}
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
use super::db::get_db;
|
|
||||||
use heromodels::{
|
|
||||||
db::{Collection, Db},
|
|
||||||
models::biz::{BusinessType, Company, CompanyStatus, Shareholder, ShareholderType},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Creates a new company and saves it to the database
|
|
||||||
pub fn create_new_company(
|
|
||||||
name: String,
|
|
||||||
registration_number: String,
|
|
||||||
incorporation_date: i64,
|
|
||||||
business_type: BusinessType,
|
|
||||||
email: String,
|
|
||||||
phone: String,
|
|
||||||
website: String,
|
|
||||||
address: String,
|
|
||||||
industry: String,
|
|
||||||
description: String,
|
|
||||||
fiscal_year_end: String,
|
|
||||||
) -> Result<(u32, Company), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create using heromodels constructor
|
|
||||||
let company = Company::new(name, registration_number, incorporation_date)
|
|
||||||
.business_type(business_type)
|
|
||||||
.email(email)
|
|
||||||
.phone(phone)
|
|
||||||
.website(website)
|
|
||||||
.address(address)
|
|
||||||
.industry(industry)
|
|
||||||
.description(description)
|
|
||||||
.fiscal_year_end(fiscal_year_end)
|
|
||||||
.status(CompanyStatus::PendingPayment);
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.expect("can open company collection");
|
|
||||||
let (id, saved_company) = collection.set(&company).expect("can save company");
|
|
||||||
|
|
||||||
Ok((id, saved_company))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new company with a specific status and saves it to the database
|
|
||||||
pub fn create_new_company_with_status(
|
|
||||||
name: String,
|
|
||||||
registration_number: String,
|
|
||||||
incorporation_date: i64,
|
|
||||||
business_type: BusinessType,
|
|
||||||
email: String,
|
|
||||||
phone: String,
|
|
||||||
website: String,
|
|
||||||
address: String,
|
|
||||||
industry: String,
|
|
||||||
description: String,
|
|
||||||
fiscal_year_end: String,
|
|
||||||
status: CompanyStatus,
|
|
||||||
) -> Result<(u32, Company), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create using heromodels constructor with specified status
|
|
||||||
let company = Company::new(name, registration_number, incorporation_date)
|
|
||||||
.business_type(business_type)
|
|
||||||
.email(email)
|
|
||||||
.phone(phone)
|
|
||||||
.website(website)
|
|
||||||
.address(address)
|
|
||||||
.industry(industry)
|
|
||||||
.description(description)
|
|
||||||
.fiscal_year_end(fiscal_year_end)
|
|
||||||
.status(status);
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.expect("can open company collection");
|
|
||||||
let (id, saved_company) = collection.set(&company).expect("can save company");
|
|
||||||
|
|
||||||
Ok((id, saved_company))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all companies from the database
|
|
||||||
pub fn get_companies() -> Result<Vec<Company>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.expect("can open company collection");
|
|
||||||
|
|
||||||
let companies = match collection.get_all() {
|
|
||||||
Ok(companies) => {
|
|
||||||
log::info!(
|
|
||||||
"Successfully loaded {} companies from database",
|
|
||||||
companies.len()
|
|
||||||
);
|
|
||||||
companies
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load companies from database: {:?}", e);
|
|
||||||
// Return the error instead of empty vec to properly handle corruption
|
|
||||||
return Err(format!("Failed to get companies: {:?}", e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(companies)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update company status (e.g., from PendingPayment to Active)
|
|
||||||
pub fn update_company_status(
|
|
||||||
company_id: u32,
|
|
||||||
new_status: CompanyStatus,
|
|
||||||
) -> Result<Option<Company>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.expect("can open company collection");
|
|
||||||
|
|
||||||
// Try to get all companies, with corruption recovery
|
|
||||||
let all_companies = match collection.get_all() {
|
|
||||||
Ok(companies) => companies,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get companies for status update: {:?}", e);
|
|
||||||
|
|
||||||
// If we have a decode error, try to recover by clearing corrupted data
|
|
||||||
if format!("{:?}", e).contains("Decode") {
|
|
||||||
log::warn!("Database corruption detected, attempting recovery...");
|
|
||||||
|
|
||||||
// Try to recover by clearing the collection and recreating
|
|
||||||
match recover_from_database_corruption() {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!(
|
|
||||||
"Database recovery successful, but company {} may be lost",
|
|
||||||
company_id
|
|
||||||
);
|
|
||||||
return Err(format!(
|
|
||||||
"Database was corrupted and recovered, but company {} was not found. Please re-register.",
|
|
||||||
company_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(recovery_err) => {
|
|
||||||
log::error!("Database recovery failed: {}", recovery_err);
|
|
||||||
return Err(format!(
|
|
||||||
"Database corruption detected and recovery failed: {}",
|
|
||||||
recovery_err
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(format!("Failed to get companies: {:?}", e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the company by ID
|
|
||||||
for (_index, company) in all_companies.iter().enumerate() {
|
|
||||||
if company.base_data.id == company_id {
|
|
||||||
// Create updated company with new status
|
|
||||||
let mut updated_company = company.clone();
|
|
||||||
updated_company.status = new_status.clone();
|
|
||||||
|
|
||||||
// Update in database
|
|
||||||
let (_, saved_company) = collection.set(&updated_company).map_err(|e| {
|
|
||||||
log::error!("Failed to update company status: {:?}", e);
|
|
||||||
format!("Failed to update company: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!("Updated company {} status to {:?}", company_id, new_status);
|
|
||||||
|
|
||||||
return Ok(Some(saved_company));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::warn!(
|
|
||||||
"Company not found with ID: {} (cannot update status)",
|
|
||||||
company_id
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single company by its ID
|
|
||||||
pub fn get_company_by_id(company_id: u32) -> Result<Option<Company>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(company_id) {
|
|
||||||
Ok(company) => Ok(company),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error fetching company by id {}: {:?}", company_id, e);
|
|
||||||
Err(format!("Failed to fetch company: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates company in the database
|
|
||||||
pub fn update_company(
|
|
||||||
company_id: u32,
|
|
||||||
name: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
phone: Option<String>,
|
|
||||||
website: Option<String>,
|
|
||||||
address: Option<String>,
|
|
||||||
industry: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
fiscal_year_end: Option<String>,
|
|
||||||
status: Option<CompanyStatus>,
|
|
||||||
business_type: Option<BusinessType>,
|
|
||||||
) -> Result<Company, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut company) = collection
|
|
||||||
.get_by_id(company_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch company: {:?}", e))?
|
|
||||||
{
|
|
||||||
// Update using builder pattern
|
|
||||||
if let Some(name) = name {
|
|
||||||
company.name = name;
|
|
||||||
}
|
|
||||||
if let Some(email) = email {
|
|
||||||
company = company.email(email);
|
|
||||||
}
|
|
||||||
if let Some(phone) = phone {
|
|
||||||
company = company.phone(phone);
|
|
||||||
}
|
|
||||||
if let Some(website) = website {
|
|
||||||
company = company.website(website);
|
|
||||||
}
|
|
||||||
if let Some(address) = address {
|
|
||||||
company = company.address(address);
|
|
||||||
}
|
|
||||||
if let Some(industry) = industry {
|
|
||||||
company = company.industry(industry);
|
|
||||||
}
|
|
||||||
if let Some(description) = description {
|
|
||||||
company = company.description(description);
|
|
||||||
}
|
|
||||||
if let Some(fiscal_year_end) = fiscal_year_end {
|
|
||||||
company = company.fiscal_year_end(fiscal_year_end);
|
|
||||||
}
|
|
||||||
if let Some(status) = status {
|
|
||||||
company = company.status(status);
|
|
||||||
}
|
|
||||||
if let Some(business_type) = business_type {
|
|
||||||
company = company.business_type(business_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_, updated_company) = collection
|
|
||||||
.set(&company)
|
|
||||||
.map_err(|e| format!("Failed to update company: {:?}", e))?;
|
|
||||||
Ok(updated_company)
|
|
||||||
} else {
|
|
||||||
Err("Company not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes company from the database
|
|
||||||
pub fn delete_company(company_id: u32) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
collection
|
|
||||||
.delete_by_id(company_id)
|
|
||||||
.map_err(|e| format!("Failed to delete company: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes a company by name (useful for cleaning up test data)
|
|
||||||
pub fn delete_company_by_name(company_name: &str) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
// Get all companies and find the one with matching name
|
|
||||||
let companies = collection
|
|
||||||
.get_all()
|
|
||||||
.map_err(|e| format!("Failed to get companies: {:?}", e))?;
|
|
||||||
|
|
||||||
let company_to_delete = companies
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.name.trim().to_lowercase() == company_name.trim().to_lowercase());
|
|
||||||
|
|
||||||
if let Some(company) = company_to_delete {
|
|
||||||
collection
|
|
||||||
.delete_by_id(company.base_data.id)
|
|
||||||
.map_err(|e| format!("Failed to delete company: {:?}", e))?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Successfully deleted company '{}' with ID {}",
|
|
||||||
company.name,
|
|
||||||
company.base_data.id
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Company '{}' not found", company_name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists all company names in the database (useful for debugging duplicates)
|
|
||||||
pub fn list_company_names() -> Result<Vec<String>, String> {
|
|
||||||
let companies = get_companies()?;
|
|
||||||
let names: Vec<String> = companies.iter().map(|c| c.name.clone()).collect();
|
|
||||||
Ok(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recover from database corruption by clearing corrupted data
|
|
||||||
fn recover_from_database_corruption() -> Result<(), String> {
|
|
||||||
log::warn!("Attempting to recover from database corruption...");
|
|
||||||
|
|
||||||
// Since there's no clear method available, we'll provide instructions for manual recovery
|
|
||||||
log::warn!("Database corruption detected - manual intervention required");
|
|
||||||
log::warn!("To fix: Stop the application, delete the database files, and restart");
|
|
||||||
|
|
||||||
Err(
|
|
||||||
"Database corruption detected. Please restart the application to reset the database."
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manual function to clean up corrupted database (for emergency use)
|
|
||||||
pub fn cleanup_corrupted_database() -> Result<String, String> {
|
|
||||||
log::warn!("Manual database cleanup initiated...");
|
|
||||||
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Company>()
|
|
||||||
.expect("can open company collection");
|
|
||||||
|
|
||||||
// Try to get companies to check for corruption
|
|
||||||
match collection.get_all() {
|
|
||||||
Ok(companies) => {
|
|
||||||
log::info!("Database is healthy with {} companies", companies.len());
|
|
||||||
Ok(format!(
|
|
||||||
"Database is healthy with {} companies",
|
|
||||||
companies.len()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Database corruption detected: {:?}", e);
|
|
||||||
|
|
||||||
// Since we can't clear the collection programmatically, provide instructions
|
|
||||||
log::error!("Database corruption detected but cannot be fixed automatically");
|
|
||||||
Err("Database corruption detected. Please stop the application, delete the database files in the 'data' directory, and restart the application.".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Shareholder Management Functions ===
|
|
||||||
|
|
||||||
/// Creates a new shareholder and saves it to the database
|
|
||||||
pub fn create_new_shareholder(
|
|
||||||
company_id: u32,
|
|
||||||
user_id: u32,
|
|
||||||
name: String,
|
|
||||||
shares: f64,
|
|
||||||
percentage: f64,
|
|
||||||
shareholder_type: ShareholderType,
|
|
||||||
since: i64,
|
|
||||||
) -> Result<(u32, Shareholder), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create a new shareholder
|
|
||||||
let shareholder = Shareholder::new()
|
|
||||||
.company_id(company_id)
|
|
||||||
.user_id(user_id)
|
|
||||||
.name(name)
|
|
||||||
.shares(shares)
|
|
||||||
.percentage(percentage)
|
|
||||||
.type_(shareholder_type)
|
|
||||||
.since(since);
|
|
||||||
|
|
||||||
// Save the shareholder to the database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.expect("can open shareholder collection");
|
|
||||||
let (shareholder_id, saved_shareholder) =
|
|
||||||
collection.set(&shareholder).expect("can save shareholder");
|
|
||||||
|
|
||||||
Ok((shareholder_id, saved_shareholder))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets all shareholders for a specific company
|
|
||||||
pub fn get_company_shareholders(company_id: u32) -> Result<Vec<Shareholder>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.expect("can open shareholder collection");
|
|
||||||
|
|
||||||
let all_shareholders = match collection.get_all() {
|
|
||||||
Ok(shareholders) => shareholders,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load shareholders from database: {:?}", e);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter shareholders by company_id
|
|
||||||
let company_shareholders = all_shareholders
|
|
||||||
.into_iter()
|
|
||||||
.filter(|shareholder| shareholder.company_id == company_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(company_shareholders)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets all shareholders from the database
|
|
||||||
pub fn get_all_shareholders() -> Result<Vec<Shareholder>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.expect("can open shareholder collection");
|
|
||||||
|
|
||||||
let shareholders = match collection.get_all() {
|
|
||||||
Ok(shareholders) => shareholders,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load shareholders from database: {:?}", e);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(shareholders)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single shareholder by its ID
|
|
||||||
pub fn get_shareholder_by_id(shareholder_id: u32) -> Result<Option<Shareholder>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(shareholder_id) {
|
|
||||||
Ok(shareholder) => Ok(shareholder),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!(
|
|
||||||
"Error fetching shareholder by id {}: {:?}",
|
|
||||||
shareholder_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
Err(format!("Failed to fetch shareholder: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates shareholder in the database
|
|
||||||
pub fn update_shareholder(
|
|
||||||
shareholder_id: u32,
|
|
||||||
name: Option<String>,
|
|
||||||
shares: Option<f64>,
|
|
||||||
percentage: Option<f64>,
|
|
||||||
shareholder_type: Option<ShareholderType>,
|
|
||||||
) -> Result<Shareholder, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
if let Some(mut shareholder) = collection
|
|
||||||
.get_by_id(shareholder_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch shareholder: {:?}", e))?
|
|
||||||
{
|
|
||||||
// Update using builder pattern
|
|
||||||
if let Some(name) = name {
|
|
||||||
shareholder = shareholder.name(name);
|
|
||||||
}
|
|
||||||
if let Some(shares) = shares {
|
|
||||||
shareholder = shareholder.shares(shares);
|
|
||||||
}
|
|
||||||
if let Some(percentage) = percentage {
|
|
||||||
shareholder = shareholder.percentage(percentage);
|
|
||||||
}
|
|
||||||
if let Some(shareholder_type) = shareholder_type {
|
|
||||||
shareholder = shareholder.type_(shareholder_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_, updated_shareholder) = collection
|
|
||||||
.set(&shareholder)
|
|
||||||
.map_err(|e| format!("Failed to update shareholder: {:?}", e))?;
|
|
||||||
Ok(updated_shareholder)
|
|
||||||
} else {
|
|
||||||
Err("Shareholder not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes shareholder from the database
|
|
||||||
pub fn delete_shareholder(shareholder_id: u32) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Shareholder>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
collection
|
|
||||||
.delete_by_id(shareholder_id)
|
|
||||||
.map_err(|e| format!("Failed to delete shareholder: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
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<u64>,
|
|
||||||
end_date: Option<u64>,
|
|
||||||
renewal_period_days: Option<u32>,
|
|
||||||
) -> 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::<Contract>()
|
|
||||||
.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<Contract>.
|
|
||||||
pub fn get_contracts() -> Result<Vec<Contract>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<Option<Contract>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<u64>,
|
|
||||||
end_date: Option<u64>,
|
|
||||||
) -> Result<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<Contract, String> {
|
|
||||||
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<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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::<Contract>()
|
|
||||||
.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<Vec<Contract>, 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<Vec<Contract>, 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<Vec<Contract>, 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<Vec<Contract>, 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<u32>) -> Result<Vec<Contract>, 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<Contract, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Contract>()
|
|
||||||
.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<ContractStatistics, String> {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use heromodels::db::hero::OurDB;
|
|
||||||
|
|
||||||
/// The path to the database file. Change this as needed for your environment.
|
|
||||||
pub const DB_PATH: &str = "/tmp/freezone_db";
|
|
||||||
|
|
||||||
/// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed.
|
|
||||||
pub fn get_db() -> Result<OurDB, String> {
|
|
||||||
let db_path = PathBuf::from(DB_PATH);
|
|
||||||
if let Some(parent) = db_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
// Temporarily reset the database to fix the serialization issue
|
|
||||||
let db = heromodels::db::hero::OurDB::new(db_path, false).expect("Can create DB");
|
|
||||||
Ok(db)
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
use crate::models::document::{Document, DocumentType};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
const DOCUMENTS_FILE: &str = "/tmp/freezone_documents.json";
|
|
||||||
|
|
||||||
/// Helper function to load documents from JSON file
|
|
||||||
fn load_documents() -> Result<Vec<Document>, String> {
|
|
||||||
if !Path::new(DOCUMENTS_FILE).exists() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(DOCUMENTS_FILE)
|
|
||||||
.map_err(|e| format!("Failed to read documents file: {}", e))?;
|
|
||||||
|
|
||||||
if content.trim().is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse documents JSON: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to save documents to JSON file
|
|
||||||
fn save_documents(documents: &[Document]) -> Result<(), String> {
|
|
||||||
let content = serde_json::to_string_pretty(documents)
|
|
||||||
.map_err(|e| format!("Failed to serialize documents: {}", e))?;
|
|
||||||
|
|
||||||
fs::write(DOCUMENTS_FILE, content).map_err(|e| format!("Failed to write documents file: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new document and saves it to the database
|
|
||||||
pub fn create_new_document(
|
|
||||||
name: String,
|
|
||||||
file_path: String,
|
|
||||||
file_size: u64,
|
|
||||||
mime_type: String,
|
|
||||||
company_id: u32,
|
|
||||||
uploaded_by: String,
|
|
||||||
document_type: DocumentType,
|
|
||||||
description: Option<String>,
|
|
||||||
is_public: bool,
|
|
||||||
checksum: Option<String>,
|
|
||||||
) -> Result<u32, String> {
|
|
||||||
let mut documents = load_documents()?;
|
|
||||||
|
|
||||||
// Create new document
|
|
||||||
let mut document = Document::new(
|
|
||||||
name,
|
|
||||||
file_path,
|
|
||||||
file_size,
|
|
||||||
mime_type,
|
|
||||||
company_id,
|
|
||||||
uploaded_by,
|
|
||||||
)
|
|
||||||
.document_type(document_type)
|
|
||||||
.is_public(is_public);
|
|
||||||
|
|
||||||
if let Some(desc) = description {
|
|
||||||
document = document.description(desc);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(checksum) = checksum {
|
|
||||||
document = document.checksum(checksum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate next ID (simple incremental)
|
|
||||||
let next_id = documents.iter().map(|d| d.id).max().unwrap_or(0) + 1;
|
|
||||||
document.id = next_id;
|
|
||||||
|
|
||||||
documents.push(document);
|
|
||||||
save_documents(&documents)?;
|
|
||||||
|
|
||||||
Ok(next_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all documents from the database
|
|
||||||
pub fn get_documents() -> Result<Vec<Document>, String> {
|
|
||||||
load_documents()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets all documents for a specific company
|
|
||||||
pub fn get_company_documents(company_id: u32) -> Result<Vec<Document>, String> {
|
|
||||||
let all_documents = load_documents()?;
|
|
||||||
|
|
||||||
// Filter documents by company_id
|
|
||||||
let company_documents = all_documents
|
|
||||||
.into_iter()
|
|
||||||
.filter(|document| document.company_id == company_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(company_documents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single document by its ID
|
|
||||||
pub fn get_document_by_id(document_id: u32) -> Result<Option<Document>, String> {
|
|
||||||
let documents = load_documents()?;
|
|
||||||
|
|
||||||
let document = documents.into_iter().find(|doc| doc.id == document_id);
|
|
||||||
|
|
||||||
Ok(document)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates document in the database
|
|
||||||
pub fn update_document(
|
|
||||||
document_id: u32,
|
|
||||||
name: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
document_type: Option<DocumentType>,
|
|
||||||
is_public: Option<bool>,
|
|
||||||
) -> Result<Document, String> {
|
|
||||||
let mut documents = load_documents()?;
|
|
||||||
|
|
||||||
if let Some(document) = documents.iter_mut().find(|doc| doc.id == document_id) {
|
|
||||||
// Update fields
|
|
||||||
if let Some(name) = name {
|
|
||||||
document.name = name;
|
|
||||||
}
|
|
||||||
if let Some(description) = description {
|
|
||||||
document.description = Some(description);
|
|
||||||
}
|
|
||||||
if let Some(document_type) = document_type {
|
|
||||||
document.document_type = document_type;
|
|
||||||
}
|
|
||||||
if let Some(is_public) = is_public {
|
|
||||||
document.is_public = is_public;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated_document = document.clone();
|
|
||||||
save_documents(&documents)?;
|
|
||||||
Ok(updated_document)
|
|
||||||
} else {
|
|
||||||
Err("Document not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes document from the database
|
|
||||||
pub fn delete_document(document_id: u32) -> Result<(), String> {
|
|
||||||
let mut documents = load_documents()?;
|
|
||||||
|
|
||||||
let initial_len = documents.len();
|
|
||||||
documents.retain(|doc| doc.id != document_id);
|
|
||||||
|
|
||||||
if documents.len() == initial_len {
|
|
||||||
return Err("Document not found".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
save_documents(&documents)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets documents by type for a company
|
|
||||||
pub fn get_company_documents_by_type(
|
|
||||||
company_id: u32,
|
|
||||||
document_type: DocumentType,
|
|
||||||
) -> Result<Vec<Document>, String> {
|
|
||||||
let company_documents = get_company_documents(company_id)?;
|
|
||||||
|
|
||||||
let filtered_documents = company_documents
|
|
||||||
.into_iter()
|
|
||||||
.filter(|doc| doc.document_type == document_type)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(filtered_documents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets public documents for a company
|
|
||||||
pub fn get_public_company_documents(company_id: u32) -> Result<Vec<Document>, String> {
|
|
||||||
let company_documents = get_company_documents(company_id)?;
|
|
||||||
|
|
||||||
let public_documents = company_documents
|
|
||||||
.into_iter()
|
|
||||||
.filter(|doc| doc.is_public)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(public_documents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Searches documents by name for a company
|
|
||||||
pub fn search_company_documents(
|
|
||||||
company_id: u32,
|
|
||||||
search_term: &str,
|
|
||||||
) -> Result<Vec<Document>, String> {
|
|
||||||
let company_documents = get_company_documents(company_id)?;
|
|
||||||
|
|
||||||
let search_term_lower = search_term.to_lowercase();
|
|
||||||
let matching_documents = company_documents
|
|
||||||
.into_iter()
|
|
||||||
.filter(|doc| {
|
|
||||||
doc.name.to_lowercase().contains(&search_term_lower)
|
|
||||||
|| doc.description.as_ref().map_or(false, |desc| {
|
|
||||||
desc.to_lowercase().contains(&search_term_lower)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(matching_documents)
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
use chrono::{Duration, Utc};
|
|
||||||
use heromodels::{
|
|
||||||
db::{Collection, Db},
|
|
||||||
models::governance::{Activity, ActivityType, Proposal, ProposalStatus},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::db::get_db;
|
|
||||||
|
|
||||||
/// Creates a new proposal and saves it to the database. Returns the saved proposal and its ID.
|
|
||||||
pub fn create_new_proposal(
|
|
||||||
creator_id: &str,
|
|
||||||
creator_name: &str,
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
status: ProposalStatus,
|
|
||||||
voting_start_date: Option<chrono::DateTime<Utc>>,
|
|
||||||
voting_end_date: Option<chrono::DateTime<Utc>>,
|
|
||||||
) -> Result<(u32, Proposal), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
let created_at = Utc::now();
|
|
||||||
let updated_at = created_at;
|
|
||||||
|
|
||||||
// Create a new proposal (with auto-generated ID)
|
|
||||||
let proposal = Proposal::new(
|
|
||||||
None,
|
|
||||||
creator_id,
|
|
||||||
creator_name,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
updated_at,
|
|
||||||
voting_start_date.unwrap_or_else(Utc::now),
|
|
||||||
voting_end_date.unwrap_or_else(|| Utc::now() + Duration::days(7)),
|
|
||||||
);
|
|
||||||
// Save the proposal to the database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Proposal>()
|
|
||||||
.expect("can open proposal collection");
|
|
||||||
let (proposal_id, saved_proposal) = collection.set(&proposal).expect("can save proposal");
|
|
||||||
|
|
||||||
Ok((proposal_id, saved_proposal))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all proposals from the database and returns them as a Vec<Proposal>.
|
|
||||||
pub fn get_proposals() -> Result<Vec<Proposal>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Proposal>()
|
|
||||||
.expect("can open proposal collection");
|
|
||||||
|
|
||||||
// Try to load all proposals, but handle deserialization errors gracefully
|
|
||||||
let proposals = match collection.get_all() {
|
|
||||||
Ok(props) => props,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error loading proposals: {:?}", e);
|
|
||||||
vec![] // Return an empty vector if there's an error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(proposals)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single proposal by its ID from the database.
|
|
||||||
pub fn get_proposal_by_id(proposal_id: u32) -> Result<Option<Proposal>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Proposal>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
match collection.get_by_id(proposal_id) {
|
|
||||||
Ok(proposal) => Ok(Some(proposal.expect("proposal not found"))),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Error fetching proposal by id {}: {:?}", proposal_id, e);
|
|
||||||
Err(format!("Failed to fetch proposal: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Submits a vote on a proposal and returns the updated proposal
|
|
||||||
pub fn submit_vote_on_proposal(
|
|
||||||
proposal_id: u32,
|
|
||||||
user_id: i32,
|
|
||||||
vote_type: &str,
|
|
||||||
shares_count: u32, // Default to 1 if not specified
|
|
||||||
comment: Option<String>,
|
|
||||||
) -> Result<Proposal, String> {
|
|
||||||
// Get the proposal from the database
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Proposal>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
// Get the proposal
|
|
||||||
let mut proposal = collection
|
|
||||||
.get_by_id(proposal_id)
|
|
||||||
.map_err(|e| format!("Failed to fetch proposal: {:?}", e))?
|
|
||||||
.ok_or_else(|| format!("Proposal not found with ID: {}", proposal_id))?;
|
|
||||||
|
|
||||||
// Ensure the proposal has vote options
|
|
||||||
// Check if the proposal already has options
|
|
||||||
if proposal.options.is_empty() {
|
|
||||||
// Add standard vote options if they don't exist
|
|
||||||
proposal = proposal.add_option(1, "Approve", Some("Approve the proposal"));
|
|
||||||
proposal = proposal.add_option(2, "Reject", Some("Reject the proposal"));
|
|
||||||
proposal = proposal.add_option(3, "Abstain", Some("Abstain from voting"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map vote_type to option_id
|
|
||||||
let option_id = match vote_type {
|
|
||||||
"Yes" => 1, // Approve
|
|
||||||
"No" => 2, // Reject
|
|
||||||
"Abstain" => 3, // Abstain
|
|
||||||
_ => return Err(format!("Invalid vote type: {}", vote_type)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Since we're having issues with the cast_vote method, let's implement a workaround
|
|
||||||
// that directly updates the vote count for the selected option
|
|
||||||
|
|
||||||
// Check if the proposal is active
|
|
||||||
if proposal.status != ProposalStatus::Active {
|
|
||||||
return Err(format!(
|
|
||||||
"Cannot vote on a proposal with status: {:?}",
|
|
||||||
proposal.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if voting period is valid
|
|
||||||
let now = Utc::now();
|
|
||||||
if now > proposal.vote_end_date {
|
|
||||||
return Err("Voting period has ended".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if now < proposal.vote_start_date {
|
|
||||||
return Err("Voting period has not started yet".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the option and increment its count
|
|
||||||
let mut option_found = false;
|
|
||||||
for option in &mut proposal.options {
|
|
||||||
if option.id == option_id {
|
|
||||||
option.count += shares_count as i64;
|
|
||||||
option_found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !option_found {
|
|
||||||
return Err(format!("Option with ID {} not found", option_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record the vote in the proposal's ballots
|
|
||||||
// We'll create a simple ballot with an auto-generated ID
|
|
||||||
let ballot_id = proposal.ballots.len() as u32 + 1;
|
|
||||||
|
|
||||||
// Create a new ballot and add it to the proposal's ballots
|
|
||||||
use heromodels::models::governance::Ballot;
|
|
||||||
|
|
||||||
// Use the Ballot::new constructor which handles the BaseModelData creation
|
|
||||||
let mut ballot = Ballot::new(
|
|
||||||
Some(ballot_id),
|
|
||||||
user_id as u32,
|
|
||||||
option_id,
|
|
||||||
shares_count as i64,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the comment if provided
|
|
||||||
ballot.comment = comment;
|
|
||||||
|
|
||||||
// Store the local time (EEST = UTC+3) as the vote timestamp
|
|
||||||
// This ensures the displayed time matches the user's local time
|
|
||||||
let utc_now = Utc::now();
|
|
||||||
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
|
|
||||||
let local_time = utc_now.with_timezone(&local_offset);
|
|
||||||
|
|
||||||
// Store the local time as a timestamp (this is what will be displayed)
|
|
||||||
ballot.base_data.created_at = local_time.timestamp();
|
|
||||||
|
|
||||||
// Add the ballot to the proposal's ballots
|
|
||||||
proposal.ballots.push(ballot);
|
|
||||||
|
|
||||||
// Update the proposal's updated_at timestamp
|
|
||||||
proposal.updated_at = Utc::now();
|
|
||||||
|
|
||||||
// Save the updated proposal
|
|
||||||
let (_, updated_proposal) = collection
|
|
||||||
.set(&proposal)
|
|
||||||
.map_err(|e| format!("Failed to save vote: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(updated_proposal)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused_assignments)]
|
|
||||||
/// Creates a new governance activity and saves it to the database using OurDB
|
|
||||||
pub fn create_activity(
|
|
||||||
proposal_id: u32,
|
|
||||||
proposal_title: &str,
|
|
||||||
creator_name: &str,
|
|
||||||
activity_type: &ActivityType,
|
|
||||||
) -> Result<(u32, Activity), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
let mut activity = Activity::default();
|
|
||||||
|
|
||||||
match activity_type {
|
|
||||||
ActivityType::ProposalCreated => {
|
|
||||||
activity = Activity::proposal_created(proposal_id, proposal_title, creator_name);
|
|
||||||
}
|
|
||||||
ActivityType::VoteCast => {
|
|
||||||
activity = Activity::vote_cast(proposal_id, proposal_title, creator_name);
|
|
||||||
}
|
|
||||||
ActivityType::VotingStarted => {
|
|
||||||
activity = Activity::voting_started(proposal_id, proposal_title);
|
|
||||||
}
|
|
||||||
ActivityType::VotingEnded => {
|
|
||||||
activity = Activity::voting_ended(proposal_id, proposal_title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the proposal to the database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Activity>()
|
|
||||||
.expect("can open activity collection");
|
|
||||||
|
|
||||||
let (proposal_id, saved_proposal) = collection.set(&activity).expect("can save proposal");
|
|
||||||
Ok((proposal_id, saved_proposal))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_recent_activities() -> Result<Vec<Activity>, String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
let collection = db
|
|
||||||
.collection::<Activity>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
let mut db_activities = collection
|
|
||||||
.get_all()
|
|
||||||
.map_err(|e| format!("DB fetch error: {:?}", e))?;
|
|
||||||
|
|
||||||
// Sort by created_at descending
|
|
||||||
db_activities.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
||||||
|
|
||||||
// Take the top 10 most recent
|
|
||||||
let recent_activities = db_activities.into_iter().take(10).collect();
|
|
||||||
|
|
||||||
Ok(recent_activities)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all_activities() -> Result<Vec<Activity>, String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
let collection = db
|
|
||||||
.collection::<Activity>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
let db_activities = collection
|
|
||||||
.get_all()
|
|
||||||
.map_err(|e| format!("DB fetch error: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(db_activities)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
pub mod calendar;
|
|
||||||
pub mod company;
|
|
||||||
pub mod contracts;
|
|
||||||
pub mod db;
|
|
||||||
pub mod document;
|
|
||||||
pub mod governance;
|
|
||||||
pub mod payment;
|
|
||||||
pub mod registration;
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
use super::db::get_db;
|
|
||||||
use heromodels::{
|
|
||||||
db::{Collection, Db},
|
|
||||||
models::{Payment, PaymentStatus},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Creates a new payment and saves it to the database
|
|
||||||
pub fn create_new_payment(
|
|
||||||
payment_intent_id: String,
|
|
||||||
company_id: u32,
|
|
||||||
payment_plan: String,
|
|
||||||
setup_fee: f64,
|
|
||||||
monthly_fee: f64,
|
|
||||||
total_amount: f64,
|
|
||||||
) -> Result<(u32, Payment), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
|
|
||||||
// Create using heromodels constructor
|
|
||||||
let payment = Payment::new(
|
|
||||||
payment_intent_id.clone(),
|
|
||||||
company_id,
|
|
||||||
payment_plan,
|
|
||||||
setup_fee,
|
|
||||||
monthly_fee,
|
|
||||||
total_amount,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
let (id, saved_payment) = collection.set(&payment).expect("can save payment");
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Created payment with ID {} for company {} (Intent: {})",
|
|
||||||
id,
|
|
||||||
company_id,
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((id, saved_payment))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all payments from the database
|
|
||||||
pub fn get_payments() -> Result<Vec<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
let payments = match collection.get_all() {
|
|
||||||
Ok(payments) => payments,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load payments from database: {:?}", e);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a payment by its database ID
|
|
||||||
pub fn get_payment_by_id(payment_id: u32) -> Result<Option<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
match collection.get_by_id(payment_id) {
|
|
||||||
Ok(payment) => Ok(payment),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get payment by ID {}: {:?}", payment_id, e);
|
|
||||||
Err(format!("Failed to get payment: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a payment by Stripe payment intent ID
|
|
||||||
pub fn get_payment_by_intent_id(payment_intent_id: &str) -> Result<Option<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
// Get all payments and find by payment_intent_id
|
|
||||||
// TODO: Use indexed query when available in heromodels
|
|
||||||
let payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let payment = payments
|
|
||||||
.into_iter()
|
|
||||||
.find(|p| p.payment_intent_id == payment_intent_id);
|
|
||||||
|
|
||||||
Ok(payment)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets all payments for a specific company
|
|
||||||
pub fn get_company_payments(company_id: u32) -> Result<Vec<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
// Get all payments and filter by company_id
|
|
||||||
// TODO: Use indexed query when available in heromodels
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let company_payments = all_payments
|
|
||||||
.into_iter()
|
|
||||||
.filter(|payment| payment.company_id == company_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(company_payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates a payment in the database
|
|
||||||
pub fn update_payment(payment: Payment) -> Result<(u32, Payment), String> {
|
|
||||||
let db = get_db().expect("Can get DB");
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
let (id, updated_payment) = collection.set(&payment).expect("can update payment");
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Updated payment with ID {} (Intent: {}, Status: {:?})",
|
|
||||||
id,
|
|
||||||
payment.payment_intent_id,
|
|
||||||
payment.status
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((id, updated_payment))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update payment with company ID after company creation
|
|
||||||
pub fn update_payment_company_id(
|
|
||||||
payment_intent_id: &str,
|
|
||||||
company_id: u32,
|
|
||||||
) -> Result<Option<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
// Get all payments and find the one to update
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments for company ID update: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Find the payment by payment_intent_id
|
|
||||||
for (_index, payment) in all_payments.iter().enumerate() {
|
|
||||||
if payment.payment_intent_id == payment_intent_id {
|
|
||||||
// Create updated payment with company_id
|
|
||||||
let mut updated_payment = payment.clone();
|
|
||||||
updated_payment.company_id = company_id;
|
|
||||||
|
|
||||||
// Update in database (this is a limitation of current DB interface)
|
|
||||||
let (_, saved_payment) = collection.set(&updated_payment).map_err(|e| {
|
|
||||||
log::error!("Failed to update payment company ID: {:?}", e);
|
|
||||||
format!("Failed to update payment: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Updated payment {} with company ID {}",
|
|
||||||
payment_intent_id,
|
|
||||||
company_id
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(Some(saved_payment));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::warn!(
|
|
||||||
"Payment not found for intent ID: {} (cannot update company ID)",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update payment status
|
|
||||||
pub fn update_payment_status(
|
|
||||||
payment_intent_id: &str,
|
|
||||||
status: heromodels::models::biz::PaymentStatus,
|
|
||||||
) -> Result<Option<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
// Get all payments and find the one to update
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments for status update: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Find the payment by payment_intent_id
|
|
||||||
for (_index, payment) in all_payments.iter().enumerate() {
|
|
||||||
if payment.payment_intent_id == payment_intent_id {
|
|
||||||
// Create updated payment with new status
|
|
||||||
let mut updated_payment = payment.clone();
|
|
||||||
updated_payment.status = status.clone();
|
|
||||||
|
|
||||||
// Update in database
|
|
||||||
let (_, saved_payment) = collection.set(&updated_payment).map_err(|e| {
|
|
||||||
log::error!("Failed to update payment status: {:?}", e);
|
|
||||||
format!("Failed to update payment: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Updated payment {} status to {:?}",
|
|
||||||
payment_intent_id,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(Some(saved_payment));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::warn!(
|
|
||||||
"Payment not found for intent ID: {} (cannot update status)",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all pending payments (for monitoring/retry)
|
|
||||||
pub fn get_pending_payments() -> Result<Vec<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Filter for pending payments
|
|
||||||
let pending_payments = all_payments
|
|
||||||
.into_iter()
|
|
||||||
.filter(|payment| payment.status == heromodels::models::biz::PaymentStatus::Pending)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(pending_payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get failed payments (for retry/investigation)
|
|
||||||
pub fn get_failed_payments() -> Result<Vec<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Filter for failed payments
|
|
||||||
let failed_payments = all_payments
|
|
||||||
.into_iter()
|
|
||||||
.filter(|payment| payment.status == heromodels::models::biz::PaymentStatus::Failed)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(failed_payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Completes a payment (marks as completed with Stripe customer ID)
|
|
||||||
pub fn complete_payment(
|
|
||||||
payment_intent_id: &str,
|
|
||||||
stripe_customer_id: Option<String>,
|
|
||||||
) -> Result<Option<Payment>, String> {
|
|
||||||
if let Some(payment) = get_payment_by_intent_id(payment_intent_id)? {
|
|
||||||
let completed_payment = payment.complete_payment(stripe_customer_id);
|
|
||||||
let (_, updated_payment) = update_payment(completed_payment)?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Completed payment {} for company {}",
|
|
||||||
payment_intent_id,
|
|
||||||
updated_payment.company_id
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Some(updated_payment))
|
|
||||||
} else {
|
|
||||||
log::warn!("Payment not found for intent ID: {}", payment_intent_id);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks a payment as failed
|
|
||||||
pub fn fail_payment(payment_intent_id: &str) -> Result<Option<Payment>, String> {
|
|
||||||
if let Some(payment) = get_payment_by_intent_id(payment_intent_id)? {
|
|
||||||
let failed_payment = payment.fail_payment();
|
|
||||||
let (_, updated_payment) = update_payment(failed_payment)?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Failed payment {} for company {}",
|
|
||||||
payment_intent_id,
|
|
||||||
updated_payment.company_id
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Some(updated_payment))
|
|
||||||
} else {
|
|
||||||
log::warn!("Payment not found for intent ID: {}", payment_intent_id);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets payments by status
|
|
||||||
pub fn get_payments_by_status(status: PaymentStatus) -> Result<Vec<Payment>, String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.expect("can open payment collection");
|
|
||||||
|
|
||||||
// Get all payments and filter by status
|
|
||||||
// TODO: Use indexed query when available in heromodels
|
|
||||||
let all_payments = collection.get_all().map_err(|e| {
|
|
||||||
log::error!("Failed to get payments: {:?}", e);
|
|
||||||
format!("Failed to get payments: {:?}", e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let filtered_payments = all_payments
|
|
||||||
.into_iter()
|
|
||||||
.filter(|payment| payment.status == status)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(filtered_payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes a payment from the database
|
|
||||||
pub fn delete_payment(payment_id: u32) -> Result<(), String> {
|
|
||||||
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
let collection = db
|
|
||||||
.collection::<Payment>()
|
|
||||||
.map_err(|e| format!("Collection error: {:?}", e))?;
|
|
||||||
|
|
||||||
match collection.delete_by_id(payment_id) {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("Successfully deleted payment with ID {}", payment_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to delete payment {}: {:?}", payment_id, e);
|
|
||||||
Err(format!("Failed to delete payment: {:?}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
#![allow(dead_code)] // Database utility functions may not all be used yet
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Stored registration data linked to payment intent
|
|
||||||
/// This preserves all user form data until company creation after payment success
|
|
||||||
/// NOTE: This uses file-based storage until we can add the model to heromodels
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StoredRegistrationData {
|
|
||||||
pub payment_intent_id: String,
|
|
||||||
pub company_name: String,
|
|
||||||
pub company_type: String,
|
|
||||||
pub company_email: String,
|
|
||||||
pub company_phone: String,
|
|
||||||
pub company_website: Option<String>,
|
|
||||||
pub company_address: String,
|
|
||||||
pub company_industry: Option<String>,
|
|
||||||
pub company_purpose: Option<String>,
|
|
||||||
pub fiscal_year_end: Option<String>,
|
|
||||||
pub shareholders: String, // JSON string of shareholders array
|
|
||||||
pub payment_plan: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File path for storing registration data
|
|
||||||
const REGISTRATION_DATA_FILE: &str = "data/registration_data.json";
|
|
||||||
|
|
||||||
/// Ensure data directory exists
|
|
||||||
fn ensure_data_directory() -> Result<(), String> {
|
|
||||||
let data_dir = Path::new("data");
|
|
||||||
if !data_dir.exists() {
|
|
||||||
fs::create_dir_all(data_dir)
|
|
||||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all registration data from file
|
|
||||||
fn load_registration_data() -> Result<HashMap<String, StoredRegistrationData>, String> {
|
|
||||||
if !Path::new(REGISTRATION_DATA_FILE).exists() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(REGISTRATION_DATA_FILE)
|
|
||||||
.map_err(|e| format!("Failed to read registration data file: {}", e))?;
|
|
||||||
|
|
||||||
let data: HashMap<String, StoredRegistrationData> = serde_json::from_str(&content)
|
|
||||||
.map_err(|e| format!("Failed to parse registration data: {}", e))?;
|
|
||||||
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save all registration data to file
|
|
||||||
fn save_registration_data(data: &HashMap<String, StoredRegistrationData>) -> Result<(), String> {
|
|
||||||
ensure_data_directory()?;
|
|
||||||
|
|
||||||
let content = serde_json::to_string_pretty(data)
|
|
||||||
.map_err(|e| format!("Failed to serialize registration data: {}", e))?;
|
|
||||||
|
|
||||||
fs::write(REGISTRATION_DATA_FILE, content)
|
|
||||||
.map_err(|e| format!("Failed to write registration data file: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StoredRegistrationData {
|
|
||||||
/// Create new stored registration data
|
|
||||||
pub fn new(
|
|
||||||
payment_intent_id: String,
|
|
||||||
company_name: String,
|
|
||||||
company_type: String,
|
|
||||||
company_email: String,
|
|
||||||
company_phone: String,
|
|
||||||
company_website: Option<String>,
|
|
||||||
company_address: String,
|
|
||||||
company_industry: Option<String>,
|
|
||||||
company_purpose: Option<String>,
|
|
||||||
fiscal_year_end: Option<String>,
|
|
||||||
shareholders: String,
|
|
||||||
payment_plan: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
payment_intent_id,
|
|
||||||
company_name,
|
|
||||||
company_type,
|
|
||||||
company_email,
|
|
||||||
company_phone,
|
|
||||||
company_website,
|
|
||||||
company_address,
|
|
||||||
company_industry,
|
|
||||||
company_purpose,
|
|
||||||
fiscal_year_end,
|
|
||||||
shareholders,
|
|
||||||
payment_plan,
|
|
||||||
created_at: chrono::Utc::now().timestamp(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store registration data linked to payment intent
|
|
||||||
pub fn store_registration_data(
|
|
||||||
payment_intent_id: String,
|
|
||||||
data: crate::controllers::payment::CompanyRegistrationData,
|
|
||||||
) -> Result<(u32, StoredRegistrationData), String> {
|
|
||||||
// Create stored registration data
|
|
||||||
let stored_data = StoredRegistrationData::new(
|
|
||||||
payment_intent_id.clone(),
|
|
||||||
data.company_name,
|
|
||||||
data.company_type,
|
|
||||||
data.company_email
|
|
||||||
.unwrap_or_else(|| "noemail@example.com".to_string()),
|
|
||||||
data.company_phone
|
|
||||||
.unwrap_or_else(|| "+1234567890".to_string()),
|
|
||||||
data.company_website,
|
|
||||||
data.company_address
|
|
||||||
.unwrap_or_else(|| "No address provided".to_string()),
|
|
||||||
data.company_industry,
|
|
||||||
data.company_purpose,
|
|
||||||
data.fiscal_year_end,
|
|
||||||
data.shareholders,
|
|
||||||
data.payment_plan,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load existing data
|
|
||||||
let mut all_data = load_registration_data()?;
|
|
||||||
|
|
||||||
// Add new data
|
|
||||||
all_data.insert(payment_intent_id.clone(), stored_data.clone());
|
|
||||||
|
|
||||||
// Save to file
|
|
||||||
save_registration_data(&all_data)?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Stored registration data for payment intent {}",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return with a generated ID (timestamp-based)
|
|
||||||
let id = chrono::Utc::now().timestamp() as u32;
|
|
||||||
Ok((id, stored_data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve registration data by payment intent ID
|
|
||||||
pub fn get_registration_data(
|
|
||||||
payment_intent_id: &str,
|
|
||||||
) -> Result<Option<StoredRegistrationData>, String> {
|
|
||||||
let all_data = load_registration_data()?;
|
|
||||||
Ok(all_data.get(payment_intent_id).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all stored registration data
|
|
||||||
pub fn get_all_registration_data() -> Result<Vec<StoredRegistrationData>, String> {
|
|
||||||
let all_data = load_registration_data()?;
|
|
||||||
Ok(all_data.into_values().collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete registration data by payment intent ID
|
|
||||||
pub fn delete_registration_data(payment_intent_id: &str) -> Result<bool, String> {
|
|
||||||
let mut all_data = load_registration_data()?;
|
|
||||||
|
|
||||||
if all_data.remove(payment_intent_id).is_some() {
|
|
||||||
save_registration_data(&all_data)?;
|
|
||||||
log::info!(
|
|
||||||
"Deleted registration data for payment intent: {}",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
log::warn!(
|
|
||||||
"Registration data not found for payment intent: {}",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update registration data
|
|
||||||
pub fn update_registration_data(
|
|
||||||
payment_intent_id: &str,
|
|
||||||
updated_data: StoredRegistrationData,
|
|
||||||
) -> Result<Option<StoredRegistrationData>, String> {
|
|
||||||
let mut all_data = load_registration_data()?;
|
|
||||||
|
|
||||||
all_data.insert(payment_intent_id.to_string(), updated_data.clone());
|
|
||||||
save_registration_data(&all_data)?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Updated registration data for payment intent: {}",
|
|
||||||
payment_intent_id
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Some(updated_data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert StoredRegistrationData back to CompanyRegistrationData for processing
|
|
||||||
pub fn stored_to_registration_data(
|
|
||||||
stored: &StoredRegistrationData,
|
|
||||||
) -> crate::controllers::payment::CompanyRegistrationData {
|
|
||||||
crate::controllers::payment::CompanyRegistrationData {
|
|
||||||
company_name: stored.company_name.clone(),
|
|
||||||
company_type: stored.company_type.clone(),
|
|
||||||
company_email: Some(stored.company_email.clone()),
|
|
||||||
company_phone: Some(stored.company_phone.clone()),
|
|
||||||
company_website: stored.company_website.clone(),
|
|
||||||
company_address: Some(stored.company_address.clone()),
|
|
||||||
company_industry: stored.company_industry.clone(),
|
|
||||||
company_purpose: stored.company_purpose.clone(),
|
|
||||||
fiscal_year_end: stored.fiscal_year_end.clone(),
|
|
||||||
shareholders: stored.shareholders.clone(),
|
|
||||||
payment_plan: stored.payment_plan.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_stored_registration_data_creation() {
|
|
||||||
let data = StoredRegistrationData::new(
|
|
||||||
"pi_test123".to_string(),
|
|
||||||
"Test Company".to_string(),
|
|
||||||
"Single FZC".to_string(),
|
|
||||||
"test@example.com".to_string(),
|
|
||||||
"+1234567890".to_string(),
|
|
||||||
Some("https://example.com".to_string()),
|
|
||||||
"123 Test St".to_string(),
|
|
||||||
Some("Technology".to_string()),
|
|
||||||
Some("Software development".to_string()),
|
|
||||||
Some("December".to_string()),
|
|
||||||
"[]".to_string(),
|
|
||||||
"monthly".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(data.payment_intent_id, "pi_test123");
|
|
||||||
assert_eq!(data.company_name, "Test Company");
|
|
||||||
assert_eq!(data.company_type, "Single FZC");
|
|
||||||
assert_eq!(data.company_email, "test@example.com");
|
|
||||||
assert!(data.created_at > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_stored_to_registration_data_conversion() {
|
|
||||||
let stored = StoredRegistrationData::new(
|
|
||||||
"pi_test123".to_string(),
|
|
||||||
"Test Company".to_string(),
|
|
||||||
"Single FZC".to_string(),
|
|
||||||
"test@example.com".to_string(),
|
|
||||||
"+1234567890".to_string(),
|
|
||||||
Some("https://example.com".to_string()),
|
|
||||||
"123 Test St".to_string(),
|
|
||||||
Some("Technology".to_string()),
|
|
||||||
Some("Software development".to_string()),
|
|
||||||
Some("December".to_string()),
|
|
||||||
"[]".to_string(),
|
|
||||||
"monthly".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let registration_data = stored_to_registration_data(&stored);
|
|
||||||
|
|
||||||
assert_eq!(registration_data.company_name, "Test Company");
|
|
||||||
assert_eq!(registration_data.company_type, "Single FZC");
|
|
||||||
assert_eq!(
|
|
||||||
registration_data.company_email,
|
|
||||||
Some("test@example.com".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(registration_data.payment_plan, "monthly");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// Library exports for testing and external use
|
|
||||||
|
|
||||||
use actix_web::cookie::Key;
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
|
|
||||||
pub mod config;
|
|
||||||
pub mod controllers;
|
|
||||||
pub mod db;
|
|
||||||
pub mod middleware;
|
|
||||||
pub mod models;
|
|
||||||
pub mod routes;
|
|
||||||
pub mod utils;
|
|
||||||
pub mod validators;
|
|
||||||
|
|
||||||
// Session key needed by routes
|
|
||||||
lazy_static! {
|
|
||||||
pub static ref SESSION_KEY: Key = {
|
|
||||||
// In production, this should be a proper secret key from environment variables
|
|
||||||
let secret = std::env::var("SESSION_SECRET").unwrap_or_else(|_| {
|
|
||||||
// Create a key that's at least 64 bytes long
|
|
||||||
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure the key is at least 64 bytes
|
|
||||||
let mut key_bytes = secret.into_bytes();
|
|
||||||
while key_bytes.len() < 64 {
|
|
||||||
key_bytes.extend_from_slice(b"padding");
|
|
||||||
}
|
|
||||||
key_bytes.truncate(64);
|
|
||||||
|
|
||||||
Key::from(&key_bytes)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export commonly used types for easier testing
|
|
||||||
pub use controllers::payment::CompanyRegistrationData;
|
|
||||||
pub use validators::{CompanyRegistrationValidator, ValidationError, ValidationResult};
|
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
use actix_files as fs;
|
use actix_files as fs;
|
||||||
use actix_web::middleware::Logger;
|
|
||||||
use actix_web::{App, HttpServer, web};
|
use actix_web::{App, HttpServer, web};
|
||||||
use lazy_static::lazy_static;
|
use actix_web::middleware::Logger;
|
||||||
use std::env;
|
|
||||||
use std::io;
|
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
|
use std::io;
|
||||||
|
use std::env;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod controllers;
|
mod controllers;
|
||||||
mod db;
|
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod validators;
|
|
||||||
|
|
||||||
// Import middleware components
|
// Import middleware components
|
||||||
use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
|
use middleware::{RequestTimer, SecurityHeaders, JwtAuth};
|
||||||
use models::initialize_mock_data;
|
|
||||||
use utils::redis_service;
|
use utils::redis_service;
|
||||||
|
use models::initialize_mock_data;
|
||||||
|
|
||||||
// Initialize lazy_static for in-memory storage
|
// Initialize lazy_static for in-memory storage
|
||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
@@ -31,13 +29,13 @@ lazy_static! {
|
|||||||
// Create a key that's at least 64 bytes long
|
// Create a key that's at least 64 bytes long
|
||||||
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
|
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the key is at least 64 bytes
|
// Ensure the key is at least 64 bytes
|
||||||
let mut key_bytes = secret.as_bytes().to_vec();
|
let mut key_bytes = secret.as_bytes().to_vec();
|
||||||
while key_bytes.len() < 64 {
|
while key_bytes.len() < 64 {
|
||||||
key_bytes.extend_from_slice(b"0123456789abcdef");
|
key_bytes.extend_from_slice(b"0123456789abcdef");
|
||||||
}
|
}
|
||||||
|
|
||||||
actix_web::cookie::Key::from(&key_bytes[0..64])
|
actix_web::cookie::Key::from(&key_bytes[0..64])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -47,22 +45,14 @@ async fn main() -> io::Result<()> {
|
|||||||
// Initialize environment
|
// Initialize environment
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
|
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
let config = config::get_config();
|
let config = config::get_config();
|
||||||
|
|
||||||
// Check for port override from environment variable or command line arguments
|
// Check for port override from command line arguments
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
let mut port = config.server.port;
|
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::<u16>() {
|
|
||||||
port = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check command line arguments (takes precedence over env var)
|
|
||||||
for i in 1..args.len() {
|
for i in 1..args.len() {
|
||||||
if args[i] == "--port" && i + 1 < args.len() {
|
if args[i] == "--port" && i + 1 < args.len() {
|
||||||
if let Ok(p) = args[i + 1].parse::<u16>() {
|
if let Ok(p) = args[i + 1].parse::<u16>() {
|
||||||
@@ -71,28 +61,24 @@ async fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bind_address = format!("{}:{}", config.server.host, port);
|
let bind_address = format!("{}:{}", config.server.host, port);
|
||||||
|
|
||||||
// Initialize Redis client
|
// Initialize Redis client
|
||||||
let redis_url =
|
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
|
||||||
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
|
|
||||||
if let Err(e) = redis_service::init_redis_client(&redis_url) {
|
if let Err(e) = redis_service::init_redis_client(&redis_url) {
|
||||||
log::error!("Failed to initialize Redis client: {}", e);
|
log::error!("Failed to initialize Redis client: {}", e);
|
||||||
log::warn!("Calendar functionality will not work properly without Redis");
|
log::warn!("Calendar functionality will not work properly without Redis");
|
||||||
} else {
|
} else {
|
||||||
log::info!("Redis client initialized successfully");
|
log::info!("Redis client initialized successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize mock data for DeFi operations
|
// Initialize mock data for DeFi operations
|
||||||
initialize_mock_data();
|
initialize_mock_data();
|
||||||
log::info!("DeFi mock data initialized successfully");
|
log::info!("DeFi mock data initialized successfully");
|
||||||
|
|
||||||
// Governance activity tracker is now ready to record real user activities
|
|
||||||
log::info!("Governance activity tracker initialized and ready");
|
|
||||||
|
|
||||||
log::info!("Starting server at http://{}", bind_address);
|
log::info!("Starting server at http://{}", bind_address);
|
||||||
|
|
||||||
// Create and configure the HTTP server
|
// Create and configure the HTTP server
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
// Initialize Tera templates
|
// Initialize Tera templates
|
||||||
@@ -103,10 +89,10 @@ async fn main() -> io::Result<()> {
|
|||||||
::std::process::exit(1);
|
::std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register custom Tera functions
|
// Register custom Tera functions
|
||||||
utils::register_tera_functions(&mut tera);
|
utils::register_tera_functions(&mut tera);
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
// Enable logger middleware
|
// Enable logger middleware
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
@@ -120,8 +106,6 @@ async fn main() -> io::Result<()> {
|
|||||||
.app_data(web::Data::new(tera))
|
.app_data(web::Data::new(tera))
|
||||||
// Configure routes
|
// Configure routes
|
||||||
.configure(routes::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)?
|
.bind(bind_address)?
|
||||||
.workers(num_cpus::get())
|
.workers(num_cpus::get())
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ pub struct Asset {
|
|||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Asset {
|
impl Asset {
|
||||||
/// Creates a new asset
|
/// Creates a new asset
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
|||||||
@@ -1,4 +1,61 @@
|
|||||||
// No imports needed for this module currently
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Represents a calendar event
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarEvent {
|
||||||
|
/// Unique identifier for the event
|
||||||
|
pub id: String,
|
||||||
|
/// Title of the event
|
||||||
|
pub title: String,
|
||||||
|
/// Description of the event
|
||||||
|
pub description: String,
|
||||||
|
/// Start time of the event
|
||||||
|
pub start_time: DateTime<Utc>,
|
||||||
|
/// End time of the event
|
||||||
|
pub end_time: DateTime<Utc>,
|
||||||
|
/// Color of the event (hex code)
|
||||||
|
pub color: String,
|
||||||
|
/// Whether the event is an all-day event
|
||||||
|
pub all_day: bool,
|
||||||
|
/// User ID of the event creator
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarEvent {
|
||||||
|
/// Creates a new calendar event
|
||||||
|
pub fn new(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
start_time: DateTime<Utc>,
|
||||||
|
end_time: DateTime<Utc>,
|
||||||
|
color: Option<String>,
|
||||||
|
all_day: bool,
|
||||||
|
user_id: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
color: color.unwrap_or_else(|| "#4285F4".to_string()), // Google Calendar blue
|
||||||
|
all_day,
|
||||||
|
user_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the event to a JSON string
|
||||||
|
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an event from a JSON string
|
||||||
|
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||||
|
serde_json::from_str(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents a view mode for the calendar
|
/// Represents a view mode for the calendar
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -34,4 +91,4 @@ impl CalendarViewMode {
|
|||||||
Self::Day => "day",
|
Self::Day => "day",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,99 +1,7 @@
|
|||||||
#![allow(dead_code)] // Model utility functions may not all be used yet
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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<Utc>,
|
|
||||||
pub metadata: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
/// Contract status enum
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum ContractStatus {
|
pub enum ContractStatus {
|
||||||
@@ -102,7 +10,7 @@ pub enum ContractStatus {
|
|||||||
Signed,
|
Signed,
|
||||||
Active,
|
Active,
|
||||||
Expired,
|
Expired,
|
||||||
Cancelled,
|
Cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContractStatus {
|
impl ContractStatus {
|
||||||
@@ -129,7 +37,7 @@ pub enum ContractType {
|
|||||||
Distribution,
|
Distribution,
|
||||||
License,
|
License,
|
||||||
Membership,
|
Membership,
|
||||||
Other,
|
Other
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContractType {
|
impl ContractType {
|
||||||
@@ -153,7 +61,7 @@ impl ContractType {
|
|||||||
pub enum SignerStatus {
|
pub enum SignerStatus {
|
||||||
Pending,
|
Pending,
|
||||||
Signed,
|
Signed,
|
||||||
Rejected,
|
Rejected
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignerStatus {
|
impl SignerStatus {
|
||||||
@@ -177,7 +85,6 @@ pub struct ContractSigner {
|
|||||||
pub comments: Option<String>,
|
pub comments: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ContractSigner {
|
impl ContractSigner {
|
||||||
/// Creates a new contract signer
|
/// Creates a new contract signer
|
||||||
pub fn new(name: String, email: String) -> Self {
|
pub fn new(name: String, email: String) -> Self {
|
||||||
@@ -216,15 +123,9 @@ pub struct ContractRevision {
|
|||||||
pub comments: Option<String>,
|
pub comments: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ContractRevision {
|
impl ContractRevision {
|
||||||
/// Creates a new contract revision
|
/// Creates a new contract revision
|
||||||
pub fn new(
|
pub fn new(version: u32, content: String, created_by: String, comments: Option<String>) -> Self {
|
||||||
version: u32,
|
|
||||||
content: String,
|
|
||||||
created_by: String,
|
|
||||||
comments: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
version,
|
version,
|
||||||
content,
|
content,
|
||||||
@@ -265,16 +166,9 @@ pub struct Contract {
|
|||||||
pub toc: Option<Vec<TocItem>>,
|
pub toc: Option<Vec<TocItem>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Contract {
|
impl Contract {
|
||||||
/// Creates a new contract
|
/// Creates a new contract
|
||||||
pub fn new(
|
pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option<String>) -> Self {
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
contract_type: ContractType,
|
|
||||||
created_by: String,
|
|
||||||
organization_id: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
title,
|
title,
|
||||||
@@ -332,9 +226,7 @@ impl Contract {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.signers
|
self.signers.iter().all(|signer| signer.status == SignerStatus::Signed)
|
||||||
.iter()
|
|
||||||
.all(|signer| signer.status == SignerStatus::Signed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the contract as signed if all signers have signed
|
/// Marks the contract as signed if all signers have signed
|
||||||
@@ -366,26 +258,17 @@ impl Contract {
|
|||||||
|
|
||||||
/// Gets the number of pending signers
|
/// Gets the number of pending signers
|
||||||
pub fn pending_signers_count(&self) -> usize {
|
pub fn pending_signers_count(&self) -> usize {
|
||||||
self.signers
|
self.signers.iter().filter(|s| s.status == SignerStatus::Pending).count()
|
||||||
.iter()
|
|
||||||
.filter(|s| s.status == SignerStatus::Pending)
|
|
||||||
.count()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the number of signed signers
|
/// Gets the number of signed signers
|
||||||
pub fn signed_signers_count(&self) -> usize {
|
pub fn signed_signers_count(&self) -> usize {
|
||||||
self.signers
|
self.signers.iter().filter(|s| s.status == SignerStatus::Signed).count()
|
||||||
.iter()
|
|
||||||
.filter(|s| s.status == SignerStatus::Signed)
|
|
||||||
.count()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the number of rejected signers
|
/// Gets the number of rejected signers
|
||||||
pub fn rejected_signers_count(&self) -> usize {
|
pub fn rejected_signers_count(&self) -> usize {
|
||||||
self.signers
|
self.signers.iter().filter(|s| s.status == SignerStatus::Rejected).count()
|
||||||
.iter()
|
|
||||||
.filter(|s| s.status == SignerStatus::Rejected)
|
|
||||||
.count()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,26 +296,11 @@ impl ContractStatistics {
|
|||||||
/// Creates new contract statistics from a list of contracts
|
/// Creates new contract statistics from a list of contracts
|
||||||
pub fn new(contracts: &[Contract]) -> Self {
|
pub fn new(contracts: &[Contract]) -> Self {
|
||||||
let total_contracts = contracts.len();
|
let total_contracts = contracts.len();
|
||||||
let draft_contracts = contracts
|
let draft_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Draft).count();
|
||||||
.iter()
|
let pending_signature_contracts = contracts.iter().filter(|c| c.status == ContractStatus::PendingSignatures).count();
|
||||||
.filter(|c| c.status == ContractStatus::Draft)
|
let signed_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Signed).count();
|
||||||
.count();
|
let expired_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Expired).count();
|
||||||
let pending_signature_contracts = contracts
|
let cancelled_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Cancelled).count();
|
||||||
.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 {
|
Self {
|
||||||
total_contracts,
|
total_contracts,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ pub enum DefiPositionStatus {
|
|||||||
Cancelled
|
Cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl DefiPositionStatus {
|
impl DefiPositionStatus {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
@@ -36,7 +35,6 @@ pub enum DefiPositionType {
|
|||||||
Collateral,
|
Collateral,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl DefiPositionType {
|
impl DefiPositionType {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
@@ -97,7 +95,6 @@ pub struct DefiDatabase {
|
|||||||
receiving_positions: HashMap<String, ReceivingPosition>,
|
receiving_positions: HashMap<String, ReceivingPosition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl DefiDatabase {
|
impl DefiDatabase {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
#![allow(dead_code)] // Model utility functions may not all be used yet
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Document type enumeration
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum DocumentType {
|
|
||||||
Articles, // Articles of Incorporation
|
|
||||||
Certificate, // Business certificates
|
|
||||||
License, // Business licenses
|
|
||||||
Contract, // Contracts and agreements
|
|
||||||
Financial, // Financial documents
|
|
||||||
Legal, // Legal documents
|
|
||||||
Other, // Other documents
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentType {
|
|
||||||
fn default() -> Self {
|
|
||||||
DocumentType::Other
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocumentType {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
DocumentType::Articles => "Articles of Incorporation",
|
|
||||||
DocumentType::Certificate => "Business Certificate",
|
|
||||||
DocumentType::License => "Business License",
|
|
||||||
DocumentType::Contract => "Contract/Agreement",
|
|
||||||
DocumentType::Financial => "Financial Document",
|
|
||||||
DocumentType::Legal => "Legal Document",
|
|
||||||
DocumentType::Other => "Other",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(s: &str) -> Self {
|
|
||||||
match s {
|
|
||||||
"Articles" => DocumentType::Articles,
|
|
||||||
"Certificate" => DocumentType::Certificate,
|
|
||||||
"License" => DocumentType::License,
|
|
||||||
"Contract" => DocumentType::Contract,
|
|
||||||
"Financial" => DocumentType::Financial,
|
|
||||||
"Legal" => DocumentType::Legal,
|
|
||||||
_ => DocumentType::Other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all() -> Vec<DocumentType> {
|
|
||||||
vec![
|
|
||||||
DocumentType::Articles,
|
|
||||||
DocumentType::Certificate,
|
|
||||||
DocumentType::License,
|
|
||||||
DocumentType::Contract,
|
|
||||||
DocumentType::Financial,
|
|
||||||
DocumentType::Legal,
|
|
||||||
DocumentType::Other,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Document model for company document management
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Document {
|
|
||||||
pub id: u32,
|
|
||||||
pub name: String,
|
|
||||||
pub file_path: String,
|
|
||||||
pub file_size: u64,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub company_id: u32,
|
|
||||||
pub document_type: DocumentType,
|
|
||||||
pub uploaded_by: String,
|
|
||||||
pub upload_date: DateTime<Utc>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub is_public: bool,
|
|
||||||
pub checksum: Option<String>,
|
|
||||||
// Template-friendly fields
|
|
||||||
pub is_pdf: bool,
|
|
||||||
pub is_image: bool,
|
|
||||||
pub document_type_str: String,
|
|
||||||
pub formatted_file_size: String,
|
|
||||||
pub formatted_upload_date: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Document {
|
|
||||||
/// Creates a new document (ID will be assigned by database)
|
|
||||||
pub fn new(
|
|
||||||
name: String,
|
|
||||||
file_path: String,
|
|
||||||
file_size: u64,
|
|
||||||
mime_type: String,
|
|
||||||
company_id: u32,
|
|
||||||
uploaded_by: String,
|
|
||||||
) -> Self {
|
|
||||||
let upload_date = Utc::now();
|
|
||||||
let is_pdf = mime_type == "application/pdf";
|
|
||||||
let is_image = mime_type.starts_with("image/");
|
|
||||||
let document_type = DocumentType::default();
|
|
||||||
let document_type_str = document_type.as_str().to_string();
|
|
||||||
let formatted_file_size = Self::format_size_bytes(file_size);
|
|
||||||
let formatted_upload_date = upload_date.format("%Y-%m-%d %H:%M").to_string();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: 0, // Will be assigned by database
|
|
||||||
name,
|
|
||||||
file_path,
|
|
||||||
file_size,
|
|
||||||
mime_type,
|
|
||||||
company_id,
|
|
||||||
document_type,
|
|
||||||
uploaded_by,
|
|
||||||
upload_date,
|
|
||||||
description: None,
|
|
||||||
is_public: false,
|
|
||||||
checksum: None,
|
|
||||||
is_pdf,
|
|
||||||
is_image,
|
|
||||||
document_type_str,
|
|
||||||
formatted_file_size,
|
|
||||||
formatted_upload_date,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builder pattern methods
|
|
||||||
pub fn document_type(mut self, document_type: DocumentType) -> Self {
|
|
||||||
self.document_type_str = document_type.as_str().to_string();
|
|
||||||
self.document_type = document_type;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn description(mut self, description: String) -> Self {
|
|
||||||
self.description = Some(description);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_public(mut self, is_public: bool) -> Self {
|
|
||||||
self.is_public = is_public;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn checksum(mut self, checksum: String) -> Self {
|
|
||||||
self.checksum = Some(checksum);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the file extension from the filename
|
|
||||||
pub fn file_extension(&self) -> Option<String> {
|
|
||||||
std::path::Path::new(&self.name)
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.map(|ext| ext.to_lowercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the document is an image
|
|
||||||
pub fn is_image(&self) -> bool {
|
|
||||||
self.mime_type.starts_with("image/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the document is a PDF
|
|
||||||
pub fn is_pdf(&self) -> bool {
|
|
||||||
self.mime_type == "application/pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a human-readable file size
|
|
||||||
pub fn formatted_file_size(&self) -> String {
|
|
||||||
let size = self.file_size as f64;
|
|
||||||
if size < 1024.0 {
|
|
||||||
format!("{} B", size)
|
|
||||||
} else if size < 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} KB", size / 1024.0)
|
|
||||||
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
|
||||||
} else {
|
|
||||||
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the upload date formatted for display
|
|
||||||
pub fn formatted_upload_date(&self) -> String {
|
|
||||||
self.upload_date.format("%Y-%m-%d %H:%M").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static method to format file size
|
|
||||||
fn format_size_bytes(bytes: u64) -> String {
|
|
||||||
let size = bytes as f64;
|
|
||||||
if size < 1024.0 {
|
|
||||||
format!("{} B", size)
|
|
||||||
} else if size < 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} KB", size / 1024.0)
|
|
||||||
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
|
||||||
} else {
|
|
||||||
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Document statistics for dashboard
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DocumentStatistics {
|
|
||||||
pub total_documents: usize,
|
|
||||||
pub total_size: u64,
|
|
||||||
pub formatted_total_size: String,
|
|
||||||
pub by_type: std::collections::HashMap<String, usize>,
|
|
||||||
pub recent_uploads: usize, // Last 30 days
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocumentStatistics {
|
|
||||||
pub fn new(documents: &[Document]) -> Self {
|
|
||||||
let mut by_type = std::collections::HashMap::new();
|
|
||||||
let mut total_size = 0;
|
|
||||||
let mut recent_uploads = 0;
|
|
||||||
|
|
||||||
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
|
|
||||||
|
|
||||||
for doc in documents {
|
|
||||||
total_size += doc.file_size;
|
|
||||||
|
|
||||||
let type_key = doc.document_type.as_str().to_string();
|
|
||||||
*by_type.entry(type_key).or_insert(0) += 1;
|
|
||||||
|
|
||||||
if doc.upload_date > thirty_days_ago {
|
|
||||||
recent_uploads += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let formatted_total_size = Self::format_size_bytes(total_size);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
total_documents: documents.len(),
|
|
||||||
total_size,
|
|
||||||
formatted_total_size,
|
|
||||||
by_type,
|
|
||||||
recent_uploads,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn formatted_total_size(&self) -> String {
|
|
||||||
Self::format_size_bytes(self.total_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_size_bytes(bytes: u64) -> String {
|
|
||||||
let size = bytes as f64;
|
|
||||||
if size < 1024.0 {
|
|
||||||
format!("{} B", size)
|
|
||||||
} else if size < 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} KB", size / 1024.0)
|
|
||||||
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
|
||||||
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
|
||||||
} else {
|
|
||||||
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,7 +110,6 @@ pub struct FlowStep {
|
|||||||
pub logs: Vec<FlowLog>,
|
pub logs: Vec<FlowLog>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl FlowStep {
|
impl FlowStep {
|
||||||
/// Creates a new flow step
|
/// Creates a new flow step
|
||||||
pub fn new(name: String, description: String, order: u32) -> Self {
|
pub fn new(name: String, description: String, order: u32) -> Self {
|
||||||
@@ -190,7 +189,6 @@ pub struct FlowLog {
|
|||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl FlowLog {
|
impl FlowLog {
|
||||||
/// Creates a new flow log
|
/// Creates a new flow log
|
||||||
pub fn new(message: String) -> Self {
|
pub fn new(message: String) -> Self {
|
||||||
@@ -233,7 +231,6 @@ pub struct Flow {
|
|||||||
pub current_step: Option<FlowStep>,
|
pub current_step: Option<FlowStep>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Flow {
|
impl Flow {
|
||||||
/// Creates a new flow
|
/// Creates a new flow
|
||||||
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {
|
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {
|
||||||
|
|||||||
248
actix_mvc_app/src/models/governance.rs
Normal file
248
actix_mvc_app/src/models/governance.rs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Represents the status of a governance proposal
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ProposalStatus {
|
||||||
|
/// Proposal is in draft status, not yet open for voting
|
||||||
|
Draft,
|
||||||
|
/// Proposal is active and open for voting
|
||||||
|
Active,
|
||||||
|
/// Proposal has been approved by the community
|
||||||
|
Approved,
|
||||||
|
/// Proposal has been rejected by the community
|
||||||
|
Rejected,
|
||||||
|
/// Proposal has been cancelled by the creator
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProposalStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ProposalStatus::Draft => write!(f, "Draft"),
|
||||||
|
ProposalStatus::Active => write!(f, "Active"),
|
||||||
|
ProposalStatus::Approved => write!(f, "Approved"),
|
||||||
|
ProposalStatus::Rejected => write!(f, "Rejected"),
|
||||||
|
ProposalStatus::Cancelled => write!(f, "Cancelled"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a vote on a governance proposal
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum VoteType {
|
||||||
|
/// Vote in favor of the proposal
|
||||||
|
Yes,
|
||||||
|
/// Vote against the proposal
|
||||||
|
No,
|
||||||
|
/// Abstain from voting on the proposal
|
||||||
|
Abstain,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for VoteType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
VoteType::Yes => write!(f, "Yes"),
|
||||||
|
VoteType::No => write!(f, "No"),
|
||||||
|
VoteType::Abstain => write!(f, "Abstain"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a governance proposal in the system
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Proposal {
|
||||||
|
/// Unique identifier for the proposal
|
||||||
|
pub id: String,
|
||||||
|
/// User ID of the proposal creator
|
||||||
|
pub creator_id: i32,
|
||||||
|
/// Name of the proposal creator
|
||||||
|
pub creator_name: String,
|
||||||
|
/// Title of the proposal
|
||||||
|
pub title: String,
|
||||||
|
/// Detailed description of the proposal
|
||||||
|
pub description: String,
|
||||||
|
/// Current status of the proposal
|
||||||
|
pub status: ProposalStatus,
|
||||||
|
/// Date and time when the proposal was created
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// Date and time when the proposal was last updated
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// Date and time when voting starts
|
||||||
|
pub voting_starts_at: Option<DateTime<Utc>>,
|
||||||
|
/// Date and time when voting ends
|
||||||
|
pub voting_ends_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proposal {
|
||||||
|
/// Creates a new proposal
|
||||||
|
pub fn new(creator_id: i32, creator_name: String, title: String, description: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
creator_id,
|
||||||
|
creator_name,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status: ProposalStatus::Draft,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
voting_starts_at: None,
|
||||||
|
voting_ends_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the proposal status
|
||||||
|
pub fn update_status(&mut self, status: ProposalStatus) {
|
||||||
|
self.status = status;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the voting period for the proposal
|
||||||
|
pub fn set_voting_period(&mut self, starts_at: DateTime<Utc>, ends_at: DateTime<Utc>) {
|
||||||
|
self.voting_starts_at = Some(starts_at);
|
||||||
|
self.voting_ends_at = Some(ends_at);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activates the proposal for voting
|
||||||
|
pub fn activate(&mut self) {
|
||||||
|
self.status = ProposalStatus::Active;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the proposal
|
||||||
|
pub fn cancel(&mut self) {
|
||||||
|
self.status = ProposalStatus::Cancelled;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a vote cast on a proposal
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Vote {
|
||||||
|
/// Unique identifier for the vote
|
||||||
|
pub id: String,
|
||||||
|
/// ID of the proposal being voted on
|
||||||
|
pub proposal_id: String,
|
||||||
|
/// User ID of the voter
|
||||||
|
pub voter_id: i32,
|
||||||
|
/// Name of the voter
|
||||||
|
pub voter_name: String,
|
||||||
|
/// Type of vote cast
|
||||||
|
pub vote_type: VoteType,
|
||||||
|
/// Optional comment explaining the vote
|
||||||
|
pub comment: Option<String>,
|
||||||
|
/// Date and time when the vote was cast
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// Date and time when the vote was last updated
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vote {
|
||||||
|
/// Creates a new vote
|
||||||
|
pub fn new(proposal_id: String, voter_id: i32, voter_name: String, vote_type: VoteType, comment: Option<String>) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
proposal_id,
|
||||||
|
voter_id,
|
||||||
|
voter_name,
|
||||||
|
vote_type,
|
||||||
|
comment,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the vote type
|
||||||
|
pub fn update_vote(&mut self, vote_type: VoteType, comment: Option<String>) {
|
||||||
|
self.vote_type = vote_type;
|
||||||
|
self.comment = comment;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a filter for searching proposals
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProposalFilter {
|
||||||
|
/// Filter by proposal status
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// Filter by creator ID
|
||||||
|
pub creator_id: Option<i32>,
|
||||||
|
/// Search term for title and description
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProposalFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
status: None,
|
||||||
|
creator_id: None,
|
||||||
|
search: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the voting results for a proposal
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VotingResults {
|
||||||
|
/// Proposal ID
|
||||||
|
pub proposal_id: String,
|
||||||
|
/// Number of yes votes
|
||||||
|
pub yes_count: usize,
|
||||||
|
/// Number of no votes
|
||||||
|
pub no_count: usize,
|
||||||
|
/// Number of abstain votes
|
||||||
|
pub abstain_count: usize,
|
||||||
|
/// Total number of votes
|
||||||
|
pub total_votes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VotingResults {
|
||||||
|
/// Creates a new empty voting results object
|
||||||
|
pub fn new(proposal_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
proposal_id,
|
||||||
|
yes_count: 0,
|
||||||
|
no_count: 0,
|
||||||
|
abstain_count: 0,
|
||||||
|
total_votes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a vote to the results
|
||||||
|
pub fn add_vote(&mut self, vote_type: &VoteType) {
|
||||||
|
match vote_type {
|
||||||
|
VoteType::Yes => self.yes_count += 1,
|
||||||
|
VoteType::No => self.no_count += 1,
|
||||||
|
VoteType::Abstain => self.abstain_count += 1,
|
||||||
|
}
|
||||||
|
self.total_votes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the percentage of yes votes
|
||||||
|
pub fn yes_percentage(&self) -> f64 {
|
||||||
|
if self.total_votes == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.yes_count as f64 / self.total_votes as f64) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the percentage of no votes
|
||||||
|
pub fn no_percentage(&self) -> f64 {
|
||||||
|
if self.total_votes == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.no_count as f64 / self.total_votes as f64) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the percentage of abstain votes
|
||||||
|
pub fn abstain_percentage(&self) -> f64 {
|
||||||
|
if self.total_votes == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.abstain_count as f64 / self.total_votes as f64) * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::asset::AssetType;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use crate::models::asset::{Asset, AssetType};
|
||||||
|
|
||||||
/// Status of a marketplace listing
|
/// Status of a marketplace listing
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -12,7 +12,6 @@ pub enum ListingStatus {
|
|||||||
Expired,
|
Expired,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ListingStatus {
|
impl ListingStatus {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
@@ -64,7 +63,6 @@ pub enum BidStatus {
|
|||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl BidStatus {
|
impl BidStatus {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
@@ -105,7 +103,6 @@ pub struct Listing {
|
|||||||
pub image_url: Option<String>,
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Listing {
|
impl Listing {
|
||||||
/// Creates a new listing
|
/// Creates a new listing
|
||||||
pub fn new(
|
pub fn new(
|
||||||
@@ -153,13 +150,7 @@ impl Listing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a bid to the listing
|
/// Adds a bid to the listing
|
||||||
pub fn add_bid(
|
pub fn add_bid(&mut self, bidder_id: String, bidder_name: String, amount: f64, currency: String) -> Result<(), String> {
|
||||||
&mut self,
|
|
||||||
bidder_id: String,
|
|
||||||
bidder_name: String,
|
|
||||||
amount: f64,
|
|
||||||
currency: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if self.status != ListingStatus::Active {
|
if self.status != ListingStatus::Active {
|
||||||
return Err("Listing is not active".to_string());
|
return Err("Listing is not active".to_string());
|
||||||
}
|
}
|
||||||
@@ -169,10 +160,7 @@ impl Listing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if currency != self.currency {
|
if currency != self.currency {
|
||||||
return Err(format!(
|
return Err(format!("Currency mismatch: expected {}, got {}", self.currency, currency));
|
||||||
"Currency mismatch: expected {}, got {}",
|
|
||||||
self.currency, currency
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if bid amount is higher than current highest bid or starting price
|
// Check if bid amount is higher than current highest bid or starting price
|
||||||
@@ -205,19 +193,13 @@ impl Listing {
|
|||||||
|
|
||||||
/// Gets the highest bid on the listing
|
/// Gets the highest bid on the listing
|
||||||
pub fn highest_bid(&self) -> Option<&Bid> {
|
pub fn highest_bid(&self) -> Option<&Bid> {
|
||||||
self.bids
|
self.bids.iter()
|
||||||
.iter()
|
|
||||||
.filter(|b| b.status == BidStatus::Active)
|
.filter(|b| b.status == BidStatus::Active)
|
||||||
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
|
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the listing as sold
|
/// Marks the listing as sold
|
||||||
pub fn mark_as_sold(
|
pub fn mark_as_sold(&mut self, buyer_id: String, buyer_name: String, sale_price: f64) -> Result<(), String> {
|
||||||
&mut self,
|
|
||||||
buyer_id: String,
|
|
||||||
buyer_name: String,
|
|
||||||
sale_price: f64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if self.status != ListingStatus::Active {
|
if self.status != ListingStatus::Active {
|
||||||
return Err("Listing is not active".to_string());
|
return Err("Listing is not active".to_string());
|
||||||
}
|
}
|
||||||
@@ -275,13 +257,11 @@ impl MarketplaceStatistics {
|
|||||||
let mut listings_by_type = std::collections::HashMap::new();
|
let mut listings_by_type = std::collections::HashMap::new();
|
||||||
let mut sales_by_asset_type = std::collections::HashMap::new();
|
let mut sales_by_asset_type = std::collections::HashMap::new();
|
||||||
|
|
||||||
let active_listings = listings
|
let active_listings = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.status == ListingStatus::Active)
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let sold_listings = listings
|
let sold_listings = listings.iter()
|
||||||
.iter()
|
|
||||||
.filter(|l| l.status == ListingStatus::Sold)
|
.filter(|l| l.status == ListingStatus::Sold)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
#![allow(dead_code)] // Mock user utility functions may not all be used yet
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Mock user object for development and testing
|
|
||||||
/// This will be replaced with real user authentication later
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MockUser {
|
|
||||||
pub id: u32,
|
|
||||||
pub name: String,
|
|
||||||
pub email: String,
|
|
||||||
pub role: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockUser {
|
|
||||||
/// Create a new mock user
|
|
||||||
pub fn new(id: u32, name: String, email: String, role: String) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
role,
|
|
||||||
created_at: chrono::Utc::now().timestamp(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// System-wide mock user constant
|
|
||||||
/// Use this throughout the application until real authentication is implemented
|
|
||||||
pub const MOCK_USER_ID: u32 = 1;
|
|
||||||
|
|
||||||
/// Get the default mock user object
|
|
||||||
/// This provides a consistent mock user across the entire system
|
|
||||||
pub fn get_mock_user() -> MockUser {
|
|
||||||
MockUser::new(
|
|
||||||
MOCK_USER_ID,
|
|
||||||
"Mock User".to_string(),
|
|
||||||
"mock@example.com".to_string(),
|
|
||||||
"admin".to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get mock user ID for database operations
|
|
||||||
/// Use this function instead of hardcoding user IDs
|
|
||||||
pub fn get_mock_user_id() -> u32 {
|
|
||||||
MOCK_USER_ID
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_mock_user_creation() {
|
|
||||||
let user = get_mock_user();
|
|
||||||
assert_eq!(user.id, MOCK_USER_ID);
|
|
||||||
assert_eq!(user.name, "Mock User");
|
|
||||||
assert_eq!(user.email, "mock@example.com");
|
|
||||||
assert_eq!(user.role, "admin");
|
|
||||||
assert!(user.created_at > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_mock_user_id_consistency() {
|
|
||||||
assert_eq!(get_mock_user_id(), MOCK_USER_ID);
|
|
||||||
assert_eq!(get_mock_user().id, MOCK_USER_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_mock_user_immutability() {
|
|
||||||
let user1 = get_mock_user();
|
|
||||||
let user2 = get_mock_user();
|
|
||||||
|
|
||||||
// Should have same ID and basic info
|
|
||||||
assert_eq!(user1.id, user2.id);
|
|
||||||
assert_eq!(user1.name, user2.name);
|
|
||||||
assert_eq!(user1.email, user2.email);
|
|
||||||
assert_eq!(user1.role, user2.role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
// Export models
|
// Export models
|
||||||
pub mod asset;
|
|
||||||
pub mod calendar;
|
|
||||||
pub mod contract;
|
|
||||||
pub mod defi;
|
|
||||||
pub mod document;
|
|
||||||
pub mod flow;
|
|
||||||
pub mod marketplace;
|
|
||||||
pub mod mock_user;
|
|
||||||
pub mod ticket;
|
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod ticket;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod governance;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod contract;
|
||||||
|
pub mod asset;
|
||||||
|
pub mod marketplace;
|
||||||
|
pub mod defi;
|
||||||
|
|
||||||
// Re-export models for easier imports
|
// Re-export models for easier imports
|
||||||
pub use calendar::CalendarViewMode;
|
|
||||||
pub use defi::initialize_mock_data;
|
|
||||||
// Mock user exports removed - import directly from mock_user module when needed
|
|
||||||
pub use ticket::{Ticket, TicketComment, TicketPriority, TicketStatus};
|
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
|
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority};
|
||||||
|
pub use calendar::{CalendarEvent, CalendarViewMode};
|
||||||
|
pub use marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
|
||||||
|
pub use defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB, initialize_mock_data};
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ pub struct Ticket {
|
|||||||
pub assigned_to: Option<i32>,
|
pub assigned_to: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Ticket {
|
impl Ticket {
|
||||||
/// Creates a new ticket
|
/// Creates a new ticket
|
||||||
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use bcrypt::{hash, verify, DEFAULT_COST};
|
|||||||
|
|
||||||
/// Represents a user in the system
|
/// Represents a user in the system
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct User {
|
pub struct User {
|
||||||
/// Unique identifier for the user
|
/// Unique identifier for the user
|
||||||
pub id: Option<i32>,
|
pub id: Option<i32>,
|
||||||
@@ -32,7 +31,6 @@ pub enum UserRole {
|
|||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl User {
|
impl User {
|
||||||
/// Creates a new user with default values
|
/// Creates a new user with default values
|
||||||
pub fn new(name: String, email: String) -> Self {
|
pub fn new(name: String, email: String) -> Self {
|
||||||
@@ -127,7 +125,6 @@ impl User {
|
|||||||
|
|
||||||
/// Represents user login credentials
|
/// Represents user login credentials
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct LoginCredentials {
|
pub struct LoginCredentials {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
@@ -135,7 +132,6 @@ pub struct LoginCredentials {
|
|||||||
|
|
||||||
/// Represents user registration data
|
/// Represents user registration data
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct RegistrationData {
|
pub struct RegistrationData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
use crate::SESSION_KEY;
|
|
||||||
use crate::controllers::asset::AssetController;
|
|
||||||
use crate::controllers::auth::AuthController;
|
|
||||||
use crate::controllers::calendar::CalendarController;
|
|
||||||
use crate::controllers::company::CompanyController;
|
|
||||||
use crate::controllers::contract::ContractController;
|
|
||||||
use crate::controllers::defi::DefiController;
|
|
||||||
use crate::controllers::document::DocumentController;
|
|
||||||
use crate::controllers::flow::FlowController;
|
|
||||||
use crate::controllers::governance::GovernanceController;
|
|
||||||
use crate::controllers::home::HomeController;
|
|
||||||
use crate::controllers::marketplace::MarketplaceController;
|
|
||||||
use crate::controllers::payment::PaymentController;
|
|
||||||
use crate::controllers::ticket::TicketController;
|
|
||||||
use crate::middleware::JwtAuth;
|
|
||||||
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
|
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
|
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
|
||||||
|
use crate::controllers::home::HomeController;
|
||||||
|
use crate::controllers::auth::AuthController;
|
||||||
|
use crate::controllers::ticket::TicketController;
|
||||||
|
use crate::controllers::calendar::CalendarController;
|
||||||
|
use crate::controllers::governance::GovernanceController;
|
||||||
|
use crate::controllers::flow::FlowController;
|
||||||
|
use crate::controllers::contract::ContractController;
|
||||||
|
use crate::controllers::asset::AssetController;
|
||||||
|
use crate::controllers::marketplace::MarketplaceController;
|
||||||
|
use crate::controllers::defi::DefiController;
|
||||||
|
use crate::controllers::company::CompanyController;
|
||||||
|
use crate::middleware::JwtAuth;
|
||||||
|
use crate::SESSION_KEY;
|
||||||
|
|
||||||
/// Configures all application routes
|
/// Configures all application routes
|
||||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||||
// Configure health check routes (no authentication required)
|
|
||||||
crate::controllers::health::configure_health_routes(cfg);
|
|
||||||
|
|
||||||
// Configure session middleware with the consistent key
|
// Configure session middleware with the consistent key
|
||||||
let session_middleware =
|
let session_middleware = SessionMiddleware::builder(
|
||||||
SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
|
CookieSessionStore::default(),
|
||||||
.cookie_secure(false) // Set to true in production with HTTPS
|
SESSION_KEY.clone()
|
||||||
.build();
|
)
|
||||||
|
.cookie_secure(false) // Set to true in production with HTTPS
|
||||||
|
.build();
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
// Public routes that don't require authentication
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -36,187 +33,67 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
.route("/about", web::get().to(HomeController::about))
|
.route("/about", web::get().to(HomeController::about))
|
||||||
.route("/contact", web::get().to(HomeController::contact))
|
.route("/contact", web::get().to(HomeController::contact))
|
||||||
.route("/contact", web::post().to(HomeController::submit_contact))
|
.route("/contact", web::post().to(HomeController::submit_contact))
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
.route("/login", web::get().to(AuthController::login_page))
|
.route("/login", web::get().to(AuthController::login_page))
|
||||||
.route("/login", web::post().to(AuthController::login))
|
.route("/login", web::post().to(AuthController::login))
|
||||||
.route("/register", web::get().to(AuthController::register_page))
|
.route("/register", web::get().to(AuthController::register_page))
|
||||||
.route("/register", web::post().to(AuthController::register))
|
.route("/register", web::post().to(AuthController::register))
|
||||||
.route("/logout", web::get().to(AuthController::logout))
|
.route("/logout", web::get().to(AuthController::logout))
|
||||||
|
|
||||||
// Protected routes that require authentication
|
// Protected routes that require authentication
|
||||||
// These routes will be protected by the JwtAuth middleware in the main.rs file
|
// These routes will be protected by the JwtAuth middleware in the main.rs file
|
||||||
.route("/editor", web::get().to(HomeController::editor))
|
.route("/editor", web::get().to(HomeController::editor))
|
||||||
|
|
||||||
// Ticket routes
|
// Ticket routes
|
||||||
.route("/tickets", web::get().to(TicketController::list_tickets))
|
.route("/tickets", web::get().to(TicketController::list_tickets))
|
||||||
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
||||||
.route("/tickets", web::post().to(TicketController::create_ticket))
|
.route("/tickets", web::post().to(TicketController::create_ticket))
|
||||||
.route(
|
.route("/tickets/{id}", web::get().to(TicketController::show_ticket))
|
||||||
"/tickets/{id}",
|
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment))
|
||||||
web::get().to(TicketController::show_ticket),
|
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status))
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/tickets/{id}/comment",
|
|
||||||
web::post().to(TicketController::add_comment),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/tickets/{id}/status/{status}",
|
|
||||||
web::post().to(TicketController::update_status),
|
|
||||||
)
|
|
||||||
.route("/my-tickets", web::get().to(TicketController::my_tickets))
|
.route("/my-tickets", web::get().to(TicketController::my_tickets))
|
||||||
|
|
||||||
// Calendar routes
|
// Calendar routes
|
||||||
.route("/calendar", web::get().to(CalendarController::calendar))
|
.route("/calendar", web::get().to(CalendarController::calendar))
|
||||||
.route(
|
.route("/calendar/events/new", web::get().to(CalendarController::new_event))
|
||||||
"/calendar/events/new",
|
.route("/calendar/events", web::post().to(CalendarController::create_event))
|
||||||
web::get().to(CalendarController::new_event),
|
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event))
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/calendar/events",
|
|
||||||
web::post().to(CalendarController::create_event),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/calendar/events/{id}/delete",
|
|
||||||
web::post().to(CalendarController::delete_event),
|
|
||||||
)
|
|
||||||
// Governance routes
|
// Governance routes
|
||||||
.route("/governance", web::get().to(GovernanceController::index))
|
.route("/governance", web::get().to(GovernanceController::index))
|
||||||
.route(
|
.route("/governance/proposals", web::get().to(GovernanceController::proposals))
|
||||||
"/governance/proposals",
|
.route("/governance/proposals/{id}", web::get().to(GovernanceController::proposal_detail))
|
||||||
web::get().to(GovernanceController::proposals),
|
.route("/governance/proposals/{id}/vote", web::post().to(GovernanceController::submit_vote))
|
||||||
)
|
.route("/governance/create", web::get().to(GovernanceController::create_proposal_form))
|
||||||
.route(
|
.route("/governance/create", web::post().to(GovernanceController::submit_proposal))
|
||||||
"/governance/proposals/{id}",
|
.route("/governance/my-votes", web::get().to(GovernanceController::my_votes))
|
||||||
web::get().to(GovernanceController::proposal_detail),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/governance/proposals/{id}/vote",
|
|
||||||
web::post().to(GovernanceController::submit_vote),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/governance/create",
|
|
||||||
web::get().to(GovernanceController::create_proposal_form),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/governance/create",
|
|
||||||
web::post().to(GovernanceController::submit_proposal),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/governance/my-votes",
|
|
||||||
web::get().to(GovernanceController::my_votes),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/governance/activities",
|
|
||||||
web::get().to(GovernanceController::all_activities),
|
|
||||||
)
|
|
||||||
// Flow routes
|
// Flow routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/flows")
|
web::scope("/flows")
|
||||||
.route("", web::get().to(FlowController::index))
|
.route("", web::get().to(FlowController::index))
|
||||||
.route("/list", web::get().to(FlowController::list_flows))
|
.route("/list", web::get().to(FlowController::list_flows))
|
||||||
.route("/{id}", web::get().to(FlowController::flow_detail))
|
.route("/{id}", web::get().to(FlowController::flow_detail))
|
||||||
.route(
|
.route("/{id}/advance", web::post().to(FlowController::advance_flow_step))
|
||||||
"/{id}/advance",
|
.route("/{id}/stuck", web::post().to(FlowController::mark_flow_step_stuck))
|
||||||
web::post().to(FlowController::advance_flow_step),
|
.route("/{id}/step/{step_id}/log", web::post().to(FlowController::add_log_to_flow_step))
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/stuck",
|
|
||||||
web::post().to(FlowController::mark_flow_step_stuck),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/step/{step_id}/log",
|
|
||||||
web::post().to(FlowController::add_log_to_flow_step),
|
|
||||||
)
|
|
||||||
.route("/create", web::get().to(FlowController::create_flow_form))
|
.route("/create", web::get().to(FlowController::create_flow_form))
|
||||||
.route("/create", web::post().to(FlowController::create_flow))
|
.route("/create", web::post().to(FlowController::create_flow))
|
||||||
.route("/my-flows", web::get().to(FlowController::my_flows)),
|
.route("/my-flows", web::get().to(FlowController::my_flows))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Contract routes
|
// Contract routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/contracts")
|
web::scope("/contracts")
|
||||||
.route("", web::get().to(ContractController::index))
|
.route("", web::get().to(ContractController::index))
|
||||||
.route("/", web::get().to(ContractController::index)) // Handle trailing slash
|
|
||||||
.route("/list", web::get().to(ContractController::list))
|
.route("/list", web::get().to(ContractController::list))
|
||||||
.route("/list/", web::get().to(ContractController::list)) // Handle trailing slash
|
.route("/my", web::get().to(ContractController::my_contracts))
|
||||||
.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::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}", web::get().to(ContractController::detail))
|
||||||
.route(
|
.route("/create", web::get().to(ContractController::create_form))
|
||||||
"/{id}/status/{status}",
|
.route("/create", web::post().to(ContractController::create))
|
||||||
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
|
// Asset routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/assets")
|
web::scope("/assets")
|
||||||
@@ -227,113 +104,49 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
.route("/create", web::post().to(AssetController::create))
|
.route("/create", web::post().to(AssetController::create))
|
||||||
.route("/test", web::get().to(AssetController::test))
|
.route("/test", web::get().to(AssetController::test))
|
||||||
.route("/{id}", web::get().to(AssetController::detail))
|
.route("/{id}", web::get().to(AssetController::detail))
|
||||||
.route(
|
.route("/{id}/valuation", web::post().to(AssetController::add_valuation))
|
||||||
"/{id}/valuation",
|
.route("/{id}/transaction", web::post().to(AssetController::add_transaction))
|
||||||
web::post().to(AssetController::add_valuation),
|
.route("/{id}/status/{status}", web::post().to(AssetController::update_status))
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/transaction",
|
|
||||||
web::post().to(AssetController::add_transaction),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/status/{status}",
|
|
||||||
web::post().to(AssetController::update_status),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Marketplace routes
|
// Marketplace routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/marketplace")
|
web::scope("/marketplace")
|
||||||
.route("", web::get().to(MarketplaceController::index))
|
.route("", web::get().to(MarketplaceController::index))
|
||||||
.route(
|
.route("/listings", web::get().to(MarketplaceController::list_listings))
|
||||||
"/listings",
|
|
||||||
web::get().to(MarketplaceController::list_listings),
|
|
||||||
)
|
|
||||||
.route("/my", web::get().to(MarketplaceController::my_listings))
|
.route("/my", web::get().to(MarketplaceController::my_listings))
|
||||||
.route(
|
.route("/create", web::get().to(MarketplaceController::create_listing_form))
|
||||||
"/create",
|
.route("/create", web::post().to(MarketplaceController::create_listing))
|
||||||
web::get().to(MarketplaceController::create_listing_form),
|
.route("/{id}", web::get().to(MarketplaceController::listing_detail))
|
||||||
)
|
.route("/{id}/bid", web::post().to(MarketplaceController::submit_bid))
|
||||||
.route(
|
.route("/{id}/purchase", web::post().to(MarketplaceController::purchase_listing))
|
||||||
"/create",
|
.route("/{id}/cancel", web::post().to(MarketplaceController::cancel_listing))
|
||||||
web::post().to(MarketplaceController::create_listing),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
web::get().to(MarketplaceController::listing_detail),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/bid",
|
|
||||||
web::post().to(MarketplaceController::submit_bid),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/purchase",
|
|
||||||
web::post().to(MarketplaceController::purchase_listing),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/cancel",
|
|
||||||
web::post().to(MarketplaceController::cancel_listing),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeFi routes
|
// DeFi routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/defi")
|
web::scope("/defi")
|
||||||
.route("", web::get().to(DefiController::index))
|
.route("", web::get().to(DefiController::index))
|
||||||
.route(
|
.route("/providing", web::post().to(DefiController::create_providing))
|
||||||
"/providing",
|
.route("/receiving", web::post().to(DefiController::create_receiving))
|
||||||
web::post().to(DefiController::create_providing),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/receiving",
|
|
||||||
web::post().to(DefiController::create_receiving),
|
|
||||||
)
|
|
||||||
.route("/liquidity", web::post().to(DefiController::add_liquidity))
|
.route("/liquidity", web::post().to(DefiController::add_liquidity))
|
||||||
.route("/staking", web::post().to(DefiController::create_staking))
|
.route("/staking", web::post().to(DefiController::create_staking))
|
||||||
.route("/swap", web::post().to(DefiController::swap_tokens))
|
.route("/swap", web::post().to(DefiController::swap_tokens))
|
||||||
.route(
|
.route("/collateral", web::post().to(DefiController::create_collateral))
|
||||||
"/collateral",
|
|
||||||
web::post().to(DefiController::create_collateral),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// Company routes
|
// Company routes
|
||||||
.service(
|
.service(
|
||||||
web::scope("/company")
|
web::scope("/company")
|
||||||
.route("", web::get().to(CompanyController::index))
|
.route("", web::get().to(CompanyController::index))
|
||||||
// OLD REGISTRATION ROUTE REMOVED - Now only payment flow creates companies
|
.route("/register", web::post().to(CompanyController::register))
|
||||||
.route("/view/{id}", web::get().to(CompanyController::view_company))
|
.route("/view/{id}", web::get().to(CompanyController::view_company))
|
||||||
.route("/edit/{id}", web::get().to(CompanyController::edit_form))
|
.route("/switch/{id}", web::get().to(CompanyController::switch_entity))
|
||||||
.route("/edit/{id}", web::post().to(CompanyController::edit))
|
)
|
||||||
.route(
|
|
||||||
"/switch/{id}",
|
|
||||||
web::get().to(CompanyController::switch_entity),
|
|
||||||
)
|
|
||||||
// Payment routes - ONLY way to create companies now
|
|
||||||
.route(
|
|
||||||
"/create-payment-intent",
|
|
||||||
web::post().to(PaymentController::create_payment_intent),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/payment-success",
|
|
||||||
web::get().to(PaymentController::payment_success),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/payment-webhook",
|
|
||||||
web::post().to(PaymentController::webhook),
|
|
||||||
)
|
|
||||||
// Document management routes
|
|
||||||
.route("/documents/{id}", web::get().to(DocumentController::index))
|
|
||||||
.route(
|
|
||||||
"/documents/{id}/upload",
|
|
||||||
web::post().to(DocumentController::upload),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/documents/{company_id}/delete/{document_id}",
|
|
||||||
web::get().to(DocumentController::delete),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the /protected scope for any future routes that should be under that path
|
// Keep the /protected scope for any future routes that should be under that path
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
|
web::scope("/protected")
|
||||||
|
.wrap(JwtAuth) // Apply JWT authentication middleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
use actix_web::{Error, HttpResponse};
|
use actix_web::{error, Error, HttpResponse};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use pulldown_cmark::{Options, Parser, html};
|
|
||||||
use std::error::Error as StdError;
|
|
||||||
use tera::{self, Context, Function, Tera, Value};
|
use tera::{self, Context, Function, Tera, Value};
|
||||||
|
use std::error::Error as StdError;
|
||||||
|
|
||||||
// Export modules
|
// Export modules
|
||||||
pub mod redis_service;
|
pub mod redis_service;
|
||||||
pub mod secure_logging;
|
|
||||||
pub mod stripe_security;
|
|
||||||
|
|
||||||
// Re-export for easier imports
|
// Re-export for easier imports
|
||||||
// pub use redis_service::RedisCalendarService; // Currently unused
|
pub use redis_service::RedisCalendarService;
|
||||||
|
|
||||||
/// Error type for template rendering
|
/// Error type for template rendering
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct TemplateError {
|
pub struct TemplateError {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub details: String,
|
pub details: String,
|
||||||
@@ -29,16 +25,10 @@ impl std::fmt::Display for TemplateError {
|
|||||||
|
|
||||||
impl std::error::Error for TemplateError {}
|
impl std::error::Error for TemplateError {}
|
||||||
|
|
||||||
/// Registers custom Tera functions and filters
|
/// Registers custom Tera functions
|
||||||
pub fn register_tera_functions(tera: &mut tera::Tera) {
|
pub fn register_tera_functions(tera: &mut tera::Tera) {
|
||||||
tera.register_function("now", NowFunction);
|
tera.register_function("now", NowFunction);
|
||||||
tera.register_function("format_date", FormatDateFunction);
|
tera.register_function("format_date", FormatDateFunction);
|
||||||
tera.register_function("local_time", LocalTimeFunction);
|
|
||||||
|
|
||||||
// Register custom filters
|
|
||||||
tera.register_filter("format_hour", format_hour_filter);
|
|
||||||
tera.register_filter("extract_hour", extract_hour_filter);
|
|
||||||
tera.register_filter("format_time", format_time_filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tera function to get the current date/time
|
/// Tera function to get the current date/time
|
||||||
@@ -56,7 +46,7 @@ impl Function for NowFunction {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
// Special case for just getting the year
|
// Special case for just getting the year
|
||||||
if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) {
|
if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
return Ok(Value::String(now.format("%Y").to_string()));
|
return Ok(Value::String(now.format("%Y").to_string()));
|
||||||
@@ -78,10 +68,14 @@ impl Function for FormatDateFunction {
|
|||||||
None => {
|
None => {
|
||||||
return Err(tera::Error::msg(
|
return Err(tera::Error::msg(
|
||||||
"The 'timestamp' argument must be a valid timestamp",
|
"The 'timestamp' argument must be a valid timestamp",
|
||||||
));
|
))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => return Err(tera::Error::msg("The 'timestamp' argument is required")),
|
None => {
|
||||||
|
return Err(tera::Error::msg(
|
||||||
|
"The 'timestamp' argument is required",
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let format = match args.get("format") {
|
let format = match args.get("format") {
|
||||||
@@ -95,130 +89,23 @@ impl Function for FormatDateFunction {
|
|||||||
// Convert timestamp to DateTime using the non-deprecated method
|
// Convert timestamp to DateTime using the non-deprecated method
|
||||||
let datetime = match DateTime::from_timestamp(timestamp, 0) {
|
let datetime = match DateTime::from_timestamp(timestamp, 0) {
|
||||||
Some(dt) => dt,
|
Some(dt) => dt,
|
||||||
None => return Err(tera::Error::msg("Failed to convert timestamp to datetime")),
|
None => {
|
||||||
|
return Err(tera::Error::msg(
|
||||||
|
"Failed to convert timestamp to datetime",
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Value::String(datetime.format(format).to_string()))
|
Ok(Value::String(datetime.format(format).to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tera function to convert UTC datetime to local time
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct LocalTimeFunction;
|
|
||||||
|
|
||||||
impl Function for LocalTimeFunction {
|
|
||||||
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
|
|
||||||
let datetime_value = match args.get("datetime") {
|
|
||||||
Some(val) => val,
|
|
||||||
None => return Err(tera::Error::msg("The 'datetime' argument is required")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let format = match args.get("format") {
|
|
||||||
Some(val) => match val.as_str() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => "%Y-%m-%d %H:%M",
|
|
||||||
},
|
|
||||||
None => "%Y-%m-%d %H:%M",
|
|
||||||
};
|
|
||||||
|
|
||||||
// The datetime comes from Rust as a serialized DateTime<Utc>
|
|
||||||
// We need to handle it properly
|
|
||||||
let utc_datetime = if let Some(dt_str) = datetime_value.as_str() {
|
|
||||||
// Try to parse as RFC3339 first
|
|
||||||
match DateTime::parse_from_rfc3339(dt_str) {
|
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
|
||||||
Err(_) => {
|
|
||||||
// Try to parse as our standard format
|
|
||||||
match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
|
||||||
Err(_) => return Err(tera::Error::msg("Invalid datetime string format")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(tera::Error::msg("Datetime must be a string"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert UTC to local time (EEST = UTC+3)
|
|
||||||
// In a real application, you'd want to get the user's timezone from their profile
|
|
||||||
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
|
|
||||||
let local_datetime = utc_datetime.with_timezone(&local_offset);
|
|
||||||
|
|
||||||
Ok(Value::String(local_datetime.format(format).to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tera filter to format hour with zero padding
|
|
||||||
pub fn format_hour_filter(
|
|
||||||
value: &Value,
|
|
||||||
_args: &std::collections::HashMap<String, Value>,
|
|
||||||
) -> tera::Result<Value> {
|
|
||||||
match value.as_i64() {
|
|
||||||
Some(hour) => Ok(Value::String(format!("{:02}", hour))),
|
|
||||||
None => Err(tera::Error::msg("Value must be a number")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tera filter to extract hour from datetime string
|
|
||||||
pub fn extract_hour_filter(
|
|
||||||
value: &Value,
|
|
||||||
_args: &std::collections::HashMap<String, Value>,
|
|
||||||
) -> tera::Result<Value> {
|
|
||||||
match value.as_str() {
|
|
||||||
Some(datetime_str) => {
|
|
||||||
// Try to parse as RFC3339 first
|
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
|
|
||||||
Ok(Value::String(dt.format("%H").to_string()))
|
|
||||||
} else {
|
|
||||||
// Try to parse as our standard format
|
|
||||||
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
|
||||||
Ok(dt) => Ok(Value::String(dt.format("%H").to_string())),
|
|
||||||
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => Err(tera::Error::msg("Value must be a string")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tera filter to format time from datetime string
|
|
||||||
pub fn format_time_filter(
|
|
||||||
value: &Value,
|
|
||||||
args: &std::collections::HashMap<String, Value>,
|
|
||||||
) -> tera::Result<Value> {
|
|
||||||
let format = match args.get("format") {
|
|
||||||
Some(val) => match val.as_str() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => "%H:%M",
|
|
||||||
},
|
|
||||||
None => "%H:%M",
|
|
||||||
};
|
|
||||||
|
|
||||||
match value.as_str() {
|
|
||||||
Some(datetime_str) => {
|
|
||||||
// Try to parse as RFC3339 first
|
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
|
|
||||||
Ok(Value::String(dt.format(format).to_string()))
|
|
||||||
} else {
|
|
||||||
// Try to parse as our standard format
|
|
||||||
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
|
||||||
Ok(dt) => Ok(Value::String(dt.format(format).to_string())),
|
|
||||||
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => Err(tera::Error::msg("Value must be a string")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats a date for display
|
/// Formats a date for display
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
|
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
|
||||||
date.format(format).to_string()
|
date.format(format).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncates a string to a maximum length and adds an ellipsis if truncated
|
/// Truncates a string to a maximum length and adds an ellipsis if truncated
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn truncate_string(s: &str, max_length: usize) -> String {
|
pub fn truncate_string(s: &str, max_length: usize) -> String {
|
||||||
if s.len() <= max_length {
|
if s.len() <= max_length {
|
||||||
s.to_string()
|
s.to_string()
|
||||||
@@ -227,26 +114,6 @@ 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
|
/// Renders a template with error handling
|
||||||
///
|
///
|
||||||
/// This function attempts to render a template and handles any errors by rendering
|
/// This function attempts to render a template and handles any errors by rendering
|
||||||
@@ -257,41 +124,38 @@ pub fn render_template(
|
|||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
println!("DEBUG: Attempting to render template: {}", template_name);
|
println!("DEBUG: Attempting to render template: {}", template_name);
|
||||||
|
|
||||||
// Print all context keys for debugging
|
// Print all context keys for debugging
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() {
|
for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() {
|
||||||
keys.push(key.clone());
|
keys.push(key.clone());
|
||||||
}
|
}
|
||||||
println!("DEBUG: Context keys: {:?}", keys);
|
println!("DEBUG: Context keys: {:?}", keys);
|
||||||
|
|
||||||
match tmpl.render(template_name, ctx) {
|
match tmpl.render(template_name, ctx) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
println!("DEBUG: Successfully rendered template: {}", template_name);
|
println!("DEBUG: Successfully rendered template: {}", template_name);
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(content))
|
Ok(HttpResponse::Ok().content_type("text/html").body(content))
|
||||||
}
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Log the error with more details
|
// Log the error with more details
|
||||||
println!(
|
println!("DEBUG: Template rendering error for {}: {}", template_name, e);
|
||||||
"DEBUG: Template rendering error for {}: {}",
|
|
||||||
template_name, e
|
|
||||||
);
|
|
||||||
println!("DEBUG: Error details: {:?}", e);
|
println!("DEBUG: Error details: {:?}", e);
|
||||||
|
|
||||||
// Print the error cause chain for better debugging
|
// Print the error cause chain for better debugging
|
||||||
let mut current_error: Option<&dyn StdError> = Some(&e);
|
let mut current_error: Option<&dyn StdError> = Some(&e);
|
||||||
let mut error_chain = Vec::new();
|
let mut error_chain = Vec::new();
|
||||||
|
|
||||||
while let Some(error) = current_error {
|
while let Some(error) = current_error {
|
||||||
error_chain.push(format!("{}", error));
|
error_chain.push(format!("{}", error));
|
||||||
current_error = error.source();
|
current_error = error.source();
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("DEBUG: Error chain: {:?}", error_chain);
|
println!("DEBUG: Error chain: {:?}", error_chain);
|
||||||
|
|
||||||
// Log the error
|
// Log the error
|
||||||
log::error!("Template rendering error: {}", e);
|
log::error!("Template rendering error: {}", e);
|
||||||
|
|
||||||
// Create a simple error response with more detailed information
|
// Create a simple error response with more detailed information
|
||||||
let error_html = format!(
|
let error_html = format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
@@ -323,9 +187,9 @@ pub fn render_template(
|
|||||||
e,
|
e,
|
||||||
error_chain.join("\n")
|
error_chain.join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("DEBUG: Returning simple error page");
|
println!("DEBUG: Returning simple error page");
|
||||||
|
|
||||||
Ok(HttpResponse::InternalServerError()
|
Ok(HttpResponse::InternalServerError()
|
||||||
.content_type("text/html")
|
.content_type("text/html")
|
||||||
.body(error_html))
|
.body(error_html))
|
||||||
@@ -343,4 +207,4 @@ mod tests {
|
|||||||
assert_eq!(truncate_string("Hello, world!", 5), "Hello...");
|
assert_eq!(truncate_string("Hello, world!", 5), "Hello...");
|
||||||
assert_eq!(truncate_string("", 5), "");
|
assert_eq!(truncate_string("", 5), "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
#![allow(dead_code)] // Redis utility functions may not all be used yet
|
|
||||||
|
|
||||||
use heromodels::models::Event as CalendarEvent;
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use redis::{Client, Commands, Connection, RedisError};
|
use redis::{Client, Commands, Connection, RedisError};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use crate::models::CalendarEvent;
|
||||||
|
|
||||||
// Create a lazy static Redis client that can be used throughout the application
|
// Create a lazy static Redis client that can be used throughout the application
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@@ -13,21 +11,21 @@ lazy_static! {
|
|||||||
/// Initialize the Redis client
|
/// Initialize the Redis client
|
||||||
pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> {
|
pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> {
|
||||||
let client = redis::Client::open(redis_url)?;
|
let client = redis::Client::open(redis_url)?;
|
||||||
|
|
||||||
// Test the connection
|
// Test the connection
|
||||||
let _: Connection = client.get_connection()?;
|
let _: Connection = client.get_connection()?;
|
||||||
|
|
||||||
// Store the client in the lazy static
|
// Store the client in the lazy static
|
||||||
let mut client_guard = REDIS_CLIENT.lock().unwrap();
|
let mut client_guard = REDIS_CLIENT.lock().unwrap();
|
||||||
*client_guard = Some(client);
|
*client_guard = Some(client);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a Redis connection
|
/// Get a Redis connection
|
||||||
pub fn get_connection() -> Result<Connection, RedisError> {
|
pub fn get_connection() -> Result<Connection, RedisError> {
|
||||||
let client_guard = REDIS_CLIENT.lock().unwrap();
|
let client_guard = REDIS_CLIENT.lock().unwrap();
|
||||||
|
|
||||||
if let Some(client) = &*client_guard {
|
if let Some(client) = &*client_guard {
|
||||||
client.get_connection()
|
client.get_connection()
|
||||||
} else {
|
} else {
|
||||||
@@ -44,14 +42,14 @@ pub struct RedisCalendarService;
|
|||||||
impl RedisCalendarService {
|
impl RedisCalendarService {
|
||||||
/// Key prefix for calendar events
|
/// Key prefix for calendar events
|
||||||
const EVENT_KEY_PREFIX: &'static str = "calendar:event:";
|
const EVENT_KEY_PREFIX: &'static str = "calendar:event:";
|
||||||
|
|
||||||
/// Key for the set of all event IDs
|
/// Key for the set of all event IDs
|
||||||
const ALL_EVENTS_KEY: &'static str = "calendar:all_events";
|
const ALL_EVENTS_KEY: &'static str = "calendar:all_events";
|
||||||
|
|
||||||
/// Save a calendar event to Redis
|
/// Save a calendar event to Redis
|
||||||
pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> {
|
pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> {
|
||||||
let mut conn = get_connection()?;
|
let mut conn = get_connection()?;
|
||||||
|
|
||||||
// Convert the event to JSON
|
// Convert the event to JSON
|
||||||
let json = event.to_json().map_err(|e| {
|
let json = event.to_json().map_err(|e| {
|
||||||
RedisError::from(std::io::Error::new(
|
RedisError::from(std::io::Error::new(
|
||||||
@@ -59,25 +57,25 @@ impl RedisCalendarService {
|
|||||||
format!("Failed to serialize event: {}", e),
|
format!("Failed to serialize event: {}", e),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Save the event
|
// Save the event
|
||||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.base_data.id);
|
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.id);
|
||||||
let _: () = conn.set(event_key, json)?;
|
let _: () = conn.set(event_key, json)?;
|
||||||
|
|
||||||
// Add the event ID to the set of all events
|
// Add the event ID to the set of all events
|
||||||
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.base_data.id)?;
|
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.id)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a calendar event from Redis by ID
|
/// Get a calendar event from Redis by ID
|
||||||
pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> {
|
pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> {
|
||||||
let mut conn = get_connection()?;
|
let mut conn = get_connection()?;
|
||||||
|
|
||||||
// Get the event JSON
|
// Get the event JSON
|
||||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
|
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
|
||||||
let json: Option<String> = conn.get(event_key)?;
|
let json: Option<String> = conn.get(event_key)?;
|
||||||
|
|
||||||
// Parse the JSON
|
// Parse the JSON
|
||||||
if let Some(json) = json {
|
if let Some(json) = json {
|
||||||
let event = CalendarEvent::from_json(&json).map_err(|e| {
|
let event = CalendarEvent::from_json(&json).map_err(|e| {
|
||||||
@@ -86,34 +84,34 @@ impl RedisCalendarService {
|
|||||||
format!("Failed to deserialize event: {}", e),
|
format!("Failed to deserialize event: {}", e),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Some(event))
|
Ok(Some(event))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a calendar event from Redis
|
/// Delete a calendar event from Redis
|
||||||
pub fn delete_event(id: &str) -> Result<bool, RedisError> {
|
pub fn delete_event(id: &str) -> Result<bool, RedisError> {
|
||||||
let mut conn = get_connection()?;
|
let mut conn = get_connection()?;
|
||||||
|
|
||||||
// Delete the event
|
// Delete the event
|
||||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
|
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
|
||||||
let deleted: i32 = conn.del(event_key)?;
|
let deleted: i32 = conn.del(event_key)?;
|
||||||
|
|
||||||
// Remove the event ID from the set of all events
|
// Remove the event ID from the set of all events
|
||||||
let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?;
|
let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?;
|
||||||
|
|
||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all calendar events from Redis
|
/// Get all calendar events from Redis
|
||||||
pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> {
|
pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> {
|
||||||
let mut conn = get_connection()?;
|
let mut conn = get_connection()?;
|
||||||
|
|
||||||
// Get all event IDs
|
// Get all event IDs
|
||||||
let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?;
|
let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?;
|
||||||
|
|
||||||
// Get all events
|
// Get all events
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for id in event_ids {
|
for id in event_ids {
|
||||||
@@ -121,23 +119,23 @@ impl RedisCalendarService {
|
|||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get events for a specific date range
|
/// Get events for a specific date range
|
||||||
pub fn get_events_in_range(
|
pub fn get_events_in_range(
|
||||||
start: chrono::DateTime<chrono::Utc>,
|
start: chrono::DateTime<chrono::Utc>,
|
||||||
end: chrono::DateTime<chrono::Utc>,
|
end: chrono::DateTime<chrono::Utc>,
|
||||||
) -> Result<Vec<CalendarEvent>, RedisError> {
|
) -> Result<Vec<CalendarEvent>, RedisError> {
|
||||||
let all_events = Self::get_all_events()?;
|
let all_events = Self::get_all_events()?;
|
||||||
|
|
||||||
// Filter events that fall within the date range
|
// Filter events that fall within the date range
|
||||||
let filtered_events = all_events
|
let filtered_events = all_events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|event| event.start_time <= end && event.end_time >= start)
|
.filter(|event| event.start_time <= end && event.end_time >= start)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(filtered_events)
|
Ok(filtered_events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
use serde_json::json;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// Secure logging utilities that prevent sensitive data exposure
|
|
||||||
pub struct SecureLogger;
|
|
||||||
|
|
||||||
impl SecureLogger {
|
|
||||||
/// Log payment events without exposing sensitive data
|
|
||||||
pub fn log_payment_event(event: &str, payment_id: &str, success: bool, details: Option<&str>) {
|
|
||||||
if success {
|
|
||||||
log::info!(
|
|
||||||
"Payment event: {} for payment ID: {} - SUCCESS{}",
|
|
||||||
event,
|
|
||||||
Self::sanitize_payment_id(payment_id),
|
|
||||||
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"Payment event: {} for payment ID: {} - FAILED{}",
|
|
||||||
event,
|
|
||||||
Self::sanitize_payment_id(payment_id),
|
|
||||||
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log security events with IP tracking
|
|
||||||
pub fn log_security_event(event: &str, ip: &str, success: bool, details: Option<&str>) {
|
|
||||||
let status = if success { "ALLOWED" } else { "BLOCKED" };
|
|
||||||
log::warn!(
|
|
||||||
"Security event: {} from IP: {} - {}{}",
|
|
||||||
event,
|
|
||||||
Self::sanitize_ip(ip),
|
|
||||||
status,
|
|
||||||
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log webhook events securely
|
|
||||||
pub fn log_webhook_event(event_type: &str, success: bool, payment_intent_id: Option<&str>) {
|
|
||||||
let payment_info = payment_intent_id
|
|
||||||
.map(|id| format!(" for payment {}", Self::sanitize_payment_id(id)))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if success {
|
|
||||||
log::info!("Webhook event: {} - SUCCESS{}", event_type, payment_info);
|
|
||||||
} else {
|
|
||||||
log::error!("Webhook event: {} - FAILED{}", event_type, payment_info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log company registration events
|
|
||||||
pub fn log_company_event(event: &str, company_id: u32, company_name: &str, success: bool) {
|
|
||||||
let sanitized_name = Self::sanitize_company_name(company_name);
|
|
||||||
if success {
|
|
||||||
log::info!(
|
|
||||||
"Company event: {} for company ID: {} ({}) - SUCCESS",
|
|
||||||
event, company_id, sanitized_name
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"Company event: {} for company ID: {} ({}) - FAILED",
|
|
||||||
event, company_id, sanitized_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log validation errors without exposing user data
|
|
||||||
pub fn log_validation_error(field: &str, error_code: &str, ip: Option<&str>) {
|
|
||||||
let ip_info = ip
|
|
||||||
.map(|ip| format!(" from IP: {}", Self::sanitize_ip(ip)))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
log::warn!(
|
|
||||||
"Validation error: field '{}' failed with code '{}'{}",
|
|
||||||
field, error_code, ip_info
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log performance metrics
|
|
||||||
pub fn log_performance_metric(operation: &str, duration_ms: u64, success: bool) {
|
|
||||||
if success {
|
|
||||||
log::info!("Performance: {} completed in {}ms", operation, duration_ms);
|
|
||||||
} else {
|
|
||||||
log::warn!("Performance: {} failed after {}ms", operation, duration_ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log database operations
|
|
||||||
pub fn log_database_operation(operation: &str, table: &str, success: bool, duration_ms: Option<u64>) {
|
|
||||||
let duration_info = duration_ms
|
|
||||||
.map(|ms| format!(" in {}ms", ms))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if success {
|
|
||||||
log::debug!("Database: {} on {} - SUCCESS{}", operation, table, duration_info);
|
|
||||||
} else {
|
|
||||||
log::error!("Database: {} on {} - FAILED{}", operation, table, duration_info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create structured log entry for monitoring systems
|
|
||||||
pub fn create_structured_log(
|
|
||||||
level: &str,
|
|
||||||
event: &str,
|
|
||||||
details: HashMap<String, serde_json::Value>,
|
|
||||||
) -> String {
|
|
||||||
let mut log_entry = json!({
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
||||||
"level": level,
|
|
||||||
"event": event,
|
|
||||||
"service": "freezone-registration"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add sanitized details
|
|
||||||
for (key, value) in details {
|
|
||||||
let sanitized_key = Self::sanitize_log_key(&key);
|
|
||||||
let sanitized_value = Self::sanitize_log_value(&value);
|
|
||||||
log_entry[sanitized_key] = sanitized_value;
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string(&log_entry).unwrap_or_else(|_| {
|
|
||||||
format!("{{\"error\": \"Failed to serialize log entry for event: {}\"}}", event)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize payment ID for logging (show only last 4 characters)
|
|
||||||
fn sanitize_payment_id(payment_id: &str) -> String {
|
|
||||||
if payment_id.len() > 4 {
|
|
||||||
format!("****{}", &payment_id[payment_id.len() - 4..])
|
|
||||||
} else {
|
|
||||||
"****".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize IP address for logging (mask last octet)
|
|
||||||
fn sanitize_ip(ip: &str) -> String {
|
|
||||||
if let Some(last_dot) = ip.rfind('.') {
|
|
||||||
format!("{}.***", &ip[..last_dot])
|
|
||||||
} else {
|
|
||||||
"***".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize company name for logging (truncate and remove special chars)
|
|
||||||
fn sanitize_company_name(name: &str) -> String {
|
|
||||||
let sanitized = name
|
|
||||||
.chars()
|
|
||||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '.')
|
|
||||||
.take(50)
|
|
||||||
.collect::<String>();
|
|
||||||
|
|
||||||
if sanitized.is_empty() {
|
|
||||||
"***".to_string()
|
|
||||||
} else {
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize log keys to prevent injection
|
|
||||||
fn sanitize_log_key(key: &str) -> String {
|
|
||||||
key.chars()
|
|
||||||
.filter(|c| c.is_alphanumeric() || *c == '_')
|
|
||||||
.take(50)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize log values to prevent sensitive data exposure
|
|
||||||
fn sanitize_log_value(value: &serde_json::Value) -> serde_json::Value {
|
|
||||||
match value {
|
|
||||||
serde_json::Value::String(s) => {
|
|
||||||
// Check if this looks like sensitive data
|
|
||||||
if Self::is_sensitive_data(s) {
|
|
||||||
json!("***REDACTED***")
|
|
||||||
} else {
|
|
||||||
json!(s.chars().take(200).collect::<String>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::Value::Number(n) => json!(n),
|
|
||||||
serde_json::Value::Bool(b) => json!(b),
|
|
||||||
serde_json::Value::Array(arr) => {
|
|
||||||
json!(arr.iter().take(10).map(|v| Self::sanitize_log_value(v)).collect::<Vec<_>>())
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(obj) => {
|
|
||||||
let sanitized: serde_json::Map<String, serde_json::Value> = obj
|
|
||||||
.iter()
|
|
||||||
.take(20)
|
|
||||||
.map(|(k, v)| (Self::sanitize_log_key(k), Self::sanitize_log_value(v)))
|
|
||||||
.collect();
|
|
||||||
json!(sanitized)
|
|
||||||
}
|
|
||||||
serde_json::Value::Null => json!(null),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a string contains sensitive data patterns
|
|
||||||
fn is_sensitive_data(s: &str) -> bool {
|
|
||||||
let sensitive_patterns = [
|
|
||||||
"password", "secret", "key", "token", "card", "cvv", "cvc",
|
|
||||||
"ssn", "social", "credit", "bank", "account", "pin"
|
|
||||||
];
|
|
||||||
|
|
||||||
let lower_s = s.to_lowercase();
|
|
||||||
sensitive_patterns.iter().any(|pattern| lower_s.contains(pattern)) ||
|
|
||||||
s.len() > 100 || // Long strings might contain sensitive data
|
|
||||||
s.chars().all(|c| c.is_ascii_digit()) && s.len() > 8 // Might be a card number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Audit trail logging for compliance
|
|
||||||
pub struct AuditLogger;
|
|
||||||
|
|
||||||
impl AuditLogger {
|
|
||||||
/// Log user actions for audit trail
|
|
||||||
pub fn log_user_action(
|
|
||||||
user_id: u32,
|
|
||||||
action: &str,
|
|
||||||
resource: &str,
|
|
||||||
success: bool,
|
|
||||||
ip: Option<&str>,
|
|
||||||
) {
|
|
||||||
let ip_info = ip
|
|
||||||
.map(|ip| format!(" from {}", SecureLogger::sanitize_ip(ip)))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let status = if success { "SUCCESS" } else { "FAILED" };
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"AUDIT: User {} performed '{}' on '{}' - {}{}",
|
|
||||||
user_id, action, resource, status, ip_info
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log administrative actions
|
|
||||||
pub fn log_admin_action(
|
|
||||||
admin_id: u32,
|
|
||||||
action: &str,
|
|
||||||
target: &str,
|
|
||||||
success: bool,
|
|
||||||
details: Option<&str>,
|
|
||||||
) {
|
|
||||||
let details_info = details
|
|
||||||
.map(|d| format!(" ({})", d))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let status = if success { "SUCCESS" } else { "FAILED" };
|
|
||||||
|
|
||||||
log::warn!(
|
|
||||||
"ADMIN_AUDIT: Admin {} performed '{}' on '{}' - {}{}",
|
|
||||||
admin_id, action, target, status, details_info
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log data access for compliance
|
|
||||||
pub fn log_data_access(
|
|
||||||
user_id: u32,
|
|
||||||
data_type: &str,
|
|
||||||
operation: &str,
|
|
||||||
record_count: Option<usize>,
|
|
||||||
) {
|
|
||||||
let count_info = record_count
|
|
||||||
.map(|c| format!(" ({} records)", c))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"DATA_ACCESS: User {} performed '{}' on '{}'{}",
|
|
||||||
user_id, operation, data_type, count_info
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_payment_id() {
|
|
||||||
assert_eq!(SecureLogger::sanitize_payment_id("pi_1234567890"), "****7890");
|
|
||||||
assert_eq!(SecureLogger::sanitize_payment_id("123"), "****");
|
|
||||||
assert_eq!(SecureLogger::sanitize_payment_id(""), "****");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_ip() {
|
|
||||||
assert_eq!(SecureLogger::sanitize_ip("192.168.1.100"), "192.168.1.***");
|
|
||||||
assert_eq!(SecureLogger::sanitize_ip("invalid"), "***");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_company_name() {
|
|
||||||
assert_eq!(SecureLogger::sanitize_company_name("Test Company Ltd."), "Test Company Ltd.");
|
|
||||||
assert_eq!(SecureLogger::sanitize_company_name("Test<script>alert(1)</script>"), "Testscriptalert1script");
|
|
||||||
assert_eq!(SecureLogger::sanitize_company_name(""), "***");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_sensitive_data() {
|
|
||||||
assert!(SecureLogger::is_sensitive_data("password123"));
|
|
||||||
assert!(SecureLogger::is_sensitive_data("secret_key"));
|
|
||||||
assert!(SecureLogger::is_sensitive_data("4111111111111111")); // Card number pattern
|
|
||||||
assert!(!SecureLogger::is_sensitive_data("normal text"));
|
|
||||||
assert!(!SecureLogger::is_sensitive_data("123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_structured_log_creation() {
|
|
||||||
let mut details = HashMap::new();
|
|
||||||
details.insert("user_id".to_string(), json!(123));
|
|
||||||
details.insert("action".to_string(), json!("payment_created"));
|
|
||||||
|
|
||||||
let log_entry = SecureLogger::create_structured_log("INFO", "payment_event", details);
|
|
||||||
assert!(log_entry.contains("payment_event"));
|
|
||||||
assert!(log_entry.contains("freezone-registration"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
use hmac::{Hmac, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
/// Stripe webhook signature verification
|
|
||||||
/// Implements proper HMAC-SHA256 verification as per Stripe documentation
|
|
||||||
pub struct StripeWebhookVerifier;
|
|
||||||
|
|
||||||
impl StripeWebhookVerifier {
|
|
||||||
/// Verify Stripe webhook signature
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `payload` - Raw webhook payload bytes
|
|
||||||
/// * `signature_header` - Stripe-Signature header value
|
|
||||||
/// * `webhook_secret` - Webhook endpoint secret from Stripe
|
|
||||||
/// * `tolerance_seconds` - Maximum age of webhook (default: 300 seconds)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * `Ok(true)` - Signature is valid
|
|
||||||
/// * `Ok(false)` - Signature is invalid
|
|
||||||
/// * `Err(String)` - Verification error
|
|
||||||
pub fn verify_signature(
|
|
||||||
payload: &[u8],
|
|
||||||
signature_header: &str,
|
|
||||||
webhook_secret: &str,
|
|
||||||
tolerance_seconds: Option<u64>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let tolerance = tolerance_seconds.unwrap_or(300); // 5 minutes default
|
|
||||||
|
|
||||||
// Parse signature header
|
|
||||||
let (timestamp, signatures) = Self::parse_signature_header(signature_header)?;
|
|
||||||
|
|
||||||
// Check timestamp tolerance
|
|
||||||
Self::verify_timestamp(timestamp, tolerance)?;
|
|
||||||
|
|
||||||
// Verify signature
|
|
||||||
Self::verify_hmac(payload, timestamp, signatures, webhook_secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Stripe signature header
|
|
||||||
/// Format: "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
|
|
||||||
fn parse_signature_header(signature_header: &str) -> Result<(u64, Vec<String>), String> {
|
|
||||||
let mut timestamp = None;
|
|
||||||
let mut signatures = Vec::new();
|
|
||||||
|
|
||||||
for element in signature_header.split(',') {
|
|
||||||
let parts: Vec<&str> = element.splitn(2, '=').collect();
|
|
||||||
if parts.len() != 2 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match parts[0] {
|
|
||||||
"t" => {
|
|
||||||
timestamp = Some(
|
|
||||||
parts[1]
|
|
||||||
.parse::<u64>()
|
|
||||||
.map_err(|_| "Invalid timestamp in signature header".to_string())?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"v1" => {
|
|
||||||
signatures.push(parts[1].to_string());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Ignore unknown signature schemes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timestamp = timestamp.ok_or("Missing timestamp in signature header")?;
|
|
||||||
|
|
||||||
if signatures.is_empty() {
|
|
||||||
return Err("No valid signatures found in header".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((timestamp, signatures))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify timestamp is within tolerance
|
|
||||||
fn verify_timestamp(timestamp: u64, tolerance_seconds: u64) -> Result<(), String> {
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|_| "Failed to get current time")?
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let age = current_time.saturating_sub(timestamp);
|
|
||||||
|
|
||||||
if age > tolerance_seconds {
|
|
||||||
return Err(format!(
|
|
||||||
"Webhook timestamp too old: {} seconds (max: {})",
|
|
||||||
age, tolerance_seconds
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify HMAC signature
|
|
||||||
fn verify_hmac(
|
|
||||||
payload: &[u8],
|
|
||||||
timestamp: u64,
|
|
||||||
signatures: Vec<String>,
|
|
||||||
webhook_secret: &str,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
// Create signed payload: timestamp + "." + payload
|
|
||||||
let signed_payload = format!(
|
|
||||||
"{}.{}",
|
|
||||||
timestamp,
|
|
||||||
std::str::from_utf8(payload).map_err(|_| "Invalid UTF-8 in payload")?
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create HMAC
|
|
||||||
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes())
|
|
||||||
.map_err(|_| "Invalid webhook secret")?;
|
|
||||||
mac.update(signed_payload.as_bytes());
|
|
||||||
|
|
||||||
// Get expected signature
|
|
||||||
let expected_signature = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
// Compare with provided signatures (constant-time comparison)
|
|
||||||
for signature in signatures {
|
|
||||||
if constant_time_compare(&expected_signature, &signature) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Constant-time string comparison to prevent timing attacks
|
|
||||||
fn constant_time_compare(a: &str, b: &str) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = 0u8;
|
|
||||||
for (byte_a, byte_b) in a.bytes().zip(b.bytes()) {
|
|
||||||
result |= byte_a ^ byte_b;
|
|
||||||
}
|
|
||||||
|
|
||||||
result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_signature_header() {
|
|
||||||
let header =
|
|
||||||
"t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";
|
|
||||||
let (timestamp, signatures) =
|
|
||||||
StripeWebhookVerifier::parse_signature_header(header).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(timestamp, 1492774577);
|
|
||||||
assert_eq!(signatures.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
signatures[0],
|
|
||||||
"5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_signature_header_multiple_signatures() {
|
|
||||||
let header = "t=1492774577,v1=sig1,v1=sig2";
|
|
||||||
let (timestamp, signatures) =
|
|
||||||
StripeWebhookVerifier::parse_signature_header(header).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(timestamp, 1492774577);
|
|
||||||
assert_eq!(signatures.len(), 2);
|
|
||||||
assert_eq!(signatures[0], "sig1");
|
|
||||||
assert_eq!(signatures[1], "sig2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_signature_header_invalid() {
|
|
||||||
let header = "invalid_header";
|
|
||||||
let result = StripeWebhookVerifier::parse_signature_header(header);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_constant_time_compare() {
|
|
||||||
assert!(constant_time_compare("hello", "hello"));
|
|
||||||
assert!(!constant_time_compare("hello", "world"));
|
|
||||||
assert!(!constant_time_compare("hello", "hello123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_verify_timestamp_valid() {
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Test with current timestamp (should pass)
|
|
||||||
assert!(StripeWebhookVerifier::verify_timestamp(current_time, 300).is_ok());
|
|
||||||
|
|
||||||
// Test with timestamp 100 seconds ago (should pass)
|
|
||||||
assert!(StripeWebhookVerifier::verify_timestamp(current_time - 100, 300).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_verify_timestamp_too_old() {
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Test with timestamp 400 seconds ago (should fail with 300s tolerance)
|
|
||||||
let result = StripeWebhookVerifier::verify_timestamp(current_time - 400, 300);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().contains("too old"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_verify_signature_integration() {
|
|
||||||
// Test with known good signature from Stripe documentation
|
|
||||||
let payload = b"test payload";
|
|
||||||
let webhook_secret = "whsec_test_secret";
|
|
||||||
let timestamp = 1492774577u64;
|
|
||||||
|
|
||||||
// Create expected signature manually for testing
|
|
||||||
let signed_payload = format!("{}.{}", timestamp, std::str::from_utf8(payload).unwrap());
|
|
||||||
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
|
||||||
mac.update(signed_payload.as_bytes());
|
|
||||||
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
let _signature_header = format!("t={},v1={}", timestamp, expected_sig);
|
|
||||||
|
|
||||||
// This would fail due to timestamp being too old, so we test with a recent timestamp
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let signed_payload_current =
|
|
||||||
format!("{}.{}", current_time, std::str::from_utf8(payload).unwrap());
|
|
||||||
let mut mac_current = HmacSha256::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
|
||||||
mac_current.update(signed_payload_current.as_bytes());
|
|
||||||
let current_sig = hex::encode(mac_current.finalize().into_bytes());
|
|
||||||
|
|
||||||
let current_signature_header = format!("t={},v1={}", current_time, current_sig);
|
|
||||||
|
|
||||||
let result = StripeWebhookVerifier::verify_signature(
|
|
||||||
payload,
|
|
||||||
¤t_signature_header,
|
|
||||||
webhook_secret,
|
|
||||||
Some(300),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert!(result.unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
use regex::Regex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Validation error details
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationError {
|
|
||||||
pub field: String,
|
|
||||||
pub message: String,
|
|
||||||
pub code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validation result
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationResult {
|
|
||||||
pub is_valid: bool,
|
|
||||||
pub errors: Vec<ValidationError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ValidationResult {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
is_valid: true,
|
|
||||||
errors: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_error(&mut self, field: &str, message: &str, code: &str) {
|
|
||||||
self.is_valid = false;
|
|
||||||
self.errors.push(ValidationError {
|
|
||||||
field: field.to_string(),
|
|
||||||
message: message.to_string(),
|
|
||||||
code: code.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(&mut self, other: ValidationResult) {
|
|
||||||
if !other.is_valid {
|
|
||||||
self.is_valid = false;
|
|
||||||
self.errors.extend(other.errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Company registration data validator
|
|
||||||
pub struct CompanyRegistrationValidator;
|
|
||||||
|
|
||||||
impl CompanyRegistrationValidator {
|
|
||||||
/// Validate complete company registration data
|
|
||||||
pub fn validate(
|
|
||||||
data: &crate::controllers::payment::CompanyRegistrationData,
|
|
||||||
) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
// Validate company name
|
|
||||||
result.merge(Self::validate_company_name(&data.company_name));
|
|
||||||
|
|
||||||
// Validate company type
|
|
||||||
result.merge(Self::validate_company_type(&data.company_type));
|
|
||||||
|
|
||||||
// Validate email (if provided)
|
|
||||||
if let Some(ref email) = data.company_email {
|
|
||||||
if !email.is_empty() {
|
|
||||||
result.merge(Self::validate_email(email));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate phone (if provided)
|
|
||||||
if let Some(ref phone) = data.company_phone {
|
|
||||||
if !phone.is_empty() {
|
|
||||||
result.merge(Self::validate_phone(phone));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate website (if provided)
|
|
||||||
if let Some(ref website) = data.company_website {
|
|
||||||
if !website.is_empty() {
|
|
||||||
result.merge(Self::validate_website(website));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate address (if provided)
|
|
||||||
if let Some(ref address) = data.company_address {
|
|
||||||
if !address.is_empty() {
|
|
||||||
result.merge(Self::validate_address(address));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate shareholders JSON
|
|
||||||
result.merge(Self::validate_shareholders(&data.shareholders));
|
|
||||||
|
|
||||||
// Validate payment plan
|
|
||||||
result.merge(Self::validate_payment_plan(&data.payment_plan));
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate company name
|
|
||||||
fn validate_company_name(name: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if name.trim().is_empty() {
|
|
||||||
result.add_error("company_name", "Company name is required", "required");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if name.len() < 2 {
|
|
||||||
result.add_error(
|
|
||||||
"company_name",
|
|
||||||
"Company name must be at least 2 characters long",
|
|
||||||
"min_length",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if name.len() > 100 {
|
|
||||||
result.add_error(
|
|
||||||
"company_name",
|
|
||||||
"Company name must be less than 100 characters",
|
|
||||||
"max_length",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for valid characters (letters, numbers, spaces, common punctuation)
|
|
||||||
let valid_name_regex = Regex::new(r"^[a-zA-Z0-9\s\-\.\&\(\)]+$").unwrap();
|
|
||||||
if !valid_name_regex.is_match(name) {
|
|
||||||
result.add_error(
|
|
||||||
"company_name",
|
|
||||||
"Company name contains invalid characters",
|
|
||||||
"invalid_format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate company type
|
|
||||||
fn validate_company_type(company_type: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
let valid_types = vec![
|
|
||||||
"Single FZC",
|
|
||||||
"Startup FZC",
|
|
||||||
"Growth FZC",
|
|
||||||
"Global FZC",
|
|
||||||
"Cooperative FZC",
|
|
||||||
"Twin FZC",
|
|
||||||
];
|
|
||||||
|
|
||||||
if !valid_types.contains(&company_type) {
|
|
||||||
result.add_error(
|
|
||||||
"company_type",
|
|
||||||
"Invalid company type selected",
|
|
||||||
"invalid_option",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate email address
|
|
||||||
fn validate_email(email: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if email.trim().is_empty() {
|
|
||||||
return result; // Email is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic email regex
|
|
||||||
let email_regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
|
||||||
if !email_regex.is_match(email) {
|
|
||||||
result.add_error(
|
|
||||||
"company_email",
|
|
||||||
"Please enter a valid email address",
|
|
||||||
"invalid_format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if email.len() > 254 {
|
|
||||||
result.add_error("company_email", "Email address is too long", "max_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate phone number
|
|
||||||
fn validate_phone(phone: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if phone.trim().is_empty() {
|
|
||||||
return result; // Phone is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove common formatting characters
|
|
||||||
let cleaned_phone = phone.replace(&[' ', '-', '(', ')', '+'][..], "");
|
|
||||||
|
|
||||||
if cleaned_phone.len() < 7 {
|
|
||||||
result.add_error("company_phone", "Phone number is too short", "min_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleaned_phone.len() > 15 {
|
|
||||||
result.add_error("company_phone", "Phone number is too long", "max_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if contains only digits after cleaning
|
|
||||||
if !cleaned_phone.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
result.add_error(
|
|
||||||
"company_phone",
|
|
||||||
"Phone number contains invalid characters",
|
|
||||||
"invalid_format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate website URL
|
|
||||||
fn validate_website(website: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if website.trim().is_empty() {
|
|
||||||
return result; // Website is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic URL validation
|
|
||||||
let url_regex = Regex::new(r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$").unwrap();
|
|
||||||
if !url_regex.is_match(website) {
|
|
||||||
result.add_error(
|
|
||||||
"company_website",
|
|
||||||
"Please enter a valid website URL (e.g., https://example.com)",
|
|
||||||
"invalid_format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if website.len() > 255 {
|
|
||||||
result.add_error("company_website", "Website URL is too long", "max_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate address
|
|
||||||
fn validate_address(address: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if address.trim().is_empty() {
|
|
||||||
return result; // Address is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
if address.len() < 5 {
|
|
||||||
result.add_error("company_address", "Address is too short", "min_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
if address.len() > 500 {
|
|
||||||
result.add_error("company_address", "Address is too long", "max_length");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate shareholders JSON
|
|
||||||
fn validate_shareholders(shareholders: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
if shareholders.trim().is_empty() {
|
|
||||||
result.add_error(
|
|
||||||
"shareholders",
|
|
||||||
"Shareholders information is required",
|
|
||||||
"required",
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as JSON
|
|
||||||
match serde_json::from_str::<serde_json::Value>(shareholders) {
|
|
||||||
Ok(json) => {
|
|
||||||
if let Some(array) = json.as_array() {
|
|
||||||
if array.is_empty() {
|
|
||||||
result.add_error(
|
|
||||||
"shareholders",
|
|
||||||
"At least one shareholder is required",
|
|
||||||
"min_items",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.add_error(
|
|
||||||
"shareholders",
|
|
||||||
"Shareholders must be a valid JSON array",
|
|
||||||
"invalid_format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
result.add_error(
|
|
||||||
"shareholders",
|
|
||||||
"Invalid shareholders data format",
|
|
||||||
"invalid_json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate payment plan
|
|
||||||
fn validate_payment_plan(payment_plan: &str) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
let valid_plans = vec!["monthly", "yearly", "two_year"];
|
|
||||||
|
|
||||||
if !valid_plans.contains(&payment_plan) {
|
|
||||||
result.add_error(
|
|
||||||
"payment_plan",
|
|
||||||
"Invalid payment plan selected",
|
|
||||||
"invalid_option",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::controllers::payment::CompanyRegistrationData;
|
|
||||||
|
|
||||||
fn create_valid_registration_data() -> CompanyRegistrationData {
|
|
||||||
CompanyRegistrationData {
|
|
||||||
company_name: "Test Company Ltd".to_string(),
|
|
||||||
company_type: "Single FZC".to_string(),
|
|
||||||
company_email: Some("test@example.com".to_string()),
|
|
||||||
company_phone: Some("+1234567890".to_string()),
|
|
||||||
company_website: Some("https://example.com".to_string()),
|
|
||||||
company_address: Some("123 Test Street, Test City".to_string()),
|
|
||||||
company_industry: Some("Technology".to_string()),
|
|
||||||
company_purpose: Some("Software development".to_string()),
|
|
||||||
fiscal_year_end: Some("December".to_string()),
|
|
||||||
shareholders: r#"[{"name": "John Doe", "percentage": 100}]"#.to_string(),
|
|
||||||
payment_plan: "monthly".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_valid_registration_data() {
|
|
||||||
let data = create_valid_registration_data();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(result.is_valid, "Valid data should pass validation");
|
|
||||||
assert!(result.errors.is_empty(), "Valid data should have no errors");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_company_name() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_name = "".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_name"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_email() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_email = Some("invalid-email".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_email"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_phone() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_phone = Some("123".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_phone"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_website() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_website = Some("not-a-url".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_website"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_shareholders() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.shareholders = "invalid json".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "shareholders"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_payment_plan() {
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.payment_plan = "invalid_plan".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "payment_plan"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod company;
|
|
||||||
|
|
||||||
// Re-export for easier imports
|
|
||||||
pub use company::{CompanyRegistrationValidator, ValidationError, ValidationResult};
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,29 +5,29 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<h1>Create New Event</h1>
|
<h1>Create New Event</h1>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="/calendar/events" method="post">
|
<form action="/calendar/new" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="title" class="form-label">Event Title</label>
|
<label for="title" class="form-label">Event Title</label>
|
||||||
<input type="text" class="form-control" id="title" name="title" required>
|
<input type="text" class="form-control" id="title" name="title" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="description" class="form-label">Description</label>
|
<label for="description" class="form-label">Description</label>
|
||||||
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
|
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="all_day" name="all_day">
|
<input type="checkbox" class="form-check-input" id="all_day" name="all_day">
|
||||||
<label class="form-check-label" for="all_day">All Day Event</label>
|
<label class="form-check-label" for="all_day">All Day Event</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label for="start_time" class="form-label">Start Time</label>
|
<label for="start_time" class="form-label">Start Time</label>
|
||||||
@@ -38,14 +38,7 @@
|
|||||||
<input type="datetime-local" class="form-control" id="end_time" name="end_time" required>
|
<input type="datetime-local" class="form-control" id="end_time" name="end_time" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Show selected date info when coming from calendar date click -->
|
|
||||||
<div id="selected-date-info" class="alert alert-info" style="display: none;">
|
|
||||||
<strong>Selected Date:</strong> <span id="selected-date-display"></span>
|
|
||||||
<br>
|
|
||||||
<small>The date is pre-selected. You can only modify the time portion.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="color" class="form-label">Event Color</label>
|
<label for="color" class="form-label">Event Color</label>
|
||||||
<select class="form-control" id="color" name="color">
|
<select class="form-control" id="color" name="color">
|
||||||
@@ -57,7 +50,7 @@
|
|||||||
<option value="#24C1E0">Cyan</option>
|
<option value="#24C1E0">Cyan</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="submit" class="btn btn-primary">Create Event</button>
|
<button type="submit" class="btn btn-primary">Create Event</button>
|
||||||
<a href="/calendar" class="btn btn-secondary">Cancel</a>
|
<a href="/calendar" class="btn btn-secondary">Cancel</a>
|
||||||
@@ -66,106 +59,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Check if we came from a date click (URL parameter)
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const selectedDate = urlParams.get('date');
|
|
||||||
|
|
||||||
if (selectedDate) {
|
|
||||||
// Show the selected date info
|
|
||||||
document.getElementById('selected-date-info').style.display = 'block';
|
|
||||||
document.getElementById('selected-date-display').textContent = new Date(selectedDate).toLocaleDateString();
|
|
||||||
|
|
||||||
// Pre-fill the date portion and restrict date changes
|
|
||||||
const startTimeInput = document.getElementById('start_time');
|
|
||||||
const endTimeInput = document.getElementById('end_time');
|
|
||||||
|
|
||||||
// Set default times (9 AM to 10 AM on the selected date)
|
|
||||||
const startDateTime = new Date(selectedDate + 'T09:00');
|
|
||||||
const endDateTime = new Date(selectedDate + 'T10:00');
|
|
||||||
|
|
||||||
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
|
|
||||||
startTimeInput.value = startDateTime.toISOString().slice(0, 16);
|
|
||||||
endTimeInput.value = endDateTime.toISOString().slice(0, 16);
|
|
||||||
|
|
||||||
// Set minimum and maximum date to the selected date to prevent changing the date
|
|
||||||
const minDate = selectedDate + 'T00:00';
|
|
||||||
const maxDate = selectedDate + 'T23:59';
|
|
||||||
startTimeInput.min = minDate;
|
|
||||||
startTimeInput.max = maxDate;
|
|
||||||
endTimeInput.min = minDate;
|
|
||||||
endTimeInput.max = maxDate;
|
|
||||||
|
|
||||||
// Add event listeners to ensure end time is after start time
|
|
||||||
startTimeInput.addEventListener('change', function () {
|
|
||||||
const startTime = new Date(this.value);
|
|
||||||
const endTime = new Date(endTimeInput.value);
|
|
||||||
|
|
||||||
if (endTime <= startTime) {
|
|
||||||
// Set end time to 1 hour after start time
|
|
||||||
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
|
||||||
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update end time minimum to be after start time
|
|
||||||
endTimeInput.min = this.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
endTimeInput.addEventListener('change', function () {
|
|
||||||
const startTime = new Date(startTimeInput.value);
|
|
||||||
const endTime = new Date(this.value);
|
|
||||||
|
|
||||||
if (endTime <= startTime) {
|
|
||||||
// Reset to 1 hour after start time
|
|
||||||
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
|
||||||
this.value = newEndTime.toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No date selected, set default to current time
|
|
||||||
const now = new Date();
|
|
||||||
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
|
|
||||||
|
|
||||||
document.getElementById('start_time').value = now.toISOString().slice(0, 16);
|
|
||||||
document.getElementById('end_time').value = oneHourLater.toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert datetime-local inputs to RFC3339 format on form submission
|
// Convert datetime-local inputs to RFC3339 format on form submission
|
||||||
document.querySelector('form').addEventListener('submit', function (e) {
|
document.querySelector('form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const startTime = document.getElementById('start_time').value;
|
const startTime = document.getElementById('start_time').value;
|
||||||
const endTime = document.getElementById('end_time').value;
|
const endTime = document.getElementById('end_time').value;
|
||||||
|
|
||||||
// Validate that end time is after start time
|
|
||||||
if (new Date(endTime) <= new Date(startTime)) {
|
|
||||||
alert('End time must be after start time');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to RFC3339 format
|
// Convert to RFC3339 format
|
||||||
const startRFC = new Date(startTime).toISOString();
|
const startRFC = new Date(startTime).toISOString();
|
||||||
const endRFC = new Date(endTime).toISOString();
|
const endRFC = new Date(endTime).toISOString();
|
||||||
|
|
||||||
// Create hidden inputs for the RFC3339 values
|
// Create hidden inputs for the RFC3339 values
|
||||||
const startInput = document.createElement('input');
|
const startInput = document.createElement('input');
|
||||||
startInput.type = 'hidden';
|
startInput.type = 'hidden';
|
||||||
startInput.name = 'start_time';
|
startInput.name = 'start_time';
|
||||||
startInput.value = startRFC;
|
startInput.value = startRFC;
|
||||||
|
|
||||||
const endInput = document.createElement('input');
|
const endInput = document.createElement('input');
|
||||||
endInput.type = 'hidden';
|
endInput.type = 'hidden';
|
||||||
endInput.name = 'end_time';
|
endInput.name = 'end_time';
|
||||||
endInput.value = endRFC;
|
endInput.value = endRFC;
|
||||||
|
|
||||||
// Remove the original inputs
|
// Remove the original inputs
|
||||||
document.getElementById('start_time').removeAttribute('name');
|
document.getElementById('start_time').removeAttribute('name');
|
||||||
document.getElementById('end_time').removeAttribute('name');
|
document.getElementById('end_time').removeAttribute('name');
|
||||||
|
|
||||||
// Add the hidden inputs to the form
|
// Add the hidden inputs to the form
|
||||||
this.appendChild(startInput);
|
this.appendChild(startInput);
|
||||||
this.appendChild(endInput);
|
this.appendChild(endInput);
|
||||||
|
|
||||||
// Submit the form
|
// Submit the form
|
||||||
this.submit();
|
this.submit();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ company.name }} - Document Management{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
|
||||||
<style>
|
|
||||||
.document-card {
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-area {
|
|
||||||
border: 2px dashed #dee2e6;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-area:hover {
|
|
||||||
border-color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-area.dragover {
|
|
||||||
border-color: #0d6efd;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-folder me-2"></i>{{ company.name }} - Documents</h2>
|
|
||||||
<div>
|
|
||||||
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary me-2">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to Company
|
|
||||||
</a>
|
|
||||||
<a href="/company" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-building me-1"></i>All Companies
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success/Error Messages -->
|
|
||||||
{% if success %}
|
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Document Statistics -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<i class="bi bi-files text-primary" style="font-size: 2rem;"></i>
|
|
||||||
<h4 class="mt-2">{{ stats.total_documents }}</h4>
|
|
||||||
<p class="text-muted mb-0">Total Documents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<i class="bi bi-hdd text-info" style="font-size: 2rem;"></i>
|
|
||||||
<h4 class="mt-2">{{ stats.formatted_total_size }}</h4>
|
|
||||||
<p class="text-muted mb-0">Total Size</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<i class="bi bi-upload text-success" style="font-size: 2rem;"></i>
|
|
||||||
<h4 class="mt-2">{{ stats.recent_uploads }}</h4>
|
|
||||||
<p class="text-muted mb-0">Recent Uploads</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<i class="bi bi-folder-plus text-warning" style="font-size: 2rem;"></i>
|
|
||||||
<h4 class="mt-2">{{ stats.by_type | length }}</h4>
|
|
||||||
<p class="text-muted mb-0">Document Types</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Document Upload Section -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header bg-primary text-white">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2"></i>Upload Documents</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="/company/documents/{{ company_id }}/upload" method="post" enctype="multipart/form-data"
|
|
||||||
id="uploadForm">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="document_type" class="form-label">Document Type</label>
|
|
||||||
<select class="form-select" id="document_type" name="document_type" required>
|
|
||||||
<option value="">Select document type...</option>
|
|
||||||
{% for doc_type in document_types %}
|
|
||||||
<option value="{{ doc_type.0 }}">{{ doc_type.1 }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description" class="form-label">Description (Optional)</label>
|
|
||||||
<textarea class="form-control" id="description" name="description" rows="3"
|
|
||||||
placeholder="Enter document description..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="is_public" name="is_public">
|
|
||||||
<label class="form-check-label" for="is_public">
|
|
||||||
Make document publicly accessible
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="documents" class="form-label">Select Files</label>
|
|
||||||
<div class="upload-area" id="uploadArea">
|
|
||||||
<i class="bi bi-cloud-upload file-icon text-muted"></i>
|
|
||||||
<p class="mb-2">Drag and drop files here or click to browse</p>
|
|
||||||
<input type="file" class="form-control" id="documents" name="documents" multiple
|
|
||||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.txt" style="display: none;">
|
|
||||||
<button type="button" class="btn btn-outline-primary"
|
|
||||||
onclick="document.getElementById('documents').click()">
|
|
||||||
<i class="bi bi-folder2-open me-1"></i>Browse Files
|
|
||||||
</button>
|
|
||||||
<div id="fileList" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<button type="submit" class="btn btn-primary" id="uploadBtn">
|
|
||||||
<i class="bi bi-upload me-1"></i>Upload Documents
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documents List -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-files me-2"></i>Documents ({{ documents | length }})</h5>
|
|
||||||
<div class="input-group" style="width: 300px;">
|
|
||||||
<input type="text" class="form-control" id="searchInput" placeholder="Search documents...">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" id="searchBtn">
|
|
||||||
<i class="bi bi-search"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if documents and documents | length > 0 %}
|
|
||||||
<div class="row" id="documentsGrid">
|
|
||||||
{% for document in documents %}
|
|
||||||
<div class="col-md-4 mb-3 document-item" data-name="{{ document.name | lower }}"
|
|
||||||
data-type="{{ document.document_type_str | lower }}">
|
|
||||||
<div class="card document-card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<div class="file-icon">
|
|
||||||
{% if document.is_pdf %}
|
|
||||||
<i class="bi bi-file-earmark-pdf text-danger"></i>
|
|
||||||
{% elif document.is_image %}
|
|
||||||
<i class="bi bi-file-earmark-image text-success"></i>
|
|
||||||
{% elif document.mime_type == "application/msword" %}
|
|
||||||
<i class="bi bi-file-earmark-word text-primary"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-file-earmark text-secondary"></i>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
|
|
||||||
data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-three-dots"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a class="dropdown-item" href="#"
|
|
||||||
onclick="downloadDocument({{ document.id }})">
|
|
||||||
<i class="bi bi-download me-1"></i>Download
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="#" onclick="editDocument({{ document.id }})">
|
|
||||||
<i class="bi bi-pencil me-1"></i>Edit
|
|
||||||
</a></li>
|
|
||||||
<li>
|
|
||||||
<hr class="dropdown-divider">
|
|
||||||
</li>
|
|
||||||
<li><a class="dropdown-item text-danger" href="#"
|
|
||||||
onclick="deleteDocument({{ document.id }}, '{{ document.name }}')">
|
|
||||||
<i class="bi bi-trash me-1"></i>Delete
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h6 class="card-title text-truncate" title="{{ document.name }}">{{ document.name }}</h6>
|
|
||||||
<p class="card-text">
|
|
||||||
<small class="text-muted">
|
|
||||||
<span class="badge bg-secondary mb-1">{{ document.document_type_str }}</span><br>
|
|
||||||
Size: {{ document.formatted_file_size }}<br>
|
|
||||||
Uploaded: {{ document.formatted_upload_date }}<br>
|
|
||||||
By: {{ document.uploaded_by }}
|
|
||||||
{% if document.is_public %}
|
|
||||||
<br><span class="badge bg-success">Public</span>
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
{% if document.description %}
|
|
||||||
<p class="card-text"><small>{{ document.description }}</small></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-folder-x text-muted" style="font-size: 4rem;"></i>
|
|
||||||
<h4 class="text-muted mt-3">No Documents Found</h4>
|
|
||||||
<p class="text-muted">Upload your first document using the form above.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Confirm Delete</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Are you sure you want to delete the document "<span id="deleteDocumentName"></span>"?</p>
|
|
||||||
<p class="text-danger"><small>This action cannot be undone.</small></p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<a href="#" class="btn btn-danger" id="confirmDeleteBtn">Delete Document</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{{ super() }}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const uploadArea = document.getElementById('uploadArea');
|
|
||||||
const fileInput = document.getElementById('documents');
|
|
||||||
const fileList = document.getElementById('fileList');
|
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
|
|
||||||
// File upload handling
|
|
||||||
fileInput.addEventListener('change', function () {
|
|
||||||
console.log('Files selected:', this.files.length);
|
|
||||||
updateFileList();
|
|
||||||
updateUploadButton();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag and drop
|
|
||||||
uploadArea.addEventListener('dragover', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
uploadArea.classList.add('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadArea.addEventListener('dragleave', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
uploadArea.classList.remove('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadArea.addEventListener('drop', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
uploadArea.classList.remove('dragover');
|
|
||||||
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
console.log('Files dropped:', files.length);
|
|
||||||
|
|
||||||
// Create a new DataTransfer object and assign to input
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
dt.items.add(files[i]);
|
|
||||||
}
|
|
||||||
fileInput.files = dt.files;
|
|
||||||
|
|
||||||
updateFileList();
|
|
||||||
updateUploadButton();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click to upload area
|
|
||||||
uploadArea.addEventListener('click', function (e) {
|
|
||||||
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
|
|
||||||
fileInput.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
searchInput.addEventListener('input', function () {
|
|
||||||
const searchTerm = this.value.toLowerCase();
|
|
||||||
const documentItems = document.querySelectorAll('.document-item');
|
|
||||||
|
|
||||||
documentItems.forEach(function (item) {
|
|
||||||
const name = item.dataset.name;
|
|
||||||
const type = item.dataset.type;
|
|
||||||
const matches = name.includes(searchTerm) || type.includes(searchTerm);
|
|
||||||
item.style.display = matches ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateFileList() {
|
|
||||||
const files = Array.from(fileInput.files);
|
|
||||||
if (files.length === 0) {
|
|
||||||
fileList.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listHtml = files.map(file =>
|
|
||||||
`<div class="d-flex justify-content-between align-items-center p-2 border rounded mb-1">
|
|
||||||
<span class="text-truncate">${file.name}</span>
|
|
||||||
<small class="text-muted">${formatFileSize(file.size)}</small>
|
|
||||||
</div>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
fileList.innerHTML = `<div class="mt-2"><strong>Selected files:</strong>${listHtml}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUploadButton() {
|
|
||||||
const hasFiles = fileInput.files.length > 0;
|
|
||||||
const hasDocumentType = document.getElementById('document_type').value !== '';
|
|
||||||
uploadBtn.disabled = !hasFiles || !hasDocumentType;
|
|
||||||
|
|
||||||
console.log('Update upload button - Files:', hasFiles, 'DocType:', hasDocumentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update button when document type changes
|
|
||||||
document.getElementById('document_type').addEventListener('change', function () {
|
|
||||||
updateUploadButton();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add form submission debugging
|
|
||||||
document.getElementById('uploadForm').addEventListener('submit', function (e) {
|
|
||||||
console.log('Form submitted');
|
|
||||||
console.log('Files:', fileInput.files.length);
|
|
||||||
console.log('Document type:', document.getElementById('document_type').value);
|
|
||||||
|
|
||||||
if (fileInput.files.length === 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Please select at least one file to upload.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById('document_type').value === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Please select a document type.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function deleteDocument(documentId, documentName) {
|
|
||||||
document.getElementById('deleteDocumentName').textContent = documentName;
|
|
||||||
document.getElementById('confirmDeleteBtn').href = `/company/documents/{{ company_id }}/delete/${documentId}`;
|
|
||||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadDocument(documentId) {
|
|
||||||
// TODO: Implement download functionality
|
|
||||||
alert('Download functionality will be implemented soon');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editDocument(documentId) {
|
|
||||||
// TODO: Implement edit functionality
|
|
||||||
alert('Edit functionality will be implemented soon');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit {{ company.name }} - Company Management{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-pencil-square me-2"></i>Edit Company</h2>
|
|
||||||
<div>
|
|
||||||
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary me-2">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to Company
|
|
||||||
</a>
|
|
||||||
<a href="/company" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-building me-1"></i>All Companies
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success/Error Messages -->
|
|
||||||
{% if success %}
|
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Edit Form -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-building me-2"></i>Company Information</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="/company/edit/{{ company.base_data.id }}" method="post" id="editCompanyForm">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Basic Information -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-3">Basic Information</h6>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="company_name" class="form-label">Company Name <span
|
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="company_name" name="company_name"
|
|
||||||
value="{{ company.name }}" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="company_type" class="form-label">Company Type <span
|
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="company_type" name="company_type" required>
|
|
||||||
<option value="Startup FZC" {% if company.business_type=="Starter" %}selected{% endif
|
|
||||||
%}>Startup FZC</option>
|
|
||||||
<option value="Growth FZC" {% if company.business_type=="Global" %}selected{% endif %}>
|
|
||||||
Growth FZC</option>
|
|
||||||
<option value="Cooperative FZC" {% if company.business_type=="Coop" %}selected{% endif
|
|
||||||
%}>Cooperative FZC</option>
|
|
||||||
<option value="Single FZC" {% if company.business_type=="Single" %}selected{% endif %}>
|
|
||||||
Single FZC</option>
|
|
||||||
<option value="Twin FZC" {% if company.business_type=="Twin" %}selected{% endif %}>Twin
|
|
||||||
FZC</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="status" class="form-label">Status</label>
|
|
||||||
<select class="form-select" id="status" name="status">
|
|
||||||
<option value="Active" {% if company.status=="Active" %}selected{% endif %}>Active
|
|
||||||
</option>
|
|
||||||
<option value="Inactive" {% if company.status=="Inactive" %}selected{% endif %}>Inactive
|
|
||||||
</option>
|
|
||||||
<option value="Suspended" {% if company.status=="Suspended" %}selected{% endif %}>
|
|
||||||
Suspended</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="industry" class="form-label">Industry</label>
|
|
||||||
<input type="text" class="form-control" id="industry" name="industry"
|
|
||||||
value="{{ company.industry | default(value='') }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="fiscal_year_end" class="form-label">Fiscal Year End</label>
|
|
||||||
<input type="text" class="form-control" id="fiscal_year_end" name="fiscal_year_end"
|
|
||||||
value="{{ company.fiscal_year_end | default(value='') }}"
|
|
||||||
placeholder="MM-DD (e.g., 12-31)" pattern="^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"
|
|
||||||
title="Enter date in MM-DD format (e.g., 12-31)">
|
|
||||||
<div class="form-text">Enter the last day of your company's fiscal year (MM-DD format)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact Information -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-3">Contact Information</h6>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="email" name="email"
|
|
||||||
value="{{ company.email | default(value='') }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="phone" class="form-label">Phone</label>
|
|
||||||
<input type="tel" class="form-control" id="phone" name="phone"
|
|
||||||
value="{{ company.phone | default(value='') }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="website" class="form-label">Website</label>
|
|
||||||
<input type="url" class="form-control" id="website" name="website"
|
|
||||||
value="{{ company.website | default(value='') }}" placeholder="https://example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="address" class="form-label">Address</label>
|
|
||||||
<textarea class="form-control" id="address" name="address"
|
|
||||||
rows="3">{{ company.address | default(value='') }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="text-muted mb-3">Additional Information</h6>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description" class="form-label">Company Description</label>
|
|
||||||
<textarea class="form-control" id="description" name="description" rows="4"
|
|
||||||
placeholder="Describe the company's purpose and activities">{{ company.description | default(value='') }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Read-only Information -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="text-muted mb-3">Registration Information (Read-only)</h6>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Registration Number</label>
|
|
||||||
<input type="text" class="form-control" value="{{ company.registration_number }}"
|
|
||||||
readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Incorporation Date</label>
|
|
||||||
<input type="text" class="form-control" value="{{ incorporation_date_formatted }}"
|
|
||||||
readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Company ID</label>
|
|
||||||
<input type="text" class="form-control" value="{{ company.base_data.id }}" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Update Company
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{{ super() }}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Form validation
|
|
||||||
const form = document.getElementById('editCompanyForm');
|
|
||||||
const companyName = document.getElementById('company_name');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (e) {
|
|
||||||
if (companyName.value.trim() === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
showValidationAlert('Company name is required', companyName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to show validation alert with consistent styling
|
|
||||||
function showValidationAlert(message, focusElement) {
|
|
||||||
// Remove existing alerts
|
|
||||||
const existingAlerts = document.querySelectorAll('.validation-alert');
|
|
||||||
existingAlerts.forEach(alert => alert.remove());
|
|
||||||
|
|
||||||
// Create new alert
|
|
||||||
const alertDiv = document.createElement('div');
|
|
||||||
alertDiv.className = 'alert alert-warning alert-dismissible fade show validation-alert mt-3';
|
|
||||||
alertDiv.innerHTML = `
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
<span>${message}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Insert alert at the top of the form
|
|
||||||
const form = document.getElementById('editCompanyForm');
|
|
||||||
form.insertBefore(alertDiv, form.firstChild);
|
|
||||||
|
|
||||||
// Focus on the problematic field
|
|
||||||
if (focusElement) {
|
|
||||||
focusElement.focus();
|
|
||||||
focusElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-dismiss after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (alertDiv.parentNode) {
|
|
||||||
alertDiv.remove();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-format website URL
|
|
||||||
const websiteInput = document.getElementById('website');
|
|
||||||
websiteInput.addEventListener('blur', function () {
|
|
||||||
let value = this.value.trim();
|
|
||||||
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
|
||||||
this.value = 'https://' + value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -15,71 +15,55 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if companies and companies|length > 0 %}
|
<!-- Example rows -->
|
||||||
{% for company in companies %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ company.name }}</td>
|
<td>Zanzibar Digital Solutions</td>
|
||||||
<td>
|
<td>Startup FZC</td>
|
||||||
{% if company.business_type == "Starter" %}Startup FZC
|
<td><span class="badge bg-success">Active</span></td>
|
||||||
{% elif company.business_type == "Global" %}Growth FZC
|
<td>2025-04-01</td>
|
||||||
{% elif company.business_type == "Coop" %}Cooperative FZC
|
|
||||||
{% elif company.business_type == "Single" %}Single FZC
|
|
||||||
{% elif company.business_type == "Twin" %}Twin FZC
|
|
||||||
{% else %}{{ company.business_type }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if company.status == "Active" %}
|
|
||||||
<span class="badge bg-success">Active</span>
|
|
||||||
{% elif company.status == "Inactive" %}
|
|
||||||
<span class="badge bg-secondary">Inactive</span>
|
|
||||||
{% elif company.status == "Suspended" %}
|
|
||||||
<span class="badge bg-warning text-dark">Suspended</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">{{ company.status }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ company.incorporation_date | date(format="%Y-%m-%d") }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-sm btn-outline-primary">
|
<a href="/company/view/company1" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||||
<i class="bi bi-eye"></i> View
|
<a href="/company/switch/company1" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a>
|
||||||
</a>
|
|
||||||
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-sm btn-primary">
|
|
||||||
<i class="bi bi-box-arrow-in-right"></i> Switch to Entity
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center py-4">
|
<td>Blockchain Innovations Ltd</td>
|
||||||
<div class="text-muted">
|
<td>Growth FZC</td>
|
||||||
<i class="bi bi-building display-4 mb-3"></i>
|
<td><span class="badge bg-success">Active</span></td>
|
||||||
<h5>No Companies Found</h5>
|
<td>2025-03-15</td>
|
||||||
<p>You haven't registered any companies yet. Get started by registering your first company.
|
<td>
|
||||||
</p>
|
<div class="btn-group">
|
||||||
<button class="btn btn-primary" onclick="document.querySelector('#register-tab').click()">
|
<a href="/company/view/company2" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||||
<i class="bi bi-plus-circle me-1"></i> Register Your First Company
|
<a href="/company/switch/company2" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
<tr>
|
||||||
|
<td>Sustainable Energy Cooperative</td>
|
||||||
|
<td>Cooperative FZC</td>
|
||||||
|
<td><span class="badge bg-warning text-dark">Pending</span></td>
|
||||||
|
<td>2025-05-01</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/company/view/company3" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||||
|
<a href="/company/switch/company3" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- More rows dynamically rendered here -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Company Details Modal -->
|
<!-- Company Details Modal -->
|
||||||
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel"
|
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel" aria-hidden="true">
|
||||||
aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header bg-light">
|
<div class="modal-header bg-light">
|
||||||
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details
|
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details</h5>
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -137,7 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -202,9 +186,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i
|
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</button>
|
||||||
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Payment Error - Company Registration{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mt-4">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card border-danger">
|
|
||||||
<div class="card-header bg-danger text-white text-center">
|
|
||||||
<h3 class="mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
||||||
Payment Error
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-x-circle text-danger" style="font-size: 4rem;"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="text-danger mb-3">Payment Processing Failed</h4>
|
|
||||||
|
|
||||||
<p class="lead mb-4">
|
|
||||||
We encountered an issue processing your payment. Your company registration could not be completed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<h6><i class="bi bi-exclamation-circle me-2"></i>Error Details</h6>
|
|
||||||
<p class="mb-0">{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h6><i class="bi bi-info-circle me-2"></i>What You Can Do</h6>
|
|
||||||
<ul class="list-unstyled mb-0 text-start">
|
|
||||||
<li><i class="bi bi-arrow-right me-2"></i>Check your payment method details</li>
|
|
||||||
<li><i class="bi bi-arrow-right me-2"></i>Ensure you have sufficient funds</li>
|
|
||||||
<li><i class="bi bi-arrow-right me-2"></i>Try a different payment method</li>
|
|
||||||
<li><i class="bi bi-arrow-right me-2"></i>Contact your bank if the issue persists</li>
|
|
||||||
<li><i class="bi bi-arrow-right me-2"></i>Contact our support team for assistance</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
|
||||||
<a href="/company?tab=register" class="btn btn-primary btn-lg">
|
|
||||||
<i class="bi bi-arrow-clockwise me-2"></i>Try Again
|
|
||||||
</a>
|
|
||||||
<a href="/contact" class="btn btn-outline-primary btn-lg">
|
|
||||||
<i class="bi bi-envelope me-2"></i>Contact Support
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-muted text-center">
|
|
||||||
<small>
|
|
||||||
<i class="bi bi-shield-check me-1"></i>
|
|
||||||
No charges were made to your account
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-info {
|
|
||||||
border-left: 4px solid #0dcaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
border-left: 4px solid #dc3545;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Payment Successful - Company Registration{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mt-4">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-header bg-success text-white text-center">
|
|
||||||
<h3 class="mb-0">
|
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>
|
|
||||||
Payment Successful!
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="text-success mb-3">Company Registration Complete</h4>
|
|
||||||
|
|
||||||
<p class="lead mb-4">
|
|
||||||
Congratulations! Your payment has been processed successfully and your company has been registered.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card bg-light">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">Company ID</h6>
|
|
||||||
<p class="card-text h5 text-primary">{{ company_id }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card bg-light">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">Payment ID</h6>
|
|
||||||
<p class="card-text h6 text-muted">{{ payment_intent_id }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h6><i class="bi bi-info-circle me-2"></i>What's Next?</h6>
|
|
||||||
<ul class="list-unstyled mb-0 text-start">
|
|
||||||
<li><i class="bi bi-check me-2"></i>You will receive a confirmation email shortly</li>
|
|
||||||
<li><i class="bi bi-check me-2"></i>Your company documents will be prepared within 24 hours</li>
|
|
||||||
<li><i class="bi bi-check me-2"></i>You can now access your company dashboard</li>
|
|
||||||
<li><i class="bi bi-check me-2"></i>Your subscription billing will begin next month</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
|
||||||
<a href="/company" class="btn btn-primary btn-lg">
|
|
||||||
<i class="bi bi-building me-2"></i>Go to Company Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/company/view/{{ company_id }}" class="btn btn-outline-primary btn-lg">
|
|
||||||
<i class="bi bi-eye me-2"></i>View Company Details
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-muted text-center">
|
|
||||||
<small>
|
|
||||||
<i class="bi bi-shield-check me-1"></i>
|
|
||||||
Your payment was processed securely by Stripe
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-light {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-info {
|
|
||||||
border-left: 4px solid #0dcaf0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,80 +1,31 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ company.name }} - Company Details{% endblock %}
|
{% block title %}{{ company_name }} - Company Details{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<style>
|
<style>
|
||||||
.badge-signed {
|
.badge-signed {
|
||||||
background-color: #198754;
|
background-color: #198754;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.badge-pending {
|
||||||
.badge-pending {
|
background-color: #ffc107;
|
||||||
background-color: #ffc107;
|
color: #212529;
|
||||||
color: #212529;
|
}
|
||||||
}
|
</style>
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-building me-2"></i>{{ company.name }}</h2>
|
<h2><i class="bi bi-building me-2"></i>{{ company_name }}</h2>
|
||||||
<div>
|
<div>
|
||||||
<a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to
|
<a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to Companies</a>
|
||||||
Companies</a>
|
<a href="/company/switch/{{ company_id }}" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
||||||
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-primary"><i
|
|
||||||
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Completion Status -->
|
|
||||||
{% if not company.email or company.email == "" or not company.phone or company.phone == "" or not company.address or
|
|
||||||
company.address == "" %}
|
|
||||||
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="me-3">
|
|
||||||
<i class="bi bi-info-circle fs-4"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h6 class="alert-heading mb-1">Complete Your Company Profile</h6>
|
|
||||||
<p class="mb-2">Your company profile is missing some essential information. Add the missing details to
|
|
||||||
improve your company's visibility and professionalism.</p>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}" class="btn btn-sm btn-outline-info">
|
|
||||||
<i class="bi bi-pencil me-1"></i>Complete Profile
|
|
||||||
</a>
|
|
||||||
<small class="text-muted align-self-center">
|
|
||||||
Missing:
|
|
||||||
{% if not company.email or company.email == "" %}Email{% endif %}
|
|
||||||
{% if not company.phone or company.phone == "" %}{% if not company.email or company.email == ""
|
|
||||||
%}, {% endif %}Phone{% endif %}
|
|
||||||
{% if not company.address or company.address == "" %}{% if not company.email or company.email ==
|
|
||||||
"" or not company.phone or company.phone == "" %}, {% endif %}Address{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Success/Error Messages -->
|
|
||||||
{% if success %}
|
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -85,49 +36,29 @@
|
|||||||
<table class="table table-borderless">
|
<table class="table table-borderless">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 30%">Company Name:</th>
|
<th style="width: 30%">Company Name:</th>
|
||||||
<td>{{ company.name }}</td>
|
<td>{{ company_name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type:</th>
|
<th>Type:</th>
|
||||||
<td>
|
<td>{{ company_type }}</td>
|
||||||
{% if company.business_type == "Starter" %}Startup FZC
|
|
||||||
{% elif company.business_type == "Global" %}Growth FZC
|
|
||||||
{% elif company.business_type == "Coop" %}Cooperative FZC
|
|
||||||
{% elif company.business_type == "Single" %}Single FZC
|
|
||||||
{% elif company.business_type == "Twin" %}Twin FZC
|
|
||||||
{% else %}{{ company.business_type }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Registration Number:</th>
|
|
||||||
<td>{{ company.registration_number }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Registration Date:</th>
|
<th>Registration Date:</th>
|
||||||
<td>{{ incorporation_date_formatted }}</td>
|
<td>{{ registration_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status:</th>
|
<th>Status:</th>
|
||||||
<td>
|
<td>
|
||||||
{% if company.status == "Active" %}
|
{% if status == "Active" %}
|
||||||
<span class="badge bg-success">{{ company.status }}</span>
|
<span class="badge bg-success">{{ status }}</span>
|
||||||
{% elif company.status == "Inactive" %}
|
|
||||||
<span class="badge bg-secondary">{{ company.status }}</span>
|
|
||||||
{% elif company.status == "Suspended" %}
|
|
||||||
<span class="badge bg-warning text-dark">{{ company.status }}</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">{{ company.status }}</span>
|
<span class="badge bg-warning text-dark">{{ status }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Industry:</th>
|
<th>Purpose:</th>
|
||||||
<td>{{ company.industry | default(value="Not specified") }}</td>
|
<td>{{ purpose }}</td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Description:</th>
|
|
||||||
<td>{{ company.description | default(value="No description provided") }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,86 +67,28 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Additional Information</h5>
|
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing Information</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-borderless">
|
<table class="table table-borderless">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 30%">Email:</th>
|
<th style="width: 30%">Plan:</th>
|
||||||
<td>
|
<td>{{ plan }}</td>
|
||||||
{% if company.email and company.email != "" %}
|
|
||||||
{{ company.email }}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not provided</span>
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary ms-2">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Add
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Phone:</th>
|
<th>Next Billing:</th>
|
||||||
<td>
|
<td>{{ next_billing }}</td>
|
||||||
{% if company.phone and company.phone != "" %}
|
|
||||||
{{ company.phone }}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not provided</span>
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary ms-2">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Add
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Website:</th>
|
<th>Payment Method:</th>
|
||||||
<td>
|
<td>{{ payment_method }}</td>
|
||||||
{% if company.website and company.website != "" %}
|
|
||||||
<a href="{{ company.website }}" target="_blank">{{ company.website }}</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not provided</span>
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary ms-2">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Add
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Address:</th>
|
|
||||||
<td>
|
|
||||||
{% if company.address and company.address != "" %}
|
|
||||||
{{ company.address }}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not provided</span>
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary ms-2">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Add
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Fiscal Year End:</th>
|
|
||||||
<td>
|
|
||||||
{% if company.fiscal_year_end and company.fiscal_year_end != "" %}
|
|
||||||
{{ company.fiscal_year_end }}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not specified</span>
|
|
||||||
<a href="/company/edit/{{ company.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary ms-2">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Add
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -231,21 +104,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if shareholders and shareholders|length > 0 %}
|
|
||||||
{% for shareholder in shareholders %}
|
{% for shareholder in shareholders %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ shareholder.name }}</td>
|
<td>{{ shareholder.0 }}</td>
|
||||||
<td>{{ shareholder.percentage }}%</td>
|
<td>{{ shareholder.1 }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center text-muted py-3">
|
|
||||||
<i class="bi bi-people me-1"></i>
|
|
||||||
No shareholders registered yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,91 +118,49 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing & Payment</h5>
|
<h5 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i>Contracts</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if payment_info %}
|
<table class="table table-striped">
|
||||||
<table class="table table-borderless">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 40%">Payment Status:</th>
|
<th>Contract</th>
|
||||||
<td>
|
<th>Status</th>
|
||||||
{% if payment_info.status == "Succeeded" %}
|
<th>Action</th>
|
||||||
<span class="badge bg-success">
|
</tr>
|
||||||
<i class="bi bi-check-circle me-1"></i>Paid
|
</thead>
|
||||||
</span>
|
<tbody>
|
||||||
{% elif payment_info.status == "Pending" %}
|
{% for contract in contracts %}
|
||||||
<span class="badge bg-warning">
|
<tr>
|
||||||
<i class="bi bi-clock me-1"></i>Pending
|
<td>{{ contract.0 }}</td>
|
||||||
</span>
|
<td>
|
||||||
{% elif payment_info.status == "Failed" %}
|
{% if contract.1 == "Signed" %}
|
||||||
<span class="badge bg-danger">
|
<span class="badge bg-success">{{ contract.1 }}</span>
|
||||||
<i class="bi bi-x-circle me-1"></i>Failed
|
{% else %}
|
||||||
</span>
|
<span class="badge bg-warning text-dark">{{ contract.1 }}</span>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<span class="badge bg-secondary">{{ payment_info.status }}</span>
|
</td>
|
||||||
{% endif %}
|
<td>
|
||||||
</td>
|
<a href="/contracts/view/{{ contract.0 | lower | replace(from=' ', to='-') }}" class="btn btn-sm btn-outline-primary">View</a>
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th>Payment Plan:</th>
|
{% endfor %}
|
||||||
<td>{{ payment_plan_display }}</td>
|
</tbody>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Setup Fee:</th>
|
|
||||||
<td>${{ payment_info.setup_fee }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Monthly Fee:</th>
|
|
||||||
<td>${{ payment_info.monthly_fee }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Total Paid:</th>
|
|
||||||
<td><strong>${{ payment_info.total_amount }}</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Payment Date:</th>
|
|
||||||
<td>{{ payment_created_formatted }}</td>
|
|
||||||
</tr>
|
|
||||||
{% if payment_completed_formatted %}
|
|
||||||
<tr>
|
|
||||||
<th>Completed:</th>
|
|
||||||
<td>{{ payment_completed_formatted }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if payment_info.payment_intent_id %}
|
|
||||||
<tr>
|
|
||||||
<th>Payment ID:</th>
|
|
||||||
<td>
|
|
||||||
<code class="small">{{ payment_info.payment_intent_id }}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-3">
|
|
||||||
<i class="bi bi-credit-card me-1"></i>
|
|
||||||
No payment information available
|
|
||||||
<br>
|
|
||||||
<small class="text-muted">This company may have been created before payment integration</small>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Actions</h5>
|
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Actions</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a href="/company/edit/{{ company.base_data.id }}" class="btn btn-outline-primary"><i
|
<a href="/company/edit/{{ company_id }}" class="btn btn-outline-primary"><i class="bi bi-pencil me-1"></i>Edit Company</a>
|
||||||
class="bi bi-pencil me-1"></i>Edit Company</a>
|
<a href="/company/documents/{{ company_id }}" class="btn btn-outline-secondary"><i class="bi bi-file-earmark me-1"></i>Manage Documents</a>
|
||||||
<a href="/company/documents/{{ company.base_data.id }}" class="btn btn-outline-secondary"><i
|
<a href="/company/switch/{{ company_id }}" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
||||||
class="bi bi-file-earmark me-1"></i>Manage Documents</a>
|
|
||||||
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-primary"><i
|
|
||||||
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,10 +168,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
console.log('Company view page loaded');
|
console.log('Company view page loaded');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Add Signer - {{ contract.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col">
|
|
||||||
<nav aria-label="breadcrumb">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/contracts">Contracts</a></li>
|
|
||||||
<li class="breadcrumb-item"><a href="/contracts/{{ contract.id }}">{{ contract.title }}</a></li>
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">Add Signer</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="h3 mb-0">Add Signer</h1>
|
|
||||||
<p class="text-muted mb-0">Add a new signer to "{{ contract.title }}"</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i> Back to Contract
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Add Signer Form -->
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-person-plus me-2"></i>Signer Information
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/contracts/{{ contract.id }}/add-signer">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="name" class="form-label">
|
|
||||||
Full Name <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" class="form-control" id="name" name="name"
|
|
||||||
placeholder="Enter signer's full name" required>
|
|
||||||
<div class="form-text">The full legal name of the person who will sign</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label">
|
|
||||||
Email Address <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="email" class="form-control" id="email" name="email"
|
|
||||||
placeholder="Enter signer's email address" required>
|
|
||||||
<div class="form-text">Email where signing instructions will be sent</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
<strong>Note:</strong> The signer will receive an email with a secure link to sign the contract once you send it for signatures.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-person-plus me-1"></i> Add Signer
|
|
||||||
</button>
|
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-secondary">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contract Summary -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Contract Summary</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">{{ contract.title }}</h6>
|
|
||||||
<p class="card-text text-muted">{{ contract.description }}</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="border-end">
|
|
||||||
<div class="h4 mb-0 text-primary">{{ contract.signers|length }}</div>
|
|
||||||
<small class="text-muted">Current Signers</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="h4 mb-0 text-success">{{ contract.signed_signers }}</div>
|
|
||||||
<small class="text-muted">Signed</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Signers List -->
|
|
||||||
{% if contract.signers|length > 0 %}
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h6 class="mb-0">Current Signers</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
{% for signer in contract.signers %}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="fw-medium">{{ signer.name }}</div>
|
|
||||||
<small class="text-muted">{{ signer.email }}</small>
|
|
||||||
</div>
|
|
||||||
<span class="badge {% if signer.status == 'Signed' %}bg-success{% elif signer.status == 'Rejected' %}bg-danger{% else %}bg-warning text-dark{% endif %}">
|
|
||||||
{{ signer.status }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Form validation
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
const nameInput = document.getElementById('name');
|
|
||||||
const emailInput = document.getElementById('email');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Clear previous validation states
|
|
||||||
nameInput.classList.remove('is-invalid');
|
|
||||||
emailInput.classList.remove('is-invalid');
|
|
||||||
|
|
||||||
// Validate name
|
|
||||||
if (nameInput.value.trim().length < 2) {
|
|
||||||
nameInput.classList.add('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(emailInput.value)) {
|
|
||||||
emailInput.classList.add('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Real-time validation feedback
|
|
||||||
nameInput.addEventListener('input', function() {
|
|
||||||
if (this.value.trim().length >= 2) {
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
this.classList.add('is-valid');
|
|
||||||
} else {
|
|
||||||
this.classList.remove('is-valid');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
emailInput.addEventListener('input', function() {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (emailRegex.test(this.value)) {
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
this.classList.add('is-valid');
|
|
||||||
} else {
|
|
||||||
this.classList.remove('is-valid');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}All Contract Activities{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1 class="display-5 mb-3">Contract Activities</h1>
|
|
||||||
<p class="lead">Complete history of contract actions and events across your organization.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activities List -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">
|
|
||||||
<i class="bi bi-activity"></i> Contract Activity History
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if activities %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th width="50">Type</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Contract</th>
|
|
||||||
<th width="150">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for activity in activities %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<i class="{{ activity.icon }}"></i>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ activity.user }}</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ activity.action }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="text-decoration-none">
|
|
||||||
{{ activity.contract_title }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{ activity.created_at | date(format="%Y-%m-%d %H:%M") }}
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-activity display-1 text-muted"></i>
|
|
||||||
<h4 class="mt-3">No Activities Yet</h4>
|
|
||||||
<p class="text-muted">
|
|
||||||
Contract activities will appear here as users create contracts and add signers.
|
|
||||||
</p>
|
|
||||||
<a href="/contracts/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create First Contract
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activity Statistics -->
|
|
||||||
{% if activities %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ activities | length }}</h5>
|
|
||||||
<p class="card-text text-muted">Total Activities</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">
|
|
||||||
<i class="bi bi-file-earmark-text text-primary"></i>
|
|
||||||
</h5>
|
|
||||||
<p class="card-text text-muted">Contract Timeline</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">
|
|
||||||
<i class="bi bi-people text-success"></i>
|
|
||||||
</h5>
|
|
||||||
<p class="card-text text-muted">Team Collaboration</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Back to Dashboard -->
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12 text-center">
|
|
||||||
<a href="/contracts" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Contracts Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -36,41 +36,27 @@
|
|||||||
<label for="status" class="form-label">Status</label>
|
<label for="status" class="form-label">Status</label>
|
||||||
<select class="form-select" id="status" name="status">
|
<select class="form-select" id="status" name="status">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>Draft
|
<option value="Draft">Draft</option>
|
||||||
</option>
|
<option value="PendingSignatures">Pending Signatures</option>
|
||||||
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
<option value="Signed">Signed</option>
|
||||||
%}selected{% endif %}>Pending Signatures</option>
|
<option value="Expired">Expired</option>
|
||||||
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
<option value="Cancelled">Cancelled</option>
|
||||||
Signed</option>
|
|
||||||
<option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif %}>
|
|
||||||
Expired</option>
|
|
||||||
<option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{% endif
|
|
||||||
%}>Cancelled</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label for="type" class="form-label">Contract Type</label>
|
<label for="type" class="form-label">Contract Type</label>
|
||||||
<select class="form-select" id="type" name="type">
|
<select class="form-select" id="type" name="type">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
<option value="Service">Service Agreement</option>
|
||||||
%}selected{% endif %}>Service Agreement</option>
|
<option value="Employment">Employment Contract</option>
|
||||||
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
<option value="NDA">Non-Disclosure Agreement</option>
|
||||||
%}selected{% endif %}>Employment Contract</option>
|
<option value="SLA">Service Level Agreement</option>
|
||||||
<option value="Non-Disclosure Agreement" {% if
|
<option value="Other">Other</option>
|
||||||
current_type_filter=="Non-Disclosure Agreement" %}selected{% endif %}>Non-Disclosure
|
|
||||||
Agreement</option>
|
|
||||||
<option value="Service Level Agreement" {% if
|
|
||||||
current_type_filter=="Service Level Agreement" %}selected{% endif %}>Service Level
|
|
||||||
Agreement</option>
|
|
||||||
<option value="Other" {% if current_type_filter=="Other" %}selected{% endif %}>Other
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label for="search" class="form-label">Search</label>
|
<label for="search" class="form-label">Search</label>
|
||||||
<input type="text" class="form-control" id="search" name="search"
|
<input type="text" class="form-control" id="search" name="search" placeholder="Search by title or description">
|
||||||
placeholder="Search by title or description"
|
|
||||||
value="{{ current_search_filter | default(value='') }}">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 d-flex align-items-end">
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
||||||
@@ -112,8 +98,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ contract.contract_type }}</td>
|
<td>{{ contract.contract_type }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span class="badge {% if contract.status == 'Signed' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% else %}bg-dark{% endif %}">
|
||||||
class="badge {% if contract.status == 'Signed' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% else %}bg-dark{% endif %}">
|
|
||||||
{{ contract.status }}
|
{{ contract.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -127,14 +112,9 @@
|
|||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if contract.status == 'Draft' %}
|
{% if contract.status == 'Draft' %}
|
||||||
<a href="/contracts/{{ contract.id }}/edit"
|
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||||
class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-sm btn-outline-danger"
|
|
||||||
onclick="deleteContract({{ contract.id }}, '{{ contract.title | replace(from="'", to="\\'") }}')">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -157,70 +137,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
<strong>Warning:</strong> This action cannot be undone!
|
|
||||||
</div>
|
|
||||||
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
|
||||||
<p>This will permanently remove the contract and all its associated data.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
||||||
<i class="bi bi-trash me-1"></i> Delete Contract
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
console.log('Contracts list scripts loading...');
|
|
||||||
|
|
||||||
// Delete function using Bootstrap modal
|
|
||||||
window.deleteContract = function (contractId, contractTitle) {
|
|
||||||
console.log('Delete function called:', contractId, contractTitle);
|
|
||||||
|
|
||||||
// Set the contract title in the modal
|
|
||||||
document.getElementById('contractTitle').textContent = contractTitle;
|
|
||||||
|
|
||||||
// Store the contract ID for later use
|
|
||||||
window.currentDeleteContractId = contractId;
|
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
||||||
deleteModal.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('deleteContract function defined:', typeof window.deleteContract);
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Handle confirm delete button click
|
|
||||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
|
||||||
console.log('User confirmed deletion, submitting form...');
|
|
||||||
|
|
||||||
// Create and submit form
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
|
||||||
form.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -25,14 +25,12 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form action="/contracts/create" method="post">
|
<form action="/contracts/create" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="title" class="form-label">Contract Title <span
|
<label for="title" class="form-label">Contract Title <span class="text-danger">*</span></label>
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="title" name="title" required>
|
<input type="text" class="form-control" id="title" name="title" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="contract_type" class="form-label">Contract Type <span
|
<label for="contract_type" class="form-label">Contract Type <span class="text-danger">*</span></label>
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="contract_type" name="contract_type" required>
|
<select class="form-select" id="contract_type" name="contract_type" required>
|
||||||
<option value="" selected disabled>Select a contract type</option>
|
<option value="" selected disabled>Select a contract type</option>
|
||||||
{% for type in contract_types %}
|
{% for type in contract_types %}
|
||||||
@@ -40,59 +38,28 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="description" class="form-label">Description <span
|
<label for="description" class="form-label">Description <span class="text-danger">*</span></label>
|
||||||
class="text-danger">*</span></label>
|
<textarea class="form-control" id="description" name="description" rows="3" required></textarea>
|
||||||
<textarea class="form-control" id="description" name="description" rows="3"
|
|
||||||
required></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="content" class="form-label">Contract Content (Markdown)</label>
|
<label for="content" class="form-label">Contract Content</label>
|
||||||
<textarea class="form-control" id="content" name="content" rows="10" placeholder="# Contract Title
|
<textarea class="form-control" id="content" name="content" rows="10"></textarea>
|
||||||
|
<div class="form-text">You can leave this blank and add content later.</div>
|
||||||
## 1. Introduction
|
|
||||||
This contract outlines the terms and conditions...
|
|
||||||
|
|
||||||
## 2. Scope of Work
|
|
||||||
- Task 1
|
|
||||||
- Task 2
|
|
||||||
- Task 3
|
|
||||||
|
|
||||||
## 3. Payment Terms
|
|
||||||
Payment will be made according to the following schedule:
|
|
||||||
|
|
||||||
| Milestone | Amount | Due Date |
|
|
||||||
|-----------|--------|----------|
|
|
||||||
| Start | $1,000 | Upon signing |
|
|
||||||
| Completion | $2,000 | Upon delivery |
|
|
||||||
|
|
||||||
## 4. Terms and Conditions
|
|
||||||
**Important:** All parties must agree to these terms.
|
|
||||||
|
|
||||||
> This is a blockquote for important notices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*For questions, contact [support@example.com](mailto:support@example.com)*"></textarea>
|
|
||||||
<div class="form-text">
|
|
||||||
<strong>Markdown Support:</strong> You can use markdown formatting including headers
|
|
||||||
(#), lists (-), tables (|), bold (**text**), italic (*text*), links, and more.
|
|
||||||
<a href="/editor" target="_blank">Open Markdown Editor</a> for a live preview.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="effective_date" class="form-label">Effective Date</label>
|
<label for="effective_date" class="form-label">Effective Date</label>
|
||||||
<input type="date" class="form-control" id="effective_date" name="effective_date">
|
<input type="date" class="form-control" id="effective_date" name="effective_date">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="expiration_date" class="form-label">Expiration Date</label>
|
<label for="expiration_date" class="form-label">Expiration Date</label>
|
||||||
<input type="date" class="form-control" id="expiration_date" name="expiration_date">
|
<input type="date" class="form-control" id="expiration_date" name="expiration_date">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
<a href="/contracts" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
<a href="/contracts" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Create Contract</button>
|
<button type="submit" class="btn btn-primary">Create Contract</button>
|
||||||
@@ -101,15 +68,14 @@ Payment will be made according to the following schedule:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">Tips</h5>
|
<h5 class="mb-0">Tips</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>Creating a new contract is just the first step. After creating the contract, you'll be able to:
|
<p>Creating a new contract is just the first step. After creating the contract, you'll be able to:</p>
|
||||||
</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Add signers who need to approve the contract</li>
|
<li>Add signers who need to approve the contract</li>
|
||||||
<li>Edit the contract content</li>
|
<li>Edit the contract content</li>
|
||||||
@@ -119,7 +85,7 @@ Payment will be made according to the following schedule:
|
|||||||
<p>The contract will be in <strong>Draft</strong> status until you send it for signatures.</p>
|
<p>The contract will be in <strong>Draft</strong> status until you send it for signatures.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">Contract Templates</h5>
|
<h5 class="mb-0">Contract Templates</h5>
|
||||||
@@ -127,20 +93,16 @@ Payment will be made according to the following schedule:
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>You can use one of our pre-defined templates to get started quickly:</p>
|
<p>You can use one of our pre-defined templates to get started quickly:</p>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
<button type="button" class="list-group-item list-group-item-action"
|
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('nda')">
|
||||||
onclick="loadTemplate('nda')">
|
|
||||||
Non-Disclosure Agreement
|
Non-Disclosure Agreement
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="list-group-item list-group-item-action"
|
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('service')">
|
||||||
onclick="loadTemplate('service')">
|
|
||||||
Service Agreement
|
Service Agreement
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="list-group-item list-group-item-action"
|
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('employment')">
|
||||||
onclick="loadTemplate('employment')">
|
|
||||||
Employment Contract
|
Employment Contract
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="list-group-item list-group-item-action"
|
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('sla')">
|
||||||
onclick="loadTemplate('sla')">
|
|
||||||
Service Level Agreement
|
Service Level Agreement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,101 +121,19 @@ Payment will be made according to the following schedule:
|
|||||||
let description = '';
|
let description = '';
|
||||||
let content = '';
|
let content = '';
|
||||||
let contractType = '';
|
let contractType = '';
|
||||||
|
|
||||||
switch (type) {
|
switch(type) {
|
||||||
case 'nda':
|
case 'nda':
|
||||||
title = 'Non-Disclosure Agreement';
|
title = 'Non-Disclosure Agreement';
|
||||||
description = 'Standard NDA for protecting confidential information';
|
description = 'Standard NDA for protecting confidential information';
|
||||||
contractType = 'Non-Disclosure Agreement';
|
contractType = 'Non-Disclosure Agreement';
|
||||||
content = `# Non-Disclosure Agreement
|
content = 'This Non-Disclosure Agreement (the "Agreement") is entered into as of [DATE] by and between [PARTY A] and [PARTY B].\n\n1. Definition of Confidential Information\n2. Obligations of Receiving Party\n3. Term\n...';
|
||||||
|
|
||||||
This Non-Disclosure Agreement (the "**Agreement**") is entered into as of **[DATE]** by and between **[PARTY A]** and **[PARTY B]**.
|
|
||||||
|
|
||||||
## 1. Definition of Confidential Information
|
|
||||||
|
|
||||||
"Confidential Information" means any and all information disclosed by either party to the other party, whether orally or in writing, whether or not marked, designated or otherwise identified as "confidential."
|
|
||||||
|
|
||||||
## 2. Obligations of Receiving Party
|
|
||||||
|
|
||||||
The receiving party agrees to:
|
|
||||||
- Hold all Confidential Information in strict confidence
|
|
||||||
- Not disclose any Confidential Information to third parties
|
|
||||||
- Use Confidential Information solely for the purpose of evaluating potential business relationships
|
|
||||||
|
|
||||||
## 3. Term
|
|
||||||
|
|
||||||
This Agreement shall remain in effect for a period of **[DURATION]** years from the date first written above.
|
|
||||||
|
|
||||||
## 4. Return of Materials
|
|
||||||
|
|
||||||
Upon termination of this Agreement, each party shall promptly return all documents and materials containing Confidential Information.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**IN WITNESS WHEREOF**, the parties have executed this Agreement as of the date first written above.
|
|
||||||
|
|
||||||
**[PARTY A]** **[PARTY B]**
|
|
||||||
|
|
||||||
_____________________ _____________________
|
|
||||||
Signature Signature
|
|
||||||
|
|
||||||
_____________________ _____________________
|
|
||||||
Print Name Print Name
|
|
||||||
|
|
||||||
_____________________ _____________________
|
|
||||||
Date Date`;
|
|
||||||
break;
|
break;
|
||||||
case 'service':
|
case 'service':
|
||||||
title = 'Service Agreement';
|
title = 'Service Agreement';
|
||||||
description = 'Agreement for providing professional services';
|
description = 'Agreement for providing professional services';
|
||||||
contractType = 'Service Agreement';
|
contractType = 'Service Agreement';
|
||||||
content = `# Service Agreement
|
content = 'This Service Agreement (the "Agreement") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Services to be Provided\n2. Compensation\n3. Term and Termination\n...';
|
||||||
|
|
||||||
This Service Agreement (the "**Agreement**") is made and entered into as of **[DATE]** by and between **[SERVICE PROVIDER]** and **[CLIENT]**.
|
|
||||||
|
|
||||||
## 1. Services to be Provided
|
|
||||||
|
|
||||||
The Service Provider agrees to provide the following services:
|
|
||||||
|
|
||||||
- **[SERVICE 1]**: Description of service
|
|
||||||
- **[SERVICE 2]**: Description of service
|
|
||||||
- **[SERVICE 3]**: Description of service
|
|
||||||
|
|
||||||
## 2. Compensation
|
|
||||||
|
|
||||||
| Service | Rate | Payment Terms |
|
|
||||||
|---------|------|---------------|
|
|
||||||
| [SERVICE 1] | $[AMOUNT] | [TERMS] |
|
|
||||||
| [SERVICE 2] | $[AMOUNT] | [TERMS] |
|
|
||||||
|
|
||||||
**Total Contract Value**: $[TOTAL_AMOUNT]
|
|
||||||
|
|
||||||
## 3. Payment Schedule
|
|
||||||
|
|
||||||
- **Deposit**: [PERCENTAGE]% upon signing
|
|
||||||
- **Milestone 1**: [PERCENTAGE]% upon [MILESTONE]
|
|
||||||
- **Final Payment**: [PERCENTAGE]% upon completion
|
|
||||||
|
|
||||||
## 4. Term and Termination
|
|
||||||
|
|
||||||
This Agreement shall commence on **[START_DATE]** and shall continue until **[END_DATE]** unless terminated earlier.
|
|
||||||
|
|
||||||
> **Important**: Either party may terminate this agreement with [NUMBER] days written notice.
|
|
||||||
|
|
||||||
## 5. Deliverables
|
|
||||||
|
|
||||||
The Service Provider shall deliver:
|
|
||||||
|
|
||||||
1. [DELIVERABLE 1]
|
|
||||||
2. [DELIVERABLE 2]
|
|
||||||
3. [DELIVERABLE 3]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Service Provider** **Client**
|
|
||||||
|
|
||||||
_____________________ _____________________
|
|
||||||
Signature Signature`;
|
|
||||||
break;
|
break;
|
||||||
case 'employment':
|
case 'employment':
|
||||||
title = 'Employment Contract';
|
title = 'Employment Contract';
|
||||||
@@ -268,19 +148,19 @@ Signature Signature`;
|
|||||||
content = 'This Service Level Agreement (the "SLA") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Service Levels\n2. Performance Metrics\n3. Remedies for Failure\n...';
|
content = 'This Service Level Agreement (the "SLA") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Service Levels\n2. Performance Metrics\n3. Remedies for Failure\n...';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('title').value = title;
|
document.getElementById('title').value = title;
|
||||||
document.getElementById('description').value = description;
|
document.getElementById('description').value = description;
|
||||||
document.getElementById('content').value = content;
|
document.getElementById('content').value = content;
|
||||||
|
|
||||||
// Set the select option
|
// Set the select option
|
||||||
const selectElement = document.getElementById('contract_type');
|
const selectElement = document.getElementById('contract_type');
|
||||||
for (let i = 0; i < selectElement.options.length; i++) {
|
for(let i = 0; i < selectElement.options.length; i++) {
|
||||||
if (selectElement.options[i].text === contractType) {
|
if(selectElement.options[i].text === contractType) {
|
||||||
selectElement.selectedIndex = i;
|
selectElement.selectedIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit Contract{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<nav aria-label="breadcrumb">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/contracts">Contracts Dashboard</a></li>
|
|
||||||
<li class="breadcrumb-item"><a href="/contracts/{{ contract.id }}">{{ contract.title }}</a></li>
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">Edit Contract</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<h1 class="display-5 mb-3">Edit Contract</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Contract Details</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="/contracts/{{ contract.id }}/edit" method="post">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="title" class="form-label">Contract Title <span
|
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="title" name="title" value="{{ contract.title }}"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="contract_type" class="form-label">Contract Type <span
|
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="contract_type" name="contract_type" required>
|
|
||||||
{% for type in contract_types %}
|
|
||||||
<option value="{{ type }}" {% if contract.contract_type==type %}selected{% endif %}>{{
|
|
||||||
type }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description" class="form-label">Description <span
|
|
||||||
class="text-danger">*</span></label>
|
|
||||||
<textarea class="form-control" id="description" name="description" rows="3"
|
|
||||||
required>{{ contract.description }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="content" class="form-label">Contract Content</label>
|
|
||||||
<textarea class="form-control" id="content" name="content"
|
|
||||||
rows="10">{{ contract.terms_and_conditions | default(value='') }}</textarea>
|
|
||||||
<div class="form-text">Edit the contract content as needed.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="effective_date" class="form-label">Effective Date</label>
|
|
||||||
<input type="date" class="form-control" id="effective_date" name="effective_date"
|
|
||||||
value="{% if contract.start_date %}{{ contract.start_date | date(format='%Y-%m-%d') }}{% endif %}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="expiration_date" class="form-label">Expiration Date</label>
|
|
||||||
<input type="date" class="form-control" id="expiration_date" name="expiration_date"
|
|
||||||
value="{% if contract.end_date %}{{ contract.end_date | date(format='%Y-%m-%d') }}{% endif %}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
|
||||||
<button type="submit" class="btn btn-primary">Update Contract</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Contract Info</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>Status:</strong>
|
|
||||||
<span class="badge bg-secondary">{{ contract.status }}</span>
|
|
||||||
</p>
|
|
||||||
<p><strong>Created:</strong> {{ contract.created_at | date(format="%Y-%m-%d %H:%M") }}</p>
|
|
||||||
<p><strong>Last Updated:</strong> {{ contract.updated_at | date(format="%Y-%m-%d %H:%M") }}</p>
|
|
||||||
<p><strong>Version:</strong> {{ contract.current_version }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Edit Notes</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
<strong>Note:</strong> Only contracts in <strong>Draft</strong> status can be edited.
|
|
||||||
Once a contract is sent for signatures, you'll need to create a new revision instead.
|
|
||||||
</div>
|
|
||||||
<p>After updating the contract:</p>
|
|
||||||
<ul>
|
|
||||||
<li>The contract will remain in Draft status</li>
|
|
||||||
<li>You can continue to make changes</li>
|
|
||||||
<li>Add signers when ready</li>
|
|
||||||
<li>Send for signatures when complete</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Quick Actions</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-eye me-1"></i> View Contract
|
|
||||||
</a>
|
|
||||||
<a href="/contracts/{{ contract.id }}/add-signer" class="btn btn-outline-success">
|
|
||||||
<i class="bi bi-person-plus me-1"></i> Add Signer
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-outline-danger"
|
|
||||||
onclick="deleteContract({{ contract.id }}, '{{ contract.title | replace(from="'", to="\\'") }}')">
|
|
||||||
<i class="bi bi-trash me-1"></i> Delete Contract
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
<strong>Warning:</strong> This action cannot be undone!
|
|
||||||
</div>
|
|
||||||
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
|
||||||
<p>This will permanently remove the contract and all its associated data.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
||||||
<i class="bi bi-trash me-1"></i> Delete Contract
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
console.log('Edit contract scripts loading...');
|
|
||||||
|
|
||||||
// Delete function using Bootstrap modal
|
|
||||||
window.deleteContract = function (contractId, contractTitle) {
|
|
||||||
console.log('Delete function called:', contractId, contractTitle);
|
|
||||||
|
|
||||||
// Set the contract title in the modal
|
|
||||||
document.getElementById('contractTitle').textContent = contractTitle;
|
|
||||||
|
|
||||||
// Store the contract ID for later use
|
|
||||||
window.currentDeleteContractId = contractId;
|
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
||||||
deleteModal.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('deleteContract function defined:', typeof window.deleteContract);
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Handle confirm delete button click
|
|
||||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
|
||||||
console.log('User confirmed deletion, submitting form...');
|
|
||||||
|
|
||||||
// Create and submit form
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
|
||||||
form.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
const textarea = document.getElementById('content');
|
|
||||||
if (textarea) {
|
|
||||||
textarea.addEventListener('input', function () {
|
|
||||||
this.style.height = 'auto';
|
|
||||||
this.style.height = this.scrollHeight + 'px';
|
|
||||||
});
|
|
||||||
// Initial resize
|
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -11,108 +11,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if stats.total_contracts > 0 %}
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-primary h-100">
|
<div class="card text-white bg-primary h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Total</h5>
|
<h5 class="card-title">Total</h5>
|
||||||
<h3 class="mb-0">{{ stats.total_contracts }}</h3>
|
<p class="display-4">{{ stats.total_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-secondary h-100">
|
<div class="card text-white bg-secondary h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Draft</h5>
|
<h5 class="card-title">Draft</h5>
|
||||||
<h3 class="mb-0">{{ stats.draft_contracts }}</h3>
|
<p class="display-4">{{ stats.draft_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-warning h-100">
|
<div class="card text-white bg-warning h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Pending</h5>
|
<h5 class="card-title">Pending</h5>
|
||||||
<h3 class="mb-0">{{ stats.pending_signature_contracts }}</h3>
|
<p class="display-4">{{ stats.pending_signature_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-success h-100">
|
<div class="card text-white bg-success h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Signed</h5>
|
<h5 class="card-title">Signed</h5>
|
||||||
<h3 class="mb-0">{{ stats.signed_contracts }}</h3>
|
<p class="display-4">{{ stats.signed_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-danger h-100">
|
<div class="card text-white bg-danger h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Expired</h5>
|
<h5 class="card-title">Expired</h5>
|
||||||
<h3 class="mb-0">{{ stats.expired_contracts }}</h3>
|
<p class="display-4">{{ stats.expired_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mb-3">
|
<div class="col-md-2 mb-3">
|
||||||
<div class="card text-white bg-dark h-100">
|
<div class="card text-white bg-dark h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">Cancelled</h5>
|
<h5 class="card-title">Cancelled</h5>
|
||||||
<h3 class="mb-0">{{ stats.cancelled_contracts }}</h3>
|
<p class="display-4">{{ stats.cancelled_contracts }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<!-- Empty State Welcome Message -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 bg-light">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-muted mb-3">Welcome to Contract Management</h3>
|
|
||||||
<p class="lead text-muted mb-4">
|
|
||||||
You haven't created any contracts yet. Get started by creating your first contract to manage
|
|
||||||
legal agreements and track signatures.
|
|
||||||
</p>
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100 border-primary">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<i class="bi bi-plus-circle text-primary fs-2 mb-2"></i>
|
|
||||||
<h6 class="card-title">Create Contract</h6>
|
|
||||||
<p class="card-text small text-muted">Start with a new legal agreement</p>
|
|
||||||
<a href="/contracts/create" class="btn btn-primary btn-sm">Get Started</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100 border-success">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<i class="bi bi-question-circle text-success fs-2 mb-2"></i>
|
|
||||||
<h6 class="card-title">Need Help?</h6>
|
|
||||||
<p class="card-text small text-muted">Learn how to use the system</p>
|
|
||||||
<button class="btn btn-outline-success btn-sm"
|
|
||||||
onclick="showHelpModal()">Learn More</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if stats.total_contracts > 0 %}
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -136,7 +86,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Pending Signature Contracts -->
|
<!-- Pending Signature Contracts -->
|
||||||
{% if pending_signature_contracts and pending_signature_contracts | length > 0 %}
|
{% if pending_signature_contracts and pending_signature_contracts | length > 0 %}
|
||||||
@@ -219,8 +168,7 @@
|
|||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/contracts/{{ contract.id }}/edit"
|
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||||
class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,115 +183,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent Activity Section -->
|
|
||||||
{% if recent_activities and recent_activities | length > 0 %}
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Recent Activity</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{% for activity in recent_activities %}
|
|
||||||
<div class="list-group-item border-start-0 border-end-0 py-3">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="me-3">
|
|
||||||
<i class="{{ activity.icon }} fs-5"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<strong>{{ activity.user }}</strong>
|
|
||||||
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M")
|
|
||||||
}}</small>
|
|
||||||
</div>
|
|
||||||
<p class="mb-1">{{ activity.description }}</p>
|
|
||||||
<small class="text-muted">{{ activity.title }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-center">
|
|
||||||
<a href="/contracts/activities" class="btn btn-sm btn-outline-info">See More Activities</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help Modal -->
|
|
||||||
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="helpModalLabel">
|
|
||||||
<i class="bi bi-question-circle me-2"></i>Getting Started with Contract Management
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6><i class="bi bi-1-circle text-primary me-2"></i>Create Your First Contract</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
Start by creating a new contract. Choose from various contract types like Service
|
|
||||||
Agreements, NDAs, or Employment Contracts.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6><i class="bi bi-2-circle text-primary me-2"></i>Add Contract Details</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
Fill in the contract title, description, and terms. You can use Markdown formatting for rich
|
|
||||||
text content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6><i class="bi bi-3-circle text-primary me-2"></i>Add Signers</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
Add people who need to sign the contract. Each signer will receive a unique signing link.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6><i class="bi bi-4-circle text-success me-2"></i>Send for Signatures</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
Once your contract is ready, send it for signatures. Signers can review and sign digitally.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6><i class="bi bi-5-circle text-success me-2"></i>Track Progress</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
Monitor signature progress, send reminders, and view signed documents from the dashboard.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6><i class="bi bi-6-circle text-success me-2"></i>Manage Contracts</h6>
|
|
||||||
<p class="small text-muted mb-3">
|
|
||||||
View all contracts, filter by status, and manage the complete contract lifecycle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-info mt-3">
|
|
||||||
<i class="bi bi-lightbulb me-2"></i>
|
|
||||||
<strong>Tip:</strong> You can save contracts as drafts and come back to edit them later before
|
|
||||||
sending for signatures.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
<a href="/contracts/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i> Create My First Contract
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
function showHelpModal() {
|
|
||||||
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
|
|
||||||
helpModal.show();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -13,10 +13,7 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<h1 class="display-5 mb-0">My Contracts</h1>
|
||||||
<h1 class="display-5 mb-0">My Contracts</h1>
|
|
||||||
<p class="text-muted mb-0">Manage and track your personal contracts</p>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/contracts/create" class="btn btn-primary">
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||||
@@ -26,136 +23,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card bg-primary text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h6 class="card-title">Total Contracts</h6>
|
|
||||||
<h3 class="mb-0">{{ contracts|length }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="bi bi-file-earmark-text fs-2"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card bg-warning text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h6 class="card-title">Pending Signatures</h6>
|
|
||||||
<h3 class="mb-0" id="pending-count">0</h3>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="bi bi-clock fs-2"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card bg-success text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h6 class="card-title">Signed</h6>
|
|
||||||
<h3 class="mb-0" id="signed-count">0</h3>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="bi bi-check-circle fs-2"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card bg-secondary text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h6 class="card-title">Drafts</h6>
|
|
||||||
<h3 class="mb-0" id="draft-count">0</h3>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="bi bi-pencil fs-2"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">Filters</h5>
|
||||||
<i class="bi bi-funnel me-1"></i> Filters & Search
|
|
||||||
</h5>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
|
|
||||||
data-bs-target="#filtersCollapse" aria-expanded="false" aria-controls="filtersCollapse">
|
|
||||||
<i class="bi bi-chevron-down"></i> Toggle Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse show" id="filtersCollapse">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<form action="/contracts/my-contracts" method="get" class="row g-3">
|
||||||
<form action="/contracts/my-contracts" method="get" class="row g-3">
|
<div class="col-md-4">
|
||||||
<div class="col-md-3">
|
<label for="status" class="form-label">Status</label>
|
||||||
<label for="status" class="form-label">Status</label>
|
<select class="form-select" id="status" name="status">
|
||||||
<select class="form-select" id="status" name="status">
|
<option value="">All Statuses</option>
|
||||||
<option value="">All Statuses</option>
|
<option value="Draft">Draft</option>
|
||||||
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>
|
<option value="PendingSignatures">Pending Signatures</option>
|
||||||
Draft</option>
|
<option value="Signed">Signed</option>
|
||||||
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
<option value="Expired">Expired</option>
|
||||||
%}selected{% endif %}>Pending Signatures</option>
|
<option value="Cancelled">Cancelled</option>
|
||||||
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
</select>
|
||||||
Signed</option>
|
</div>
|
||||||
<option value="Active" {% if current_status_filter=="Active" %}selected{% endif %}>
|
<div class="col-md-4">
|
||||||
Active</option>
|
<label for="type" class="form-label">Contract Type</label>
|
||||||
<option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif
|
<select class="form-select" id="type" name="type">
|
||||||
%}>Expired</option>
|
<option value="">All Types</option>
|
||||||
<option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{%
|
<option value="Service">Service Agreement</option>
|
||||||
endif %}>Cancelled</option>
|
<option value="Employment">Employment Contract</option>
|
||||||
</select>
|
<option value="NDA">Non-Disclosure Agreement</option>
|
||||||
</div>
|
<option value="SLA">Service Level Agreement</option>
|
||||||
<div class="col-md-3">
|
<option value="Other">Other</option>
|
||||||
<label for="type" class="form-label">Contract Type</label>
|
</select>
|
||||||
<select class="form-select" id="type" name="type">
|
</div>
|
||||||
<option value="">All Types</option>
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
||||||
%}selected{% endif %}>Service Agreement</option>
|
</div>
|
||||||
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
</form>
|
||||||
%}selected{% endif %}>Employment Contract</option>
|
|
||||||
<option value="Non-Disclosure Agreement" {% if
|
|
||||||
current_type_filter=="Non-Disclosure Agreement" %}selected{% endif %}>
|
|
||||||
Non-Disclosure Agreement</option>
|
|
||||||
<option value="Service Level Agreement" {% if
|
|
||||||
current_type_filter=="Service Level Agreement" %}selected{% endif %}>Service
|
|
||||||
Level Agreement</option>
|
|
||||||
<option value="Partnership Agreement" {% if
|
|
||||||
current_type_filter=="Partnership Agreement" %}selected{% endif %}>Partnership
|
|
||||||
Agreement</option>
|
|
||||||
<option value="Other" {% if current_type_filter=="Other" %}selected{% endif %}>Other
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label for="search" class="form-label">Search</label>
|
|
||||||
<input type="text" class="form-control" id="search" name="search"
|
|
||||||
placeholder="Search by title or description"
|
|
||||||
value="{{ current_search_filter | default(value='') }}">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 d-flex align-items-end">
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,122 +67,48 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">My Contracts</h5>
|
||||||
<i class="bi bi-file-earmark-text me-1"></i> My Contracts
|
|
||||||
{% if contracts and contracts | length > 0 %}
|
|
||||||
<span class="badge bg-primary ms-2">{{ contracts|length }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</h5>
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="/contracts/statistics" class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-graph-up me-1"></i> Statistics
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if contracts and contracts | length > 0 %}
|
{% if contracts and contracts | length > 0 %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover">
|
||||||
<thead class="table-light">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th>Contract Title</th>
|
||||||
<div class="d-flex align-items-center">
|
<th>Type</th>
|
||||||
Contract Title
|
<th>Status</th>
|
||||||
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
|
<th>Signers</th>
|
||||||
onclick="sortTable(0)"></i>
|
<th>Created</th>
|
||||||
</div>
|
<th>Updated</th>
|
||||||
</th>
|
<th>Actions</th>
|
||||||
<th scope="col">Type</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Progress</th>
|
|
||||||
<th scope="col">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
Created
|
|
||||||
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
|
|
||||||
onclick="sortTable(4)"></i>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col">Last Updated</th>
|
|
||||||
<th scope="col" class="text-center">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for contract in contracts %}
|
{% for contract in contracts %}
|
||||||
<tr
|
<tr>
|
||||||
class="{% if contract.status == 'Expired' %}table-danger{% elif contract.status == 'PendingSignatures' %}table-warning{% elif contract.status == 'Signed' %}table-success{% endif %}">
|
|
||||||
<td>
|
<td>
|
||||||
<div>
|
<a href="/contracts/{{ contract.id }}">{{ contract.title }}</a>
|
||||||
<a href="/contracts/{{ contract.id }}" class="fw-bold text-decoration-none">
|
|
||||||
{{ contract.title }}
|
|
||||||
</a>
|
|
||||||
{% if contract.description %}
|
|
||||||
<div class="small text-muted">{{ contract.description }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ contract.contract_type }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-light text-dark">{{ contract.contract_type }}</span>
|
<span class="badge {% if contract.status == 'Signed' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% else %}bg-dark{% endif %}">
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge {% if contract.status == 'Signed' or contract.status == 'Active' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% elif contract.status == 'Cancelled' %}bg-dark{% else %}bg-info{% endif %}">
|
|
||||||
{% if contract.status == 'PendingSignatures' %}
|
|
||||||
<i class="bi bi-clock me-1"></i>
|
|
||||||
{% elif contract.status == 'Signed' %}
|
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
|
||||||
{% elif contract.status == 'Draft' %}
|
|
||||||
<i class="bi bi-pencil me-1"></i>
|
|
||||||
{% elif contract.status == 'Expired' %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
{% endif %}
|
|
||||||
{{ contract.status }}
|
{{ contract.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ contract.signed_signers }}/{{ contract.signers|length }}</td>
|
||||||
|
<td>{{ contract.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>{{ contract.updated_at | date(format="%Y-%m-%d") }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if contract.signers|length > 0 %}
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="progress me-2" style="width: 60px; height: 8px;">
|
|
||||||
<div class="progress-bar bg-success" role="progressbar"
|
|
||||||
style="width: 0%" data-contract-id="{{ contract.id }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">{{ contract.signed_signers }}/{{
|
|
||||||
contract.signers|length }}</small>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted small">No signers</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="small">
|
|
||||||
{{ contract.created_at | date(format="%b %d, %Y") }}
|
|
||||||
<div class="text-muted">{{ contract.created_at | date(format="%I:%M %p") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="small">
|
|
||||||
{{ contract.updated_at | date(format="%b %d, %Y") }}
|
|
||||||
<div class="text-muted">{{ contract.updated_at | date(format="%I:%M %p") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary"
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||||
title="View Details">
|
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if contract.status == 'Draft' %}
|
{% if contract.status == 'Draft' %}
|
||||||
<a href="/contracts/{{ contract.id }}/edit"
|
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||||
class="btn btn-sm btn-outline-secondary" title="Edit Contract">
|
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Delete Contract"
|
|
||||||
onclick="deleteContract('{{ contract.id }}', '{{ contract.title }}')">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -291,20 +119,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<div class="mb-4">
|
<i class="bi bi-file-earmark-text fs-1 text-muted"></i>
|
||||||
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
|
<p class="mt-3 text-muted">You don't have any contracts yet</p>
|
||||||
</div>
|
<a href="/contracts/create" class="btn btn-primary mt-2">
|
||||||
<h4 class="text-muted mb-3">No Contracts Found</h4>
|
<i class="bi bi-plus-circle me-1"></i> Create Your First Contract
|
||||||
<p class="text-muted mb-4">You haven't created any contracts yet. Get started by creating your
|
</a>
|
||||||
first contract.</p>
|
|
||||||
<div class="d-flex justify-content-center gap-2">
|
|
||||||
<a href="/contracts/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i> Create Your First Contract
|
|
||||||
</a>
|
|
||||||
<a href="/contracts" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i> Back to Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -312,166 +131,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
<strong>Warning:</strong> This action cannot be undone!
|
|
||||||
</div>
|
|
||||||
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
|
||||||
<p>This will permanently remove:</p>
|
|
||||||
<ul>
|
|
||||||
<li>The contract document and all its content</li>
|
|
||||||
<li>All signers and their signatures</li>
|
|
||||||
<li>All revisions and history</li>
|
|
||||||
<li>Any associated files or attachments</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
||||||
<i class="bi bi-trash me-1"></i> Delete Contract
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
console.log('My Contracts page scripts loading...');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Delete contract functionality using Bootstrap modal
|
|
||||||
window.deleteContract = function (contractId, contractTitle) {
|
|
||||||
console.log('Delete contract called:', contractId, contractTitle);
|
|
||||||
|
|
||||||
// Set the contract title in the modal
|
|
||||||
document.getElementById('contractTitle').textContent = contractTitle;
|
|
||||||
|
|
||||||
// Store the contract ID for later use
|
|
||||||
window.currentDeleteContractId = contractId;
|
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
||||||
deleteModal.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simple table sorting functionality
|
|
||||||
window.sortTable = function (columnIndex) {
|
|
||||||
console.log('Sorting table by column:', columnIndex);
|
|
||||||
const table = document.querySelector('.table tbody');
|
|
||||||
const rows = Array.from(table.querySelectorAll('tr'));
|
|
||||||
|
|
||||||
// Toggle sort direction
|
|
||||||
const isAscending = table.dataset.sortDirection !== 'asc';
|
|
||||||
table.dataset.sortDirection = isAscending ? 'asc' : 'desc';
|
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const aText = a.cells[columnIndex].textContent.trim();
|
|
||||||
const bText = b.cells[columnIndex].textContent.trim();
|
|
||||||
|
|
||||||
// Handle date sorting for created/updated columns
|
|
||||||
if (columnIndex === 4 || columnIndex === 5) {
|
|
||||||
const aDate = new Date(aText);
|
|
||||||
const bDate = new Date(bText);
|
|
||||||
return isAscending ? aDate - bDate : bDate - aDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle text sorting
|
|
||||||
return isAscending ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-append sorted rows
|
|
||||||
rows.forEach(row => table.appendChild(row));
|
|
||||||
|
|
||||||
// Update sort indicators
|
|
||||||
document.querySelectorAll('.bi-arrow-down-up').forEach(icon => {
|
|
||||||
icon.className = 'bi bi-arrow-down-up ms-1 text-muted';
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentIcon = document.querySelectorAll('.bi-arrow-down-up')[columnIndex === 4 ? 1 : 0];
|
|
||||||
if (currentIcon) {
|
|
||||||
currentIcon.className = `bi ${isAscending ? 'bi-arrow-up' : 'bi-arrow-down'} ms-1 text-primary`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate statistics and update progress bars
|
|
||||||
function updateStatistics() {
|
|
||||||
const rows = document.querySelectorAll('.table tbody tr');
|
|
||||||
let totalContracts = rows.length;
|
|
||||||
let pendingCount = 0;
|
|
||||||
let signedCount = 0;
|
|
||||||
let draftCount = 0;
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
|
||||||
const statusCell = row.cells[2];
|
|
||||||
const statusText = statusCell.textContent.trim();
|
|
||||||
|
|
||||||
if (statusText.includes('PendingSignatures') || statusText.includes('Pending')) {
|
|
||||||
pendingCount++;
|
|
||||||
} else if (statusText.includes('Signed')) {
|
|
||||||
signedCount++;
|
|
||||||
} else if (statusText.includes('Draft')) {
|
|
||||||
draftCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress bars
|
|
||||||
const progressBar = row.querySelector('.progress-bar');
|
|
||||||
if (progressBar) {
|
|
||||||
const signersText = row.cells[3].textContent.trim();
|
|
||||||
if (signersText !== 'No signers') {
|
|
||||||
const [signed, total] = signersText.split('/').map(n => parseInt(n));
|
|
||||||
const percentage = total > 0 ? Math.round((signed / total) * 100) : 0;
|
|
||||||
progressBar.style.width = percentage + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update statistics cards
|
|
||||||
document.getElementById('pending-count').textContent = pendingCount;
|
|
||||||
document.getElementById('signed-count').textContent = signedCount;
|
|
||||||
document.getElementById('draft-count').textContent = draftCount;
|
|
||||||
|
|
||||||
// Update total count badge
|
|
||||||
const badge = document.querySelector('.badge.bg-primary');
|
|
||||||
if (badge) {
|
|
||||||
badge.textContent = totalContracts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Calculate initial statistics
|
|
||||||
updateStatistics();
|
|
||||||
|
|
||||||
// Handle confirm delete button click
|
|
||||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
|
||||||
console.log('User confirmed deletion, submitting form...');
|
|
||||||
|
|
||||||
// Create and submit form
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
|
||||||
form.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('My Contracts page scripts loaded successfully');
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ contract.title }} - Signed Contract{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<!-- Action Bar (hidden in print) -->
|
|
||||||
<div class="row mb-4 no-print">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="h4 mb-1">
|
|
||||||
<i class="bi bi-file-earmark-check text-success me-2"></i>
|
|
||||||
Signed Contract Document
|
|
||||||
</h1>
|
|
||||||
<p class="text-muted mb-0">Official digitally signed copy</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-end">
|
|
||||||
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i> Back to Contract
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-primary" onclick="window.print()">
|
|
||||||
<i class="bi bi-printer me-1"></i> Print Document
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" id="copyContentBtn"
|
|
||||||
title="Copy contract content to clipboard">
|
|
||||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
|
||||||
<div class="spinner-border spinner-border-sm d-none" id="copySpinner" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signature Verification Banner -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-success border-success">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-md-1 text-center">
|
|
||||||
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-11">
|
|
||||||
<h5 class="alert-heading mb-2">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>Digitally Signed Document
|
|
||||||
</h5>
|
|
||||||
<p class="mb-1">
|
|
||||||
<strong>{{ signer.name }}</strong> ({{ signer.email }}) digitally signed this contract on
|
|
||||||
<strong>{{ signer.signed_at }}</strong>
|
|
||||||
</p>
|
|
||||||
{% if signer.comments %}
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>Signer Comments:</strong> {{ signer.comments }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contract Information -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>Contract Information
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<p><strong>Contract ID:</strong> {{ contract.contract_id }}</p>
|
|
||||||
<p><strong>Title:</strong> {{ contract.title }}</p>
|
|
||||||
<p><strong>Type:</strong> {{ contract.contract_type }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<p><strong>Status:</strong>
|
|
||||||
<span class="badge bg-success">{{ contract.status }}</span>
|
|
||||||
</p>
|
|
||||||
<p><strong>Created:</strong> {{ contract.created_at }}</p>
|
|
||||||
<p><strong>Version:</strong> {{ contract.current_version }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if contract.description %}
|
|
||||||
<p><strong>Description:</strong> {{ contract.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-person-check me-2"></i>Signer Information
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>Name:</strong> {{ signer.name }}</p>
|
|
||||||
<p><strong>Email:</strong> {{ signer.email }}</p>
|
|
||||||
<p><strong>Status:</strong>
|
|
||||||
<span class="badge bg-success">{{ signer.status }}</span>
|
|
||||||
</p>
|
|
||||||
<p><strong>Signed At:</strong> {{ signer.signed_at }}</p>
|
|
||||||
{% if signer.comments %}
|
|
||||||
<p><strong>Comments:</strong></p>
|
|
||||||
<div class="bg-light p-2 rounded">
|
|
||||||
{{ signer.comments }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Display Saved Signature -->
|
|
||||||
{% if signer.signature_data %}
|
|
||||||
<div class="mt-3">
|
|
||||||
<p><strong>Digital Signature:</strong></p>
|
|
||||||
<div class="signature-display bg-white border rounded p-3 text-center">
|
|
||||||
<img src="{{ signer.signature_data }}" alt="Digital Signature" class="signature-image" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contract Content -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-file-text me-2"></i>Contract Terms & Conditions
|
|
||||||
</h5>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="copyContentBtn"
|
|
||||||
title="Copy contract content to clipboard">
|
|
||||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
|
||||||
<div class="spinner-border spinner-border-sm d-none" id="copySpinner" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if contract_content_html %}
|
|
||||||
<!-- Hidden element containing raw markdown content for copying -->
|
|
||||||
<div id="rawContractContent" class="d-none">{{ contract.terms_and_conditions }}</div>
|
|
||||||
<div class="contract-content bg-white p-4 border rounded">
|
|
||||||
{{ contract_content_html | safe }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-info text-center py-5">
|
|
||||||
<i class="bi bi-file-text text-muted" style="font-size: 3rem;"></i>
|
|
||||||
<h5 class="mt-3">No Content Available</h5>
|
|
||||||
<p class="text-muted">This contract doesn't have any content.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Digital Signature Footer -->
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h6 class="text-success mb-2">
|
|
||||||
<i class="bi bi-shield-check me-2"></i>Digital Signature Verification
|
|
||||||
</h6>
|
|
||||||
<p class="small text-muted mb-0">
|
|
||||||
This document has been digitally signed by {{ signer.name }} on {{ signer.signed_at }}.
|
|
||||||
The digital signature ensures the authenticity and integrity of this contract.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
/* Print styles */
|
|
||||||
@media print {
|
|
||||||
|
|
||||||
.btn,
|
|
||||||
.card-header .btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
border: 2px solid #28a745 !important;
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid #dee2e6 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-light {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown Content Styles */
|
|
||||||
.contract-content h1,
|
|
||||||
.contract-content h2,
|
|
||||||
.contract-content h3,
|
|
||||||
.contract-content h4,
|
|
||||||
.contract-content h5,
|
|
||||||
.contract-content h6 {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
border-bottom: 2px solid #e9ecef;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
padding-bottom: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content p {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content ul,
|
|
||||||
.contract-content ol {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content table {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content table th,
|
|
||||||
.contract-content table td {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-content table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Signature Display Styles */
|
|
||||||
.signature-display {
|
|
||||||
min-height: 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-image {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 60px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Copy button styles */
|
|
||||||
#copyContentBtn {
|
|
||||||
position: relative;
|
|
||||||
min-width: 40px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#copyContentBtn:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
#copySpinner {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
// Copy contract content functionality
|
|
||||||
const copyContentBtn = document.getElementById('copyContentBtn');
|
|
||||||
const copyIcon = document.getElementById('copyIcon');
|
|
||||||
const copySpinner = document.getElementById('copySpinner');
|
|
||||||
|
|
||||||
if (copyContentBtn) {
|
|
||||||
copyContentBtn.addEventListener('click', async function () {
|
|
||||||
const rawContent = document.getElementById('rawContractContent');
|
|
||||||
|
|
||||||
if (!rawContent) {
|
|
||||||
alert('No contract content available to copy.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
copyIcon.classList.add('d-none');
|
|
||||||
copySpinner.classList.remove('d-none');
|
|
||||||
copyContentBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Copy to clipboard
|
|
||||||
await navigator.clipboard.writeText(rawContent.textContent);
|
|
||||||
|
|
||||||
// Show success state
|
|
||||||
copySpinner.classList.add('d-none');
|
|
||||||
copyIcon.classList.remove('d-none');
|
|
||||||
copyIcon.className = 'bi bi-check-circle text-success';
|
|
||||||
|
|
||||||
// Initialize tooltip
|
|
||||||
const tooltip = new bootstrap.Tooltip(copyContentBtn, {
|
|
||||||
title: 'Contract content copied to clipboard!',
|
|
||||||
placement: 'top',
|
|
||||||
trigger: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show tooltip
|
|
||||||
tooltip.show();
|
|
||||||
|
|
||||||
// Hide tooltip and reset icon after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
tooltip.hide();
|
|
||||||
copyIcon.className = 'bi bi-clipboard';
|
|
||||||
copyContentBtn.disabled = false;
|
|
||||||
|
|
||||||
// Dispose tooltip to prevent memory leaks
|
|
||||||
setTimeout(() => {
|
|
||||||
tooltip.dispose();
|
|
||||||
}, 300);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy content: ', err);
|
|
||||||
|
|
||||||
// Show error state
|
|
||||||
copySpinner.classList.add('d-none');
|
|
||||||
copyIcon.classList.remove('d-none');
|
|
||||||
copyIcon.className = 'bi bi-x-circle text-danger';
|
|
||||||
|
|
||||||
alert('Failed to copy content to clipboard. Please try again.');
|
|
||||||
|
|
||||||
// Reset icon after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
copyIcon.className = 'bi bi-clipboard';
|
|
||||||
copyContentBtn.disabled = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Page Not Found - Zanzibar Digital Freezone</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-light">
|
|
||||||
<div class="container-fluid min-vh-100 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8 col-lg-6">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<!-- 404 Icon -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-exclamation-triangle display-1 text-warning"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Code -->
|
|
||||||
<h1 class="display-1 fw-bold text-muted">404</h1>
|
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<h2 class="mb-3">{% if error_title %}{{ error_title }}{% else %}Page Not Found{% endif %}</h2>
|
|
||||||
<p class="lead text-muted mb-4">
|
|
||||||
{% if error_message %}{{ error_message }}{% else %}The page you're looking for doesn't exist
|
|
||||||
or has
|
|
||||||
been moved.{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Suggestions -->
|
|
||||||
<div class="card bg-light border-0 mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">What can you do?</h6>
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-arrow-left text-primary me-2"></i>
|
|
||||||
Go back to the previous page
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-house text-primary me-2"></i>
|
|
||||||
Visit our homepage
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-search text-primary me-2"></i>
|
|
||||||
Check the URL for typos
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-arrow-clockwise text-primary me-2"></i>
|
|
||||||
Try refreshing the page
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
|
|
||||||
<button onclick="history.back()" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i> Go Back
|
|
||||||
</button>
|
|
||||||
<a href="/" class="btn btn-primary">
|
|
||||||
<i class="bi bi-house me-1"></i> Go Home
|
|
||||||
</a>
|
|
||||||
{% if return_url %}
|
|
||||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-return-left me-1"></i> {% if return_text %}{{ return_text }}{%
|
|
||||||
else
|
|
||||||
%}Return{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Contact Support -->
|
|
||||||
<div class="mt-5 pt-4 border-top">
|
|
||||||
<p class="text-muted small">
|
|
||||||
Still having trouble?
|
|
||||||
<a href="/support" class="text-decoration-none">Contact Support</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Auto-redirect after 10 seconds if no user interaction
|
|
||||||
let redirectTimer;
|
|
||||||
let countdown = 10;
|
|
||||||
|
|
||||||
function startAutoRedirect() {
|
|
||||||
redirectTimer = setInterval(() => {
|
|
||||||
countdown--;
|
|
||||||
if (countdown <= 0) {
|
|
||||||
clearInterval(redirectTimer);
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel auto-redirect on any user interaction
|
|
||||||
function cancelAutoRedirect() {
|
|
||||||
if (redirectTimer) {
|
|
||||||
clearInterval(redirectTimer);
|
|
||||||
redirectTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start auto-redirect after 5 seconds of no interaction
|
|
||||||
setTimeout(startAutoRedirect, 5000);
|
|
||||||
|
|
||||||
// Cancel auto-redirect on mouse movement, clicks, or key presses
|
|
||||||
document.addEventListener('mousemove', cancelAutoRedirect);
|
|
||||||
document.addEventListener('click', cancelAutoRedirect);
|
|
||||||
document.addEventListener('keydown', cancelAutoRedirect);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
411
actix_mvc_app/src/views/flows/index copy.html
Normal file
411
actix_mvc_app/src/views/flows/index copy.html
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Flows Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/flows">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/flows/list">All Workflows</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/flows/my-flows">My Workflows</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/flows/create">Create Workflow</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Alert -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info alert-dismissible fade show">
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
<h5><i class="bi bi-info-circle"></i> About Workflows</h5>
|
||||||
|
<p>The workflow system helps you track and manage business processes across your organization. Create new workflows, monitor progress, and collaborate with team members to ensure smooth operations.</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="/flows/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Main Content -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<!-- Workflows with Pending Actions -->
|
||||||
|
<div class="col-lg-9 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Workflows with Pending Actions</h5>
|
||||||
|
<div>
|
||||||
|
<a href="/flows/pending" class="btn btn-sm btn-outline-primary">View All Pending</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if flows and flows|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Workflow</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Current Step</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flow in flows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0 me-2">
|
||||||
|
<div class="avatar bg-light text-primary rounded p-2">
|
||||||
|
<i class="bi bi-diagram-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/flows/{{ flow.id }}" class="text-decoration-none fw-medium">{{ flow.name }}</a>
|
||||||
|
<div class="small text-muted">ID: {{ flow.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-info">{{ flow.flow_type }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if flow.current_step %}
|
||||||
|
<span class="text-warning fw-medium">{{ flow.current_step.name }}</span>
|
||||||
|
<div class="small text-muted">{{ flow.current_step.description }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No pending step</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ flow.updated_at | date(format="%Y-%m-%d") }}</span>
|
||||||
|
{% if flow.status == 'Stuck' %}
|
||||||
|
<div class="small text-danger">May need attention</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted">Last updated</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0 me-2">
|
||||||
|
<div class="avatar avatar-sm">
|
||||||
|
<img src="{{ flow.owner_avatar or '/static/img/avatar-placeholder.png' }}" alt="{{ flow.owner_name }}" class="rounded-circle" onerror="this.src='/static/img/avatar-placeholder.png'; this.onerror='';">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{{ flow.owner_name }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/flows/{{ flow.id }}#take-action" class="btn btn-sm btn-primary">Take Action</a>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-three-dots"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}">View Details</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}/reassign">Reassign</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}/extend">Extend Deadline</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-check-circle-fill fs-1 text-success mb-3"></i>
|
||||||
|
<h5>No Pending Actions</h5>
|
||||||
|
<p class="text-muted">There are no workflows that require your immediate attention.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline of Recent Flow Steps -->
|
||||||
|
<div class="col-lg-3 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Recent Activity</h5>
|
||||||
|
<a href="/flows/activity" class="btn btn-sm btn-outline-primary">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if flows and flows|length > 0 %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% set count = 0 %}
|
||||||
|
{% for flow in flows %}
|
||||||
|
{% if count < 8 %}
|
||||||
|
{% set count = count + 1 %}
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-3">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="me-3">
|
||||||
|
<div class="timeline-icon bg-light text-{% if flow.status == 'Completed' %}success{% elif flow.status == 'Stuck' %}danger{% else %}primary{% endif %} rounded-circle p-2">
|
||||||
|
<i class="bi bi-{% if flow.status == 'Completed' %}check-circle{% elif flow.status == 'Stuck' %}exclamation-triangle{% else %}arrow-right-circle{% endif %} fs-5"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="fw-medium">
|
||||||
|
{% if flow.status == 'In Progress' %}Working on{% elif flow.status == 'Completed' %}Completed{% elif flow.status == 'Stuck' %}Stuck at{% else %}Updated{% endif %}
|
||||||
|
{% if flow.current_step %} {{ flow.current_step.name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">{{ flow.updated_at | date(format="%H:%M") }}</div>
|
||||||
|
</div>
|
||||||
|
<div>in <a href="/flows/{{ flow.id }}" class="text-decoration-none">{{ flow.name }}</a></div>
|
||||||
|
<div class="text-muted small mt-1">by {{ flow.owner_name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href="/flows/activity" class="btn btn-sm btn-outline-secondary">View Full Activity Log</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-5 text-center">
|
||||||
|
<i class="bi bi-hourglass fs-1 text-muted mb-3"></i>
|
||||||
|
<h6>No Recent Activity</h6>
|
||||||
|
<p class="text-muted small mb-0">Activity will appear here as workflows progress.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Filter Controls -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Workflow Filters</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#filterCollapse" aria-expanded="false" aria-controls="filterCollapse">
|
||||||
|
<i class="bi bi-funnel"></i> Show/Hide Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse show" id="filterCollapse">
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="row g-3" action="/flows" method="get">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="all" selected>All</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="stuck">Stuck</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Freezone filter - for UI demonstration only -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="freezone" class="form-label">Freezone</label>
|
||||||
|
<select class="form-select" id="freezone" name="freezone" disabled>
|
||||||
|
<option value="all" selected>All Freezones</option>
|
||||||
|
<option value="dubai_multi_commodities_centre">DMCC</option>
|
||||||
|
<option value="dubai_international_financial_centre">DIFC</option>
|
||||||
|
<option value="jebel_ali_free_zone">JAFZA</option>
|
||||||
|
<option value="dubai_silicon_oasis">DSO</option>
|
||||||
|
<option value="dubai_internet_city">DIC</option>
|
||||||
|
<option value="dubai_media_city">DMC</option>
|
||||||
|
<option value="abu_dhabi_global_market">ADGM</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Coming soon</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="type" class="form-label">Workflow Type</label>
|
||||||
|
<select class="form-select" id="type" name="type">
|
||||||
|
<option value="all" selected>All</option>
|
||||||
|
<option value="company_registration">Company Incorporation</option>
|
||||||
|
<option value="user_onboarding">KYC Verification</option>
|
||||||
|
<option value="service_activation">License Activation</option>
|
||||||
|
<option value="payment_processing">Payment Processing</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="search" class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search" placeholder="Search workflows...">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 text-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-filter me-1"></i> Apply Filters
|
||||||
|
</button>
|
||||||
|
<a href="/flows" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-1"></i> Clear Filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Active Workflows Section -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Active Workflows (Recent Updates)</h5>
|
||||||
|
<a href="/flows/list" class="btn btn-sm btn-outline-primary">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% set count = 0 %}
|
||||||
|
{% for flow in flows %}
|
||||||
|
{% if count < 3 and flow.status == 'In Progress' %}
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ flow.name }}</h5>
|
||||||
|
<h6 class="card-subtitle mb-2 text-muted">Owner: {{ flow.owner_name }}</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="badge bg-primary">{{ flow.flow_type }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-2">Current stage:
|
||||||
|
{% set current = flow.current_step %}
|
||||||
|
{% if current %}
|
||||||
|
{{ current.name }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No active stage</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="progress mb-2" style="height: 20px;">
|
||||||
|
<div class="progress-bar bg-primary" role="progressbar"
|
||||||
|
style="width: {{ flow.progress_percentage }}%;"
|
||||||
|
aria-valuenow="{{ flow.progress_percentage }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="100">{{ flow.progress_percentage }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<small class="text-muted">Updated: {{ flow.updated_at | date(format="%Y-%m-%d") }}</small>
|
||||||
|
<a href="/flows/{{ flow.id }}" class="btn btn-sm btn-outline-primary">View Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set count = count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if count == 0 %}
|
||||||
|
<div class="col-12 text-center py-4">
|
||||||
|
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
||||||
|
<h5>No active workflows</h5>
|
||||||
|
<p class="text-muted">All workflows are either completed or not yet started.</p>
|
||||||
|
<a href="/flows/create" class="btn btn-primary mt-3">Create New Workflow</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flows Table (Simplified) -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Recent Workflows</h5>
|
||||||
|
<a href="/flows/list" class="btn btn-sm btn-outline-primary">View All Workflows</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if flows|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Workflow Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Assignee</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Initiated</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th>Current Stage</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flow in flows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/flows/{{ flow.id }}">{{ flow.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ flow.flow_type }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge {% if flow.status == 'In Progress' %}bg-primary{% elif flow.status == 'Completed' %}bg-success{% elif flow.status == 'Stuck' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ flow.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ flow.owner_name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress mb-2" style="height: 20px;">
|
||||||
|
<div class="progress-bar {% if flow.status == 'Completed' %}bg-success{% elif flow.status == 'Stuck' %}bg-danger{% else %}bg-primary{% endif %}"
|
||||||
|
role="progressbar" style="width: {{ flow.progress_percentage }}%;"
|
||||||
|
aria-valuenow="{{ flow.progress_percentage }}" aria-valuemin="0"
|
||||||
|
aria-valuemax="100">{{ flow.progress_percentage }}%</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ flow.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>{{ flow.updated_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
{% set current = flow.current_step %}
|
||||||
|
{% if current %}
|
||||||
|
{{ current.name }}
|
||||||
|
{% else %}
|
||||||
|
{% if flow.status == 'Completed' %}
|
||||||
|
<span class="text-success">All stages completed</span>
|
||||||
|
{% elif flow.status == 'Cancelled' %}
|
||||||
|
<span class="text-secondary">Workflow cancelled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No active stage</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/flows/{{ flow.id }}" class="btn btn-sm btn-primary" title="View Details">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if flow.status == 'In Progress' %}
|
||||||
|
<a href="/flows/{{ flow.id }}#advance" class="btn btn-sm btn-success" title="Advance to Next Stage">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-search display-1 text-muted"></i>
|
||||||
|
<p class="lead mt-3">No workflows found matching your criteria.</p>
|
||||||
|
<p class="text-muted">Try adjusting your filters or create a new workflow.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -3,122 +3,188 @@
|
|||||||
{% block title %}Flows Dashboard{% endblock %}
|
{% block title %}Flows Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mb-4">
|
<!-- Navigation Tabs -->
|
||||||
<div class="col-md-8">
|
<div class="row mb-3">
|
||||||
<h1 class="display-5 mb-4">Flows Dashboard</h1>
|
|
||||||
<p class="lead">Track and manage workflow processes across the organization.</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 text-md-end">
|
|
||||||
<a href="/flows/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i> Create New Workflow
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card text-white bg-primary h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Total Flows</h5>
|
|
||||||
<p class="display-4">{{ stats.total_flows }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card text-white bg-success h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">In Progress</h5>
|
|
||||||
<p class="display-4">{{ stats.in_progress_flows }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card text-white bg-danger h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Stuck</h5>
|
|
||||||
<p class="display-4">{{ stats.stuck_flows }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card text-white bg-info h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Completed</h5>
|
|
||||||
<p class="display-4">{{ stats.completed_flows }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter Controls -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<ul class="nav nav-tabs">
|
||||||
<div class="card-header">
|
<li class="nav-item">
|
||||||
<h5 class="mb-0">Filter Workflows</h5>
|
<a class="nav-link active" href="/flows">Dashboard</a>
|
||||||
</div>
|
</li>
|
||||||
<div class="card-body">
|
<li class="nav-item">
|
||||||
<form class="row g-3" action="/flows" method="get">
|
<a class="nav-link" href="/flows/list">All Workflows</a>
|
||||||
<div class="col-md-3">
|
</li>
|
||||||
<label for="status" class="form-label">Status</label>
|
<li class="nav-item">
|
||||||
<select class="form-select" id="status" name="status">
|
<a class="nav-link" href="/flows/my-flows">My Workflows</a>
|
||||||
<option value="all" selected>All</option>
|
</li>
|
||||||
<option value="in_progress">In Progress</option>
|
<li class="nav-item">
|
||||||
<option value="completed">Completed</option>
|
<a class="nav-link" href="/flows/create">Create Workflow</a>
|
||||||
<option value="stuck">Stuck</option>
|
</li>
|
||||||
<option value="cancelled">Cancelled</option>
|
</ul>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Freezone filter - for UI demonstration only -->
|
|
||||||
<div class="col-md-3">
|
<!-- Info Alert -->
|
||||||
<label for="freezone" class="form-label">Freezone</label>
|
<div class="row mb-3">
|
||||||
<select class="form-select" id="freezone" name="freezone" disabled>
|
<div class="col-12">
|
||||||
<option value="all" selected>All Freezones</option>
|
<div class="alert alert-info alert-dismissible fade show">
|
||||||
<option value="dubai_multi_commodities_centre">DMCC</option>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
<option value="dubai_international_financial_centre">DIFC</option>
|
<h5><i class="bi bi-info-circle"></i> About Workflows</h5>
|
||||||
<option value="jebel_ali_free_zone">JAFZA</option>
|
<p>The workflow system helps you track and manage business processes across your organization. Create new workflows, monitor progress, and collaborate with team members to ensure smooth operations.</p>
|
||||||
<option value="dubai_silicon_oasis">DSO</option>
|
<div class="mt-2">
|
||||||
<option value="dubai_internet_city">DIC</option>
|
<a href="/flows/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a>
|
||||||
<option value="dubai_media_city">DMC</option>
|
|
||||||
<option value="abu_dhabi_global_market">ADGM</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">Coming soon</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label for="type" class="form-label">Workflow Type</label>
|
|
||||||
<select class="form-select" id="type" name="type">
|
|
||||||
<option value="all" selected>All</option>
|
|
||||||
<option value="company_registration">Company Incorporation</option>
|
|
||||||
<option value="user_onboarding">KYC Verification</option>
|
|
||||||
<option value="service_activation">License Activation</option>
|
|
||||||
<option value="payment_processing">Payment Processing</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label for="search" class="form-label">Search</label>
|
|
||||||
<input type="text" class="form-control" id="search" name="search" placeholder="Search workflows...">
|
|
||||||
</div>
|
|
||||||
<div class="col-12 text-end">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-filter me-1"></i> Apply Filters
|
|
||||||
</button>
|
|
||||||
<a href="/flows" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle me-1"></i> Clear Filters
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flows Table -->
|
<!-- Dashboard Main Content -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<!-- Workflows with Pending Actions -->
|
||||||
|
<div class="col-lg-9 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Workflows with Pending Actions</h5>
|
||||||
|
<div>
|
||||||
|
<a href="/flows/pending" class="btn btn-sm btn-outline-primary">View All Pending</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if flows and flows|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Workflow</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Current Step</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flow in flows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0 me-2">
|
||||||
|
<div class="avatar bg-light text-primary rounded p-2">
|
||||||
|
<i class="bi bi-diagram-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/flows/{{ flow.id }}" class="text-decoration-none fw-medium">{{ flow.name }}</a>
|
||||||
|
<div class="small text-muted">ID: {{ flow.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-info">{{ flow.flow_type }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if flow.current_step %}
|
||||||
|
<span class="text-warning fw-medium">{{ flow.current_step.name }}</span>
|
||||||
|
<div class="small text-muted">{{ flow.current_step.description }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No pending step</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ flow.updated_at | date(format="%Y-%m-%d") }}</span>
|
||||||
|
{% if flow.status == 'Stuck' %}
|
||||||
|
<div class="small text-danger">May need attention</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted">Last updated</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/flows/{{ flow.id }}#take-action" class="btn btn-sm btn-primary">Take Action</a>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-three-dots"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}">View Details</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}/reassign">Reassign</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/flows/{{ flow.id }}/extend">Extend Deadline</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-check-circle-fill fs-1 text-success mb-3"></i>
|
||||||
|
<h5>No Pending Actions</h5>
|
||||||
|
<p class="text-muted">There are no workflows that require your immediate attention.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline of Recent Flow Steps -->
|
||||||
|
<div class="col-lg-3 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Recent Activity</h5>
|
||||||
|
<a href="/flows/activity" class="btn btn-sm btn-outline-primary">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if flows and flows|length > 0 %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% set count = 0 %}
|
||||||
|
{% for flow in flows %}
|
||||||
|
{% if count < 8 %}
|
||||||
|
{% set count = count + 1 %}
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-3">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="me-3">
|
||||||
|
<div class="timeline-icon bg-light text-{% if flow.status == 'Completed' %}success{% elif flow.status == 'Stuck' %}danger{% else %}primary{% endif %} rounded-circle p-2">
|
||||||
|
<i class="bi bi-{% if flow.status == 'Completed' %}check-circle{% elif flow.status == 'Stuck' %}exclamation-triangle{% else %}arrow-right-circle{% endif %} fs-5"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="fw-medium">
|
||||||
|
{% if flow.status == 'In Progress' %}Working on{% elif flow.status == 'Completed' %}Completed{% elif flow.status == 'Stuck' %}Stuck at{% else %}Updated{% endif %}
|
||||||
|
{% if flow.current_step %} {{ flow.current_step.name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">{{ flow.updated_at | date(format="%H:%M") }}</div>
|
||||||
|
</div>
|
||||||
|
<div>in <a href="/flows/{{ flow.id }}" class="text-decoration-none">{{ flow.name }}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href="/flows/activity" class="btn btn-sm btn-outline-secondary">View Full Activity Log</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-5 text-center">
|
||||||
|
<i class="bi bi-hourglass fs-1 text-muted mb-3"></i>
|
||||||
|
<h6>No Recent Activity</h6>
|
||||||
|
<p class="text-muted small mb-0">Activity will appear here as workflows progress.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flows Table (Simplified) -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">All Workflows</h5>
|
<h5 class="mb-0">Recent Workflows</h5>
|
||||||
|
<a href="/flows/list" class="btn btn-sm btn-outline-primary">View All Workflows</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if flows|length > 0 %}
|
{% if flows|length > 0 %}
|
||||||
@@ -129,7 +195,6 @@
|
|||||||
<th>Workflow Name</th>
|
<th>Workflow Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Assignee</th>
|
|
||||||
<th>Progress</th>
|
<th>Progress</th>
|
||||||
<th>Initiated</th>
|
<th>Initiated</th>
|
||||||
<th>Last Updated</th>
|
<th>Last Updated</th>
|
||||||
@@ -150,7 +215,6 @@
|
|||||||
{{ flow.status }}
|
{{ flow.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ flow.owner_name }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="progress mb-2" style="height: 20px;">
|
<div class="progress mb-2" style="height: 20px;">
|
||||||
<div class="progress-bar {% if flow.status == 'Completed' %}bg-success{% elif flow.status == 'Stuck' %}bg-danger{% else %}bg-primary{% endif %}"
|
<div class="progress-bar {% if flow.status == 'Completed' %}bg-success{% elif flow.status == 'Stuck' %}bg-danger{% else %}bg-primary{% endif %}"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<!-- Governance Page Header -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="h3 mb-1">{{ page_title }}</h1>
|
|
||||||
<p class="text-muted mb-0">{{ page_description }}</p>
|
|
||||||
</div>
|
|
||||||
{% if show_create_button %}
|
|
||||||
<div>
|
|
||||||
<a href="/governance/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create Proposal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!-- Governance Navigation Tabs -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<ul class="nav nav-tabs">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_tab == 'dashboard' %}active{% endif %}" href="/governance">
|
|
||||||
<i class="bi bi-house"></i> Dashboard
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_tab == 'proposals' %}active{% endif %}" href="/governance/proposals">
|
|
||||||
<i class="bi bi-file-text"></i> All Proposals
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_tab == 'create' %}active{% endif %}" href="/governance/create">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create Proposal
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_tab == 'my-votes' %}active{% endif %}" href="/governance/my-votes">
|
|
||||||
<i class="bi bi-check-circle"></i> My Votes
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_tab == 'activities' %}active{% endif %}" href="/governance/activities">
|
|
||||||
<i class="bi bi-activity"></i> All Activities
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}All Governance Activities{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<!-- Header -->
|
|
||||||
{% include "governance/_header.html" %}
|
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
|
||||||
{% include "governance/_tabs.html" %}
|
|
||||||
|
|
||||||
<!-- Activities List -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">
|
|
||||||
<i class="bi bi-activity"></i> Governance Activity History
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if activities %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th width="50">Type</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Proposal</th>
|
|
||||||
<th width="150">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for activity in activities %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<i class="{{ activity.icon }}"></i>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ activity.user }}</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ activity.action }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/governance/proposals/{{ activity.proposal_id }}"
|
|
||||||
class="text-decoration-none">
|
|
||||||
{{ activity.proposal_title }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{ activity.created_at | date(format="%Y-%m-%d %H:%M") }}
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-activity display-1 text-muted"></i>
|
|
||||||
<h4 class="mt-3">No Activities Yet</h4>
|
|
||||||
<p class="text-muted">
|
|
||||||
Governance activities will appear here as users create proposals and cast votes.
|
|
||||||
</p>
|
|
||||||
<a href="/governance/create" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Create First Proposal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activity Statistics -->
|
|
||||||
{% if activities %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ activities | length }}</h5>
|
|
||||||
<p class="card-text text-muted">Total Activities</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">
|
|
||||||
<i class="bi bi-activity text-primary"></i>
|
|
||||||
</h5>
|
|
||||||
<p class="card-text text-muted">Activity Timeline</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">
|
|
||||||
<i class="bi bi-people text-success"></i>
|
|
||||||
</h5>
|
|
||||||
<p class="card-text text-muted">Community Engagement</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -4,74 +4,69 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<!-- Header -->
|
<div class="row mb-4">
|
||||||
{% include "governance/_header.html" %}
|
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
|
||||||
{% include "governance/_tabs.html" %}
|
|
||||||
|
|
||||||
<!-- Info Alert -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-info alert-dismissible fade show">
|
<h1 class="display-5 mb-4">Create Governance Proposal</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<p class="lead">Submit a new proposal for the community to vote on.</p>
|
||||||
<h5><i class="bi bi-info-circle"></i> About Creating Proposals</h5>
|
|
||||||
<p>Creating a proposal is an important step in our community governance process. Well-crafted proposals
|
|
||||||
clearly state the problem, solution, and implementation details. The community will review and vote
|
|
||||||
on your proposal, so be thorough and thoughtful in your submission.</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<a href="/governance/proposal-templates" class="btn btn-sm btn-outline-primary"><i
|
|
||||||
class="bi bi-file-earmark-text"></i> Proposal Templates</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Proposal Form and Guidelines in Flex Layout -->
|
<!-- Navigation Tabs -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<!-- Proposal Form Column -->
|
<div class="col-12">
|
||||||
<div class="col-lg-8">
|
<ul class="nav nav-tabs">
|
||||||
<div class="card h-100">
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance/proposals">All Proposals</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance/my-votes">My Votes</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/governance/create">Create Proposal</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposal Form -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8 mx-auto">
|
||||||
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">New Proposal</h5>
|
<h5 class="mb-0">New Proposal</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form action="/governance/create" method="post" id="proposalForm" novalidate>
|
<form action="/governance/create" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="title" class="form-label">Title</label>
|
<label for="title" class="form-label">Title</label>
|
||||||
<input type="text" class="form-control" id="title" name="title" required minlength="5"
|
<input type="text" class="form-control" id="title" name="title" required
|
||||||
maxlength="100" placeholder="Enter a clear, concise title for your proposal">
|
placeholder="Enter a clear, concise title for your proposal">
|
||||||
<div class="invalid-feedback">Please provide a title (5-100 characters).</div>
|
|
||||||
<div class="form-text">Make it descriptive and specific</div>
|
<div class="form-text">Make it descriptive and specific</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="description" class="form-label">Description</label>
|
<label for="description" class="form-label">Description</label>
|
||||||
<textarea class="form-control" id="description" name="description" rows="8" required
|
<textarea class="form-control" id="description" name="description" rows="6" required
|
||||||
minlength="50" maxlength="5000"
|
placeholder="Provide a detailed description of your proposal..."></textarea>
|
||||||
placeholder="Provide a detailed description of your proposal..."></textarea>
|
|
||||||
<div class="invalid-feedback">Please provide a detailed description (at least 50
|
|
||||||
characters).</div>
|
|
||||||
<div class="form-text">Explain the purpose, benefits, and implementation details</div>
|
<div class="form-text">Explain the purpose, benefits, and implementation details</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="voting_start_date" class="form-label">Voting Start Date</label>
|
<label for="voting_start_date" class="form-label">Voting Start Date</label>
|
||||||
<input type="date" class="form-control" id="voting_start_date" name="voting_start_date">
|
<input type="date" class="form-control" id="voting_start_date" name="voting_start_date">
|
||||||
<div class="invalid-feedback" id="start_date_feedback">Please select a valid start date.
|
|
||||||
</div>
|
|
||||||
<div class="form-text">When should voting begin?</div>
|
<div class="form-text">When should voting begin?</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="voting_end_date" class="form-label">Voting End Date</label>
|
<label for="voting_end_date" class="form-label">Voting End Date</label>
|
||||||
<input type="date" class="form-control" id="voting_end_date" name="voting_end_date">
|
<input type="date" class="form-control" id="voting_end_date" name="voting_end_date">
|
||||||
<div class="invalid-feedback" id="end_date_feedback">End date must be after start date.
|
|
||||||
</div>
|
|
||||||
<div class="form-text">When should voting end?</div>
|
<div class="form-text">When should voting end?</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="draft" name="draft" value="true">
|
<input class="form-check-input" type="checkbox" id="draft" name="draft" value="true">
|
||||||
@@ -80,7 +75,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Submit Proposal</button>
|
<button type="submit" class="btn btn-primary">Submit Proposal</button>
|
||||||
<a href="/governance" class="btn btn-outline-secondary">Cancel</a>
|
<a href="/governance" class="btn btn-outline-secondary">Cancel</a>
|
||||||
@@ -89,10 +84,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Guidelines Column -->
|
|
||||||
<div class="col-lg-4">
|
<!-- Guidelines Card -->
|
||||||
<div class="card bg-light h-100">
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8 mx-auto">
|
||||||
|
<div class="card bg-light">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">Proposal Guidelines</h5>
|
<h5 class="mb-0">Proposal Guidelines</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,111 +116,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const form = document.getElementById('proposalForm');
|
|
||||||
const startDateInput = document.getElementById('voting_start_date');
|
|
||||||
const endDateInput = document.getElementById('voting_end_date');
|
|
||||||
const startDateFeedback = document.getElementById('start_date_feedback');
|
|
||||||
const endDateFeedback = document.getElementById('end_date_feedback');
|
|
||||||
|
|
||||||
// Set default dates
|
|
||||||
const today = new Date();
|
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const nextWeek = new Date(today);
|
|
||||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
|
||||||
|
|
||||||
// Format dates for input fields
|
|
||||||
const formatDate = (date) => {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default values
|
|
||||||
startDateInput.value = formatDate(tomorrow);
|
|
||||||
endDateInput.value = formatDate(nextWeek);
|
|
||||||
|
|
||||||
// Validate dates when they change
|
|
||||||
function validateDates() {
|
|
||||||
const startDate = new Date(startDateInput.value);
|
|
||||||
const endDate = new Date(endDateInput.value);
|
|
||||||
const currentDate = new Date();
|
|
||||||
currentDate.setHours(0, 0, 0, 0); // Reset time to start of day
|
|
||||||
|
|
||||||
let startValid = true;
|
|
||||||
let endValid = true;
|
|
||||||
|
|
||||||
// Validate start date is not in the past
|
|
||||||
if (startDate < currentDate) {
|
|
||||||
startDateInput.classList.add('is-invalid');
|
|
||||||
startDateFeedback.textContent = 'Start date cannot be in the past.';
|
|
||||||
startValid = false;
|
|
||||||
} else {
|
|
||||||
startDateInput.classList.remove('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate end date is after start date
|
|
||||||
if (endDate < startDate) {
|
|
||||||
endDateInput.classList.add('is-invalid');
|
|
||||||
endDateFeedback.textContent = 'End date must be after start date.';
|
|
||||||
endValid = false;
|
|
||||||
} else {
|
|
||||||
endDateInput.classList.remove('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return startValid && endValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate on input
|
|
||||||
startDateInput.addEventListener('change', validateDates);
|
|
||||||
endDateInput.addEventListener('change', validateDates);
|
|
||||||
|
|
||||||
// Form submission validation
|
|
||||||
form.addEventListener('submit', function (event) {
|
|
||||||
let formValid = true;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
const requiredFields = form.querySelectorAll('[required]');
|
|
||||||
requiredFields.forEach(field => {
|
|
||||||
if (!field.value.trim()) {
|
|
||||||
field.classList.add('is-invalid');
|
|
||||||
formValid = false;
|
|
||||||
} else {
|
|
||||||
field.classList.remove('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check minlength if specified
|
|
||||||
if (field.minLength && field.value.length < field.minLength) {
|
|
||||||
field.classList.add('is-invalid');
|
|
||||||
formValid = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate dates
|
|
||||||
const datesValid = validateDates();
|
|
||||||
formValid = formValid && datesValid;
|
|
||||||
|
|
||||||
// If form is not valid, prevent submission
|
|
||||||
if (!formValid) {
|
|
||||||
event.preventDefault();
|
|
||||||
// Scroll to the first invalid element
|
|
||||||
const firstInvalid = form.querySelector('.is-invalid');
|
|
||||||
if (firstInvalid) {
|
|
||||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
firstInvalid.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial validation
|
|
||||||
validateDates();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,192 +3,170 @@
|
|||||||
{% block title %}Governance Dashboard{% endblock %}
|
{% block title %}Governance Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Header -->
|
<!-- Navigation Tabs -->
|
||||||
{% include "governance/_header.html" %}
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
<!-- Navigation Tabs -->
|
<ul class="nav nav-tabs">
|
||||||
{% include "governance/_tabs.html" %}
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/governance">Dashboard</a>
|
||||||
<!-- Info Alert -->
|
</li>
|
||||||
<div class="row mb-2">
|
<li class="nav-item">
|
||||||
<div class="col-12">
|
<a class="nav-link" href="/governance/proposals">All Proposals</a>
|
||||||
<div class="alert alert-info alert-dismissible fade show">
|
</li>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<li class="nav-item">
|
||||||
<h5><i class="bi bi-info-circle"></i> About Governance</h5>
|
<a class="nav-link" href="/governance/my-votes">My Votes</a>
|
||||||
<p>The governance system allows token holders to participate in decision-making processes by voting on
|
</li>
|
||||||
proposals that affect the platform's future. Create proposals, cast votes, and help shape the direction
|
<li class="nav-item">
|
||||||
of our decentralized ecosystem.</p>
|
<a class="nav-link" href="/governance/create">Create Proposal</a>
|
||||||
<div class="mt-2">
|
</li>
|
||||||
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i>
|
</ul>
|
||||||
Read Documentation</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard Main Content -->
|
<!-- Info Alert -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-2">
|
||||||
<!-- Voting Pane for Nearest Deadline Proposal -->
|
<div class="col-12">
|
||||||
<div class="col-lg-8 mb-4 mb-lg-0">
|
<div class="alert alert-info alert-dismissible fade show">
|
||||||
{% if nearest_proposal is defined %}
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
<div class="card h-100">
|
<h5><i class="bi bi-info-circle"></i> About Governance</h5>
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<p>The governance system allows token holders to participate in decision-making processes by voting on proposals that affect the platform's future. Create proposals, cast votes, and help shape the direction of our decentralized ecosystem.</p>
|
||||||
<h5 class="mb-0">Urgent: Voting Closes Soon</h5>
|
<div class="mt-2">
|
||||||
<div>
|
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a>
|
||||||
<span class="badge bg-warning text-dark me-2">Ends: {{ nearest_proposal.vote_end_date |
|
|
||||||
date(format="%Y-%m-%d") }}</span>
|
|
||||||
<a href="/governance/proposals/{{ nearest_proposal.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-primary">View Full Proposal</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
|
|
||||||
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<p>{{ nearest_proposal.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% set yes_percent = 0 %}
|
|
||||||
{% set no_percent = 0 %}
|
|
||||||
{% set abstain_percent = 0 %}
|
|
||||||
{% set total_votes = 0 %}
|
|
||||||
|
|
||||||
{% if nearest_proposal_results is defined %}
|
|
||||||
{% if nearest_proposal_results.total_votes > 0 %}
|
|
||||||
{% set yes_percent = (nearest_proposal_results.yes_count * 100 / nearest_proposal_results.total_votes) |
|
|
||||||
int %}
|
|
||||||
{% set no_percent = (nearest_proposal_results.no_count * 100 / nearest_proposal_results.total_votes) |
|
|
||||||
int %}
|
|
||||||
{% set abstain_percent = (nearest_proposal_results.abstain_count * 100 /
|
|
||||||
nearest_proposal_results.total_votes) |
|
|
||||||
int %}
|
|
||||||
{% endif %}
|
|
||||||
{% set total_votes = nearest_proposal_results.total_votes %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="progress mb-3" style="height: 25px;">
|
|
||||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
|
|
||||||
aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100">{{ yes_percent }}% Yes
|
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
|
|
||||||
aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100">{{ no_percent }}% No
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"
|
|
||||||
aria-valuenow="{{ abstain_percent }}" aria-valuemin="0" aria-valuemax="100">{{ abstain_percent
|
|
||||||
}}% Abstain
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between text-muted small mb-4">
|
|
||||||
<span>{{ total_votes }} votes cast</span>
|
|
||||||
<span>Quorum: {% if total_votes >= 20 %}75% reached{% else %}Not reached{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h5 class="mb-3">Cast Your Vote</h5>
|
|
||||||
<form action="/governance/proposals/{{ nearest_proposal.base_data.id }}/vote" method="post">
|
|
||||||
<div class="mb-3">
|
|
||||||
<input type="text" class="form-control" name="comment"
|
|
||||||
placeholder="Optional comment on your vote" aria-label="Vote comment">
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<button type="submit" name="vote_type" value="Yes" class="btn btn-success">Vote Yes</button>
|
|
||||||
<button type="submit" name="vote_type" value="No" class="btn btn-danger">Vote No</button>
|
|
||||||
<button type="submit" name="vote_type" value="Abstain"
|
|
||||||
class="btn btn-secondary">Abstain</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<div class="card h-100">
|
<!-- Dashboard Main Content -->
|
||||||
<div class="card-body text-center py-5">
|
<div class="row mb-3">
|
||||||
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
<!-- Voting Pane for Nearest Deadline Proposal -->
|
||||||
<h5>No active proposals requiring votes</h5>
|
<div class="col-lg-8 mb-4 mb-lg-0">
|
||||||
<p class="text-muted">When new proposals are created, they will appear here for voting.</p>
|
{% if nearest_proposal is defined %}
|
||||||
<a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
|
<div class="card h-100">
|
||||||
</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
</div>
|
<h5 class="mb-0">Urgent: Voting Closes Soon</h5>
|
||||||
{% endif %}
|
<div>
|
||||||
</div>
|
<span class="badge bg-warning text-dark me-2">Ends: {{ nearest_proposal.voting_ends_at | date(format="%Y-%m-%d") }}</span>
|
||||||
|
<a href="/governance/proposals/{{ nearest_proposal.id }}" class="btn btn-sm btn-outline-primary">View Full Proposal</a>
|
||||||
<!-- Recent Activity Timeline -->
|
</div>
|
||||||
<div class="col-lg-4">
|
</div>
|
||||||
<div class="card h-100">
|
<div class="card-body">
|
||||||
<div class="card-header">
|
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
|
||||||
<h5 class="mb-0">Recent Activity</h5>
|
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
<div class="mb-4">
|
||||||
<div class="list-group list-group-flush">
|
<p>{{ nearest_proposal.description }}</p>
|
||||||
{% for activity in recent_activity %}
|
</div>
|
||||||
<div class="list-group-item border-start-0 border-end-0 py-3">
|
|
||||||
<div class="d-flex">
|
<div class="progress mb-3" style="height: 25px;">
|
||||||
<div class="me-3">
|
<div class="progress-bar bg-success" role="progressbar" style="width: 65%" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">65% Yes</div>
|
||||||
<i class="bi {{ activity.icon }} fs-4"></i>
|
<div class="progress-bar bg-danger" role="progressbar" style="width: 35%" aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">35% No</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between text-muted small mb-4">
|
||||||
|
<span>26 votes cast</span>
|
||||||
|
<span>Quorum: 75% reached</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5 class="mb-3">Cast Your Vote</h5>
|
||||||
|
<form>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder="Optional comment on your vote" aria-label="Vote comment">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="d-flex justify-content-between">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<button type="submit" name="vote" value="yes" class="btn btn-success">Vote Yes</button>
|
||||||
<strong>{{ activity.user }}</strong>
|
<button type="submit" name="vote" value="no" class="btn btn-danger">Vote No</button>
|
||||||
<small class="text-muted">{{ activity.created_at | date(format="%H:%M") }}</small>
|
<button type="submit" name="vote" value="abstain" class="btn btn-secondary">Abstain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
||||||
|
<h5>No active proposals requiring votes</h5>
|
||||||
|
<p class="text-muted">When new proposals are created, they will appear here for voting.</p>
|
||||||
|
<a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity Timeline -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Recent Activity</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for activity in recent_activity %}
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-3">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="me-3">
|
||||||
|
<i class="bi {{ activity.icon }} fs-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">{{ activity.action }} on <a
|
<div>
|
||||||
href="/governance/proposals/{{ activity.proposal_id }}">{{
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
activity.proposal_title }}</a></p>
|
<strong>{{ activity.user }}</strong>
|
||||||
{% if activity.type == "comment" and activity.comment is defined %}
|
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M") }}</small>
|
||||||
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
|
</div>
|
||||||
{% endif %}
|
<p class="mb-1">{{ activity.action }} on <a href="/governance/proposals/{{ activity.proposal_id }}">{{ activity.proposal_title }}</a></p>
|
||||||
</div>
|
{% if activity.type == "comment" and activity.comment is defined %}
|
||||||
</div>
|
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-center">
|
|
||||||
<a href="/governance/activities" class="btn btn-sm btn-outline-info">View All Activities</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Proposals Section -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Active Proposals (Ending Soon)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
{% set count = 0 %}
|
|
||||||
{% for proposal in proposals %}
|
|
||||||
{% if count < 3 %} <div class="col-md-4 mb-3">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ proposal.title }}</h5>
|
|
||||||
<h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6>
|
|
||||||
<p class="card-text">{{ proposal.description | truncate(length=100) }}</p>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span
|
|
||||||
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% else %}bg-secondary{% endif %}">
|
|
||||||
{{ proposal.status }}
|
|
||||||
</span>
|
|
||||||
<a href="/governance/proposals/{{ proposal.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-outline-primary">View Details</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-muted text-center">
|
|
||||||
<span>Voting ends: {{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href="/governance/proposals" class="btn btn-sm btn-outline-info">View All Activity</a>
|
||||||
</div>
|
</div>
|
||||||
{% set count = count + 1 %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Recent Proposals Section -->
|
||||||
{% endblock %}
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Active Proposals (Ending Soon)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% set count = 0 %}
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
{% if count < 3 %}
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ proposal.title }}</h5>
|
||||||
|
<h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6>
|
||||||
|
<p class="card-text">{{ proposal.description | truncate(length=100) }}</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ proposal.status }}
|
||||||
|
</span>
|
||||||
|
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-outline-primary">View Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted text-center">
|
||||||
|
<span>Voting ends: {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set count = count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,121 +3,133 @@
|
|||||||
{% block title %}My Votes - Governance Dashboard{% endblock %}
|
{% block title %}My Votes - Governance Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Header -->
|
<!-- Navigation Tabs -->
|
||||||
{% include "governance/_header.html" %}
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
<!-- Navigation Tabs -->
|
<ul class="nav nav-tabs">
|
||||||
{% include "governance/_tabs.html" %}
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance">Dashboard</a>
|
||||||
<!-- Info Alert -->
|
</li>
|
||||||
<div class="row">
|
<li class="nav-item">
|
||||||
<div class="col-12">
|
<a class="nav-link" href="/governance/proposals">All Proposals</a>
|
||||||
<div class="alert alert-info alert-dismissible fade show">
|
</li>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<li class="nav-item">
|
||||||
<h5><i class="bi bi-info-circle"></i> About Votes</h5>
|
<a class="nav-link active" href="/governance/my-votes">My Votes</a>
|
||||||
<p>Voting is a fundamental right of all token holders in our governance system. Each vote carries weight
|
</li>
|
||||||
proportional to your token holdings, ensuring fair representation. The voting statistics below show the
|
<li class="nav-item">
|
||||||
community's collective decision-making across all proposals.</p>
|
<a class="nav-link" href="/governance/create">Create Proposal</a>
|
||||||
<div class="mt-2">
|
</li>
|
||||||
<a href="/governance/voting-guide" class="btn btn-sm btn-outline-primary"><i
|
</ul>
|
||||||
class="bi bi-check2-square"></i> Voting Guide</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Voting Stats -->
|
<!-- My Votes List -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-12">
|
||||||
<div class="card text-white bg-success h-100">
|
<div class="card">
|
||||||
<div class="card-body text-center">
|
<div class="card-header">
|
||||||
<h5 class="card-title">Yes Votes</h5>
|
<h5 class="mb-0">My Voting History</h5>
|
||||||
<p class="display-4">
|
|
||||||
{{ total_yes_votes }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="card text-white bg-danger h-100">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h5 class="card-title">No Votes</h5>
|
|
||||||
<p class="display-4">
|
|
||||||
{{ total_no_votes }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="card text-white bg-secondary h-100">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h5 class="card-title">Abstain Votes</h5>
|
|
||||||
<p class="display-4">
|
|
||||||
{{ total_abstain_votes }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My Votes List -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">My Voting History</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if votes | length > 0 %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Proposal</th>
|
|
||||||
<th>My Vote</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Voted On</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ proposal.title }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
|
|
||||||
{{ vote.vote_type }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
|
|
||||||
{{ proposal.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="/governance/proposals/{{ proposal.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-primary">View Proposal</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<div class="card-body">
|
||||||
<div class="text-center py-5">
|
{% if votes | length > 0 %}
|
||||||
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
<div class="table-responsive">
|
||||||
<h5>You haven't voted on any proposals yet</h5>
|
<table class="table table-hover">
|
||||||
<p class="text-muted">When you vote on proposals, they will appear here.</p>
|
<thead>
|
||||||
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
|
<tr>
|
||||||
|
<th>Proposal</th>
|
||||||
|
<th>My Vote</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Voted On</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ proposal.title }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ vote.vote_type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
|
||||||
|
{{ proposal.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View Proposal</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
||||||
|
<h5>You haven't voted on any proposals yet</h5>
|
||||||
|
<p class="text-muted">When you vote on proposals, they will appear here.</p>
|
||||||
|
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Voting Stats -->
|
||||||
{% endblock %}
|
{% if votes | length > 0 %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card text-white bg-success h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">Yes Votes</h5>
|
||||||
|
<p class="display-4">
|
||||||
|
{% set yes_count = 0 %}
|
||||||
|
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
|
||||||
|
{% if vote.vote_type == 'Yes' %}
|
||||||
|
{% set yes_count = yes_count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ yes_count }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card text-white bg-danger h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">No Votes</h5>
|
||||||
|
<p class="display-4">
|
||||||
|
{% set no_count = 0 %}
|
||||||
|
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
|
||||||
|
{% if vote.vote_type == 'No' %}
|
||||||
|
{% set no_count = no_count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ no_count }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card text-white bg-secondary h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">Abstain Votes</h5>
|
||||||
|
<p class="display-4">
|
||||||
|
{% set abstain_count = 0 %}
|
||||||
|
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
|
||||||
|
{% if vote.vote_type == 'Abstain' %}
|
||||||
|
{% set abstain_count = abstain_count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ abstain_count }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,45 +2,8 @@
|
|||||||
|
|
||||||
{% block title %}{{ proposal.title }} - Governance Proposal{% endblock %}
|
{% block title %}{{ proposal.title }} - Governance Proposal{% endblock %}
|
||||||
|
|
||||||
{% block styles %}
|
|
||||||
<style>
|
|
||||||
.avatar-circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-text {
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-text:hover {
|
|
||||||
white-space: normal;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<!-- Header -->
|
|
||||||
{% include "governance/_header.html" %}
|
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
|
||||||
{% include "governance/_tabs.html" %}
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
@@ -67,549 +30,160 @@
|
|||||||
|
|
||||||
<!-- Proposal Details -->
|
<!-- Proposal Details -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-lg-8">
|
<div class="col-md-8">
|
||||||
<div class="card h-100 shadow-sm">
|
<div class="card">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header">
|
||||||
<h4 class="mb-0">{{ proposal.title }}</h4>
|
<h4 class="mb-0">{{ proposal.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between mb-3">
|
||||||
<span
|
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %} p-2">
|
||||||
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %} p-2">
|
|
||||||
<i
|
|
||||||
class="bi {% if proposal.status == 'Active' %}bi-check-circle{% elif proposal.status == 'Approved' %}bi-trophy{% elif proposal.status == 'Rejected' %}bi-x-circle{% elif proposal.status == 'Draft' %}bi-pencil{% else %}bi-exclamation-circle{% endif %} me-1"></i>
|
|
||||||
{{ proposal.status }}
|
{{ proposal.status }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted"><i class="bi bi-person me-1"></i>Created by {{ proposal.creator_name
|
<small class="text-muted">Created by {{ proposal.creator_name }} on {{ proposal.created_at | date(format="%Y-%m-%d") }}</small>
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h5><i class="bi bi-file-text me-2"></i>Description</h5>
|
|
||||||
<div class="p-3 bg-light rounded mb-4">{{ proposal.description }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto">
|
|
||||||
<h5><i class="bi bi-calendar-event me-2"></i>Voting Period</h5>
|
|
||||||
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
|
|
||||||
{% if proposal.vote_start_date and proposal.vote_end_date %}
|
|
||||||
<div>
|
|
||||||
<div class="text-muted mb-1">Start Date</div>
|
|
||||||
<div class="fw-bold">{{ proposal.vote_start_date | date(format="%Y-%m-%d") }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<i class="bi bi-arrow-right fs-4 text-muted"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-muted mb-1">End Date</div>
|
|
||||||
<div class="fw-bold">{{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center w-100">Not set</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h5>Description</h5>
|
||||||
|
<p class="mb-4">{{ proposal.description }}</p>
|
||||||
|
|
||||||
|
<h5>Voting Period</h5>
|
||||||
|
<p>
|
||||||
|
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
|
||||||
|
<strong>Start:</strong> {{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} <br>
|
||||||
|
<strong>End:</strong> {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}
|
||||||
|
Not set
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-md-4">
|
||||||
<div class="card mb-4 shadow-sm h-100">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card-header">
|
||||||
<h5 class="mb-0"><i class="bi bi-bar-chart-fill me-2"></i>Voting Dashboard</h5>
|
<h5 class="mb-0">Voting Results</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body">
|
||||||
<!-- Voting Results Section -->
|
<div class="mb-3">
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="border-bottom pb-2 mb-3">Results</h6>
|
|
||||||
|
|
||||||
{% set yes_percent = 0 %}
|
{% set yes_percent = 0 %}
|
||||||
{% set no_percent = 0 %}
|
{% set no_percent = 0 %}
|
||||||
{% set abstain_percent = 0 %}
|
{% set abstain_percent = 0 %}
|
||||||
|
|
||||||
{% if results.total_votes > 0 %}
|
{% if results.total_votes > 0 %}
|
||||||
{% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
|
{% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
|
||||||
{% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
|
{% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
|
||||||
{% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
|
{% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Yes votes -->
|
<p class="mb-1">Yes: {{ results.yes_count }} ({{ yes_percent }}%)</p>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="progress mb-3">
|
||||||
<span class="fw-bold text-success"><i class="bi bi-check-circle-fill me-1"></i> Yes</span>
|
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"></div>
|
||||||
<span class="badge bg-success rounded-pill">{{ results.yes_count }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="progress mb-3" style="height: 12px;">
|
|
||||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
|
<p class="mb-1">No: {{ results.no_count }} ({{ no_percent }}%)</p>
|
||||||
aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100"
|
<div class="progress mb-3">
|
||||||
title="{{ yes_percent }}% of votes"></div>
|
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No votes -->
|
<p class="mb-1">Abstain: {{ results.abstain_count }} ({{ abstain_percent }}%)</p>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="progress mb-3">
|
||||||
<span class="fw-bold text-danger"><i class="bi bi-x-circle-fill me-1"></i> No</span>
|
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"></div>
|
||||||
<span class="badge bg-danger rounded-pill">{{ results.no_count }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress mb-3" style="height: 12px;">
|
|
||||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
|
|
||||||
aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100"
|
|
||||||
title="{{ no_percent }}% of votes"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Abstain votes -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
||||||
<span class="fw-bold text-secondary"><i class="bi bi-dash-circle-fill me-1"></i>
|
|
||||||
Abstain</span>
|
|
||||||
<span class="badge bg-secondary rounded-pill">{{ results.abstain_count }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress mb-3" style="height: 12px;">
|
|
||||||
<div class="progress-bar bg-secondary" role="progressbar"
|
|
||||||
style="width: {{ abstain_percent }}%" aria-valuenow="{{ abstain_percent }}"
|
|
||||||
aria-valuemin="0" aria-valuemax="100" title="{{ abstain_percent }}% of votes"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-center"><strong>Total Votes:</strong> {{ results.total_votes }}</p>
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
|
|
||||||
<div class="text-center">
|
|
||||||
<h4 class="mb-0">{{ results.total_votes }}</h4>
|
|
||||||
<small class="text-muted">Total Votes</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if proposal.status == "Active" %}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="position-relative d-inline-block" style="width: 60px; height: 60px;">
|
|
||||||
<svg width="60" height="60">
|
|
||||||
<circle cx="30" cy="30" r="25" fill="none" stroke="#e9ecef" stroke-width="5">
|
|
||||||
</circle>
|
|
||||||
<circle cx="30" cy="30" r="25" fill="none" stroke="#0d6efd" stroke-width="5"
|
|
||||||
stroke-dasharray="157"
|
|
||||||
stroke-dashoffset="{{ 157 - (157 * yes_percent / 100) }}"
|
|
||||||
transform="rotate(-90 30 30)"></circle>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
class="position-absolute top-50 start-50 translate-middle text-primary fw-bold">
|
|
||||||
{{ yes_percent }}%</div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Approval Rate</small>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vote Form Section -->
|
|
||||||
{% if proposal.status == "Active" and user and user.id %}
|
|
||||||
<div class="mt-auto">
|
|
||||||
<h6 class="border-bottom pb-2 mb-3"><i class="bi bi-check2-square me-2"></i>Cast Your Vote</h6>
|
|
||||||
<form action="/governance/proposals/{{ proposal.base_data.id }}/vote" method="post"
|
|
||||||
id="voteForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex gap-2 mb-2">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="vote_type" id="voteYes"
|
|
||||||
value="Yes" required>
|
|
||||||
<label class="form-check-label text-success" for="voteYes"><i
|
|
||||||
class="bi bi-check-circle-fill me-1"></i>Yes</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="vote_type" id="voteNo"
|
|
||||||
value="No">
|
|
||||||
<label class="form-check-label text-danger" for="voteNo"><i
|
|
||||||
class="bi bi-x-circle-fill me-1"></i>No</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain"
|
|
||||||
value="Abstain">
|
|
||||||
<label class="form-check-label text-secondary" for="voteAbstain"><i
|
|
||||||
class="bi bi-dash-circle-fill me-1"></i>Abstain</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea class="form-control" id="comment" name="comment" rows="2"
|
|
||||||
placeholder="Add your thoughts about this proposal (optional)..."></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-send me-2"></i>Submit
|
|
||||||
Vote</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% elif proposal.status != "Active" %}
|
|
||||||
<div class="mt-auto text-center p-3 bg-light rounded">
|
|
||||||
<i class="bi bi-info-circle fs-4 text-muted"></i>
|
|
||||||
<p class="mb-0 mt-2">Voting is {{ proposal.status | lower }} for this proposal</p>
|
|
||||||
</div>
|
|
||||||
{% elif not user or not user.id %}
|
|
||||||
<div class="mt-auto text-center p-3 bg-light rounded">
|
|
||||||
<i class="bi bi-person-lock fs-4 text-muted"></i>
|
|
||||||
<p class="mb-0 mt-2">You must be logged in to vote</p>
|
|
||||||
<a href="/login" class="btn btn-primary btn-sm mt-2">Login to Vote</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vote Form -->
|
||||||
|
{% if proposal.status == "Active" and user and user.id %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Cast Your Vote</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/governance/proposals/{{ proposal.id }}/vote" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Vote Type</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="vote_type" id="voteYes" value="Yes" checked>
|
||||||
|
<label class="form-check-label" for="voteYes">
|
||||||
|
Yes - I support this proposal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="vote_type" id="voteNo" value="No">
|
||||||
|
<label class="form-check-label" for="voteNo">
|
||||||
|
No - I oppose this proposal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain" value="Abstain">
|
||||||
|
<label class="form-check-label" for="voteAbstain">
|
||||||
|
Abstain - I choose not to vote
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="comment" class="form-label">Comment (Optional)</label>
|
||||||
|
<textarea class="form-control" id="comment" name="comment" rows="3" placeholder="Explain your vote..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Submit Vote</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif not user or not user.id %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p>You must be logged in to vote.</p>
|
||||||
|
<a href="/login" class="btn btn-primary">Login to Vote</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Votes List -->
|
<!-- Votes List -->
|
||||||
<div class="row mt-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<div class="card">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header">
|
||||||
<h5 class="mb-0"><i class="bi bi-list-check me-2"></i>Votes</h5>
|
<h5 class="mb-0">Votes ({{ votes | length }})</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
{% if votes | length > 0 %}
|
||||||
<table class="table table-hover align-middle mb-0">
|
<div class="table-responsive">
|
||||||
<thead class="table-light">
|
<table class="table">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="ps-3">Voter</th>
|
<tr>
|
||||||
<th>Vote</th>
|
<th>Voter</th>
|
||||||
<th>Comment</th>
|
<th>Vote</th>
|
||||||
<th class="text-end pe-3">Date</th>
|
<th>Comment</th>
|
||||||
</tr>
|
<th>Date</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id="votesTableBody">
|
</thead>
|
||||||
{% if votes | length == 0 %}
|
<tbody>
|
||||||
<tr>
|
{% for vote in votes %}
|
||||||
<td colspan="4" class="text-center py-4">
|
<tr>
|
||||||
<div class="py-3">
|
<td>{{ vote.voter_name }}</td>
|
||||||
<i class="bi bi-inbox fs-1 text-muted"></i>
|
<td>
|
||||||
<p class="mt-2 mb-0">No votes have been cast yet</p>
|
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
</div>
|
{{ vote.vote_type }}
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</td>
|
||||||
{% else %}
|
<td>{% if vote.comment %}{{ vote.comment }}{% else %}No comment{% endif %}</td>
|
||||||
{% for vote in votes %}
|
<td>{{ vote.created_at | date(format="%Y-%m-%d %H:%M") }}</td>
|
||||||
<tr class="vote-row" data-vote-type="{{ vote.vote_type | lower }}">
|
</tr>
|
||||||
<td class="ps-3">
|
{% endfor %}
|
||||||
<div class="d-flex align-items-center">
|
</tbody>
|
||||||
<div class="avatar-circle me-2 bg-primary text-white">
|
</table>
|
||||||
U
|
|
||||||
</div>
|
|
||||||
<span>{{ vote.voter_name }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %} rounded-pill px-3 py-2">
|
|
||||||
{% if vote.vote_type == 'Yes' %}
|
|
||||||
<i class="bi bi-check-circle-fill me-1"></i>
|
|
||||||
{% elif vote.vote_type == 'No' %}
|
|
||||||
<i class="bi bi-x-circle-fill me-1"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-dash-circle-fill me-1"></i>
|
|
||||||
{% endif %}
|
|
||||||
{{ vote.vote_type }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if vote.comment %}
|
|
||||||
<div class="comment-text">{{ vote.comment }}</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted fst-italic">No comment provided</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-end pe-3">
|
|
||||||
<div class="d-flex flex-column align-items-end">
|
|
||||||
<span>{{ vote.created_at | date(format="%Y-%m-%d") }}</span>
|
|
||||||
<small class="text-muted">{{ vote.created_at | date(format="%H:%M")
|
|
||||||
}}</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination Controls -->
|
|
||||||
{% if votes | length > 10 %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center p-3 border-top">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<label class="me-2 text-muted small">Rows per page:</label>
|
|
||||||
<select id="rowsPerPage" class="form-select form-select-sm" style="width: auto;">
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav aria-label="Votes pagination">
|
|
||||||
<ul class="pagination pagination-sm mb-0" id="paginationControls">
|
|
||||||
<li class="page-item disabled" id="prevPage">
|
|
||||||
<a class="page-link" href="#" aria-label="Previous">
|
|
||||||
<span aria-hidden="true">«</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item active"><a class="page-link" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">3</a></li>
|
|
||||||
<li class="page-item" id="nextPage">
|
|
||||||
<a class="page-link" href="#" aria-label="Next">
|
|
||||||
<span aria-hidden="true">»</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted small" id="paginationInfo">
|
|
||||||
Showing <span id="startRow">1</span>-<span id="endRow">10</span> of <span
|
|
||||||
id="totalRows">{{ votes | length }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center">No votes have been cast yet.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Remove query parameters from URL without refreshing the page
|
|
||||||
if (window.location.search.includes('vote_success=true')) {
|
|
||||||
const newUrl = window.location.pathname;
|
|
||||||
window.history.replaceState({}, document.title, newUrl);
|
|
||||||
|
|
||||||
// Auto-hide the success alert after 5 seconds
|
|
||||||
const successAlert = document.querySelector('.alert-success');
|
|
||||||
if (successAlert) {
|
|
||||||
setTimeout(function () {
|
|
||||||
successAlert.classList.remove('show');
|
|
||||||
setTimeout(function () {
|
|
||||||
successAlert.remove();
|
|
||||||
}, 500);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination functionality
|
|
||||||
const rowsPerPageSelect = document.getElementById('rowsPerPage');
|
|
||||||
const paginationControls = document.getElementById('paginationControls');
|
|
||||||
const votesTableBody = document.getElementById('votesTableBody');
|
|
||||||
const startRowElement = document.getElementById('startRow');
|
|
||||||
const endRowElement = document.getElementById('endRow');
|
|
||||||
const totalRowsElement = document.getElementById('totalRows');
|
|
||||||
const prevPageBtn = document.getElementById('prevPage');
|
|
||||||
const nextPageBtn = document.getElementById('nextPage');
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
|
|
||||||
|
|
||||||
// Function to update pagination display
|
|
||||||
function updatePagination() {
|
|
||||||
if (!paginationControls) return;
|
|
||||||
|
|
||||||
// Get all rows that match the current filter
|
|
||||||
const currentFilter = document.querySelector('[data-filter].active');
|
|
||||||
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
|
|
||||||
|
|
||||||
// Get rows that match the current filter and search term
|
|
||||||
let filteredRows = Array.from(voteRows);
|
|
||||||
if (filterType !== 'all') {
|
|
||||||
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search filter if there's a search term
|
|
||||||
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredRows = filteredRows.filter(row => {
|
|
||||||
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
|
|
||||||
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
|
|
||||||
return voterName.includes(searchTerm) || comment.includes(searchTerm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRows = filteredRows.length;
|
|
||||||
|
|
||||||
// Calculate total pages
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
|
|
||||||
|
|
||||||
// Ensure current page is valid
|
|
||||||
if (currentPage > totalPages) {
|
|
||||||
currentPage = totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update pagination controls
|
|
||||||
if (paginationControls) {
|
|
||||||
// Clear existing page links (except prev/next)
|
|
||||||
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
|
|
||||||
pageLinks.forEach(link => link.remove());
|
|
||||||
|
|
||||||
// Add new page links
|
|
||||||
const maxVisiblePages = 5;
|
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
|
||||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
|
||||||
|
|
||||||
// Adjust if we're near the end
|
|
||||||
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
|
|
||||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert page links before the next button
|
|
||||||
const nextPageElement = document.getElementById('nextPage');
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.className = 'page-link';
|
|
||||||
a.href = '#';
|
|
||||||
a.textContent = i;
|
|
||||||
a.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
currentPage = i;
|
|
||||||
updatePagination();
|
|
||||||
});
|
|
||||||
|
|
||||||
li.appendChild(a);
|
|
||||||
paginationControls.insertBefore(li, nextPageElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update prev/next buttons
|
|
||||||
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
|
||||||
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show current page
|
|
||||||
showCurrentPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to show current page
|
|
||||||
function showCurrentPage() {
|
|
||||||
if (!votesTableBody) return;
|
|
||||||
|
|
||||||
// Get all rows that match the current filter
|
|
||||||
const currentFilter = document.querySelector('[data-filter].active');
|
|
||||||
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
|
|
||||||
|
|
||||||
// Get rows that match the current filter and search term
|
|
||||||
let filteredRows = Array.from(voteRows);
|
|
||||||
if (filterType !== 'all') {
|
|
||||||
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search filter if there's a search term
|
|
||||||
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredRows = filteredRows.filter(row => {
|
|
||||||
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
|
|
||||||
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
|
|
||||||
return voterName.includes(searchTerm) || comment.includes(searchTerm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide all rows first
|
|
||||||
voteRows.forEach(row => row.style.display = 'none');
|
|
||||||
|
|
||||||
// Calculate pagination
|
|
||||||
const totalRows = filteredRows.length;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
|
|
||||||
|
|
||||||
// Ensure current page is valid
|
|
||||||
if (currentPage > totalPages) {
|
|
||||||
currentPage = totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show only rows for current page
|
|
||||||
const start = (currentPage - 1) * rowsPerPage;
|
|
||||||
const end = start + rowsPerPage;
|
|
||||||
|
|
||||||
filteredRows.slice(start, end).forEach(row => row.style.display = '');
|
|
||||||
|
|
||||||
// Update pagination info
|
|
||||||
if (startRowElement && endRowElement && totalRowsElement) {
|
|
||||||
startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
|
|
||||||
endRowElement.textContent = Math.min(end, totalRows);
|
|
||||||
totalRowsElement.textContent = totalRows;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners for pagination
|
|
||||||
if (prevPageBtn) {
|
|
||||||
prevPageBtn.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (currentPage > 1) {
|
|
||||||
currentPage--;
|
|
||||||
updatePagination();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextPageBtn) {
|
|
||||||
nextPageBtn.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Get all rows that match the current filter
|
|
||||||
const currentFilter = document.querySelector('[data-filter].active');
|
|
||||||
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
|
|
||||||
|
|
||||||
// Get rows that match the current filter and search term
|
|
||||||
let filteredRows = Array.from(voteRows);
|
|
||||||
if (filterType !== 'all') {
|
|
||||||
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search filter if there's a search term
|
|
||||||
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredRows = filteredRows.filter(row => {
|
|
||||||
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
|
|
||||||
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
|
|
||||||
return voterName.includes(searchTerm) || comment.includes(searchTerm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRows = filteredRows.length;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
|
|
||||||
|
|
||||||
if (currentPage < totalPages) {
|
|
||||||
currentPage++;
|
|
||||||
updatePagination();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowsPerPageSelect) {
|
|
||||||
rowsPerPageSelect.addEventListener('change', function () {
|
|
||||||
rowsPerPage = parseInt(this.value);
|
|
||||||
currentPage = 1; // Reset to first page
|
|
||||||
updatePagination();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize pagination (but don't interfere with filtering)
|
|
||||||
if (paginationControls) {
|
|
||||||
// Only initialize pagination if there are many votes
|
|
||||||
// The filtering will handle showing/hiding rows
|
|
||||||
console.log('Pagination controls available but not interfering with filtering');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize tooltips for all elements with title attributes
|
|
||||||
const tooltipElements = document.querySelectorAll('[title]');
|
|
||||||
if (tooltipElements.length > 0) {
|
|
||||||
[].slice.call(tooltipElements).map(function (el) {
|
|
||||||
return new bootstrap.Tooltip(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add debugging for vote form
|
|
||||||
const voteForm = document.getElementById('voteForm');
|
|
||||||
if (voteForm) {
|
|
||||||
console.log('Vote form found:', voteForm);
|
|
||||||
voteForm.addEventListener('submit', function (e) {
|
|
||||||
console.log('Vote form submitted');
|
|
||||||
const formData = new FormData(voteForm);
|
|
||||||
console.log('Form data:', Object.fromEntries(formData));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('Vote form not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('Filter buttons found:', filterButtons.length);
|
|
||||||
console.log('Vote rows found:', voteRows.length);
|
|
||||||
console.log('Search input found:', searchInput ? 'Yes' : 'No');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,140 +3,128 @@
|
|||||||
{% block title %}Proposals - Governance Dashboard{% endblock %}
|
{% block title %}Proposals - Governance Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Header -->
|
<!-- Success message if present -->
|
||||||
{% include "governance/_header.html" %}
|
{% if success %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
<!-- Navigation Tabs -->
|
||||||
{% include "governance/_tabs.html" %}
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/governance/proposals">All Proposals</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance/my-votes">My Votes</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/governance/create">Create Proposal</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Success message if present -->
|
|
||||||
{% if success %}
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div class="alert alert-info alert-dismissible fade show">
|
||||||
{{ success }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
<h5><i class="bi bi-info-circle"></i> About Proposals</h5>
|
||||||
</div>
|
<p>Proposals are formal requests for changes to the platform that require community approval. Each proposal includes a detailed description, implementation plan, and voting period. Browse the list below to see all active and past proposals.</p>
|
||||||
</div>
|
<div class="mt-2">
|
||||||
{% endif %}
|
<a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i class="bi bi-file-text"></i> Proposal Guidelines</a>
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-info alert-dismissible fade show">
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
<h5><i class="bi bi-info-circle"></i> About Proposals</h5>
|
|
||||||
<p>Proposals are formal requests for changes to the platform that require community approval. Each proposal
|
|
||||||
includes a detailed description, implementation plan, and voting period. Browse the list below to see all
|
|
||||||
active and past proposals.</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i
|
|
||||||
class="bi bi-file-text"></i> Proposal Guidelines</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter Controls -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="/governance/proposals" method="get" class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="status" class="form-label">Status</label>
|
|
||||||
<select class="form-select" id="status" name="status">
|
|
||||||
<option value="" {% if not status_filter or status_filter=="" %}selected{% endif %}>All
|
|
||||||
Statuses</option>
|
|
||||||
<option value="Draft" {% if status_filter=="Draft" %}selected{% endif %}>Draft</option>
|
|
||||||
<option value="Active" {% if status_filter=="Active" %}selected{% endif %}>Active</option>
|
|
||||||
<option value="Approved" {% if status_filter=="Approved" %}selected{% endif %}>Approved
|
|
||||||
</option>
|
|
||||||
<option value="Rejected" {% if status_filter=="Rejected" %}selected{% endif %}>Rejected
|
|
||||||
</option>
|
|
||||||
<option value="Cancelled" {% if status_filter=="Cancelled" %}selected{% endif %}>Cancelled
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="search" class="form-label">Search</label>
|
|
||||||
<input type="text" class="form-control" id="search" name="search"
|
|
||||||
placeholder="Search by title or description"
|
|
||||||
value="{% if search_filter %}{{ search_filter }}{% endif %}">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 d-flex align-items-end">
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proposals List -->
|
<!-- Filter Controls -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<h5 class="mb-0">All Proposals</h5>
|
<form action="/governance/proposals" method="get" class="row g-3">
|
||||||
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a>
|
<div class="col-md-4">
|
||||||
</div>
|
<label for="status" class="form-label">Status</label>
|
||||||
<div class="card-body">
|
<select class="form-select" id="status" name="status">
|
||||||
{% if proposals and proposals|length > 0 %}
|
<option value="">All Statuses</option>
|
||||||
<div class="table-responsive">
|
<option value="Draft">Draft</option>
|
||||||
<table class="table table-hover">
|
<option value="Active">Active</option>
|
||||||
<thead>
|
<option value="Approved">Approved</option>
|
||||||
<tr>
|
<option value="Rejected">Rejected</option>
|
||||||
<th>Title</th>
|
<option value="Cancelled">Cancelled</option>
|
||||||
<th>Creator</th>
|
</select>
|
||||||
<th>Status</th>
|
</div>
|
||||||
<th>Created</th>
|
<div class="col-md-6">
|
||||||
<th>Voting Period</th>
|
<label for="search" class="form-label">Search</label>
|
||||||
<th>Actions</th>
|
<input type="text" class="form-control" id="search" name="search" placeholder="Search by title or description">
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
<tbody>
|
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||||
{% for proposal in proposals %}
|
</div>
|
||||||
<tr>
|
</form>
|
||||||
<td>{{ proposal.title }}</td>
|
|
||||||
<td>{{ proposal.creator_name }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
|
|
||||||
{{ proposal.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
|
|
||||||
<td>
|
|
||||||
{% if proposal.vote_start_date and proposal.vote_end_date %}
|
|
||||||
{{ proposal.vote_start_date | date(format="%Y-%m-%d") }} to {{
|
|
||||||
proposal.vote_end_date | date(format="%Y-%m-%d") }}
|
|
||||||
{% else %}
|
|
||||||
Not set
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/governance/proposals/{{ proposal.base_data.id }}"
|
|
||||||
class="btn btn-sm btn-primary">View</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-info text-center py-5">
|
|
||||||
<i class="bi bi-info-circle fs-1 mb-3"></i>
|
|
||||||
<h5>No proposals found</h5>
|
|
||||||
{% if status_filter or search_filter %}
|
|
||||||
<p>No proposals match your current filter criteria. Try adjusting your filters or <a
|
|
||||||
href="/governance/proposals" class="alert-link">view all proposals</a>.</p>
|
|
||||||
{% else %}
|
|
||||||
<p>There are no proposals in the system yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/governance/create" class="btn btn-primary mt-3">Create New Proposal</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endblock %}
|
<!-- Proposals List -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">All Proposals</h5>
|
||||||
|
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Creator</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Voting Period</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ proposal.title }}</td>
|
||||||
|
<td>{{ proposal.creator_name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
|
||||||
|
{{ proposal.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
|
||||||
|
{{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} to {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}
|
||||||
|
Not set
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
use actix_mvc_app::controllers::payment::CompanyRegistrationData;
|
|
||||||
use actix_mvc_app::db::payment as payment_db;
|
|
||||||
use actix_mvc_app::db::registration as registration_db;
|
|
||||||
use actix_mvc_app::utils::stripe_security::StripeWebhookVerifier;
|
|
||||||
use actix_mvc_app::validators::CompanyRegistrationValidator;
|
|
||||||
use heromodels::models::biz::PaymentStatus;
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod payment_flow_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn create_valid_registration_data() -> CompanyRegistrationData {
|
|
||||||
CompanyRegistrationData {
|
|
||||||
company_name: "Test Company Ltd".to_string(),
|
|
||||||
company_type: "Single FZC".to_string(),
|
|
||||||
company_email: Some("test@example.com".to_string()),
|
|
||||||
company_phone: Some("+1234567890".to_string()),
|
|
||||||
company_website: Some("https://example.com".to_string()),
|
|
||||||
company_address: Some("123 Test Street, Test City".to_string()),
|
|
||||||
company_industry: Some("Technology".to_string()),
|
|
||||||
company_purpose: Some("Software development".to_string()),
|
|
||||||
fiscal_year_end: Some("December".to_string()),
|
|
||||||
shareholders: r#"[{"name": "John Doe", "percentage": 100}]"#.to_string(),
|
|
||||||
payment_plan: "monthly".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_registration_data_validation_success() {
|
|
||||||
let data = create_valid_registration_data();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.is_valid,
|
|
||||||
"Valid registration data should pass validation"
|
|
||||||
);
|
|
||||||
assert!(result.errors.is_empty(), "Valid data should have no errors");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_registration_data_validation_failures() {
|
|
||||||
// Test empty company name
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_name = "".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_name"));
|
|
||||||
|
|
||||||
// Test invalid email
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_email = Some("invalid-email".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_email"));
|
|
||||||
|
|
||||||
// Test invalid phone
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_phone = Some("123".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_phone"));
|
|
||||||
|
|
||||||
// Test invalid website
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.company_website = Some("not-a-url".to_string());
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "company_website"));
|
|
||||||
|
|
||||||
// Test invalid shareholders JSON
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.shareholders = "invalid json".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "shareholders"));
|
|
||||||
|
|
||||||
// Test invalid payment plan
|
|
||||||
let mut data = create_valid_registration_data();
|
|
||||||
data.payment_plan = "invalid_plan".to_string();
|
|
||||||
let result = CompanyRegistrationValidator::validate(&data);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert!(result.errors.iter().any(|e| e.field == "payment_plan"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_registration_data_storage_and_retrieval() {
|
|
||||||
let payment_intent_id = "pi_test_123456".to_string();
|
|
||||||
let data = create_valid_registration_data();
|
|
||||||
|
|
||||||
// Store registration data
|
|
||||||
let store_result =
|
|
||||||
registration_db::store_registration_data(payment_intent_id.clone(), data.clone());
|
|
||||||
assert!(
|
|
||||||
store_result.is_ok(),
|
|
||||||
"Should successfully store registration data"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retrieve registration data
|
|
||||||
let retrieve_result = registration_db::get_registration_data(&payment_intent_id);
|
|
||||||
assert!(
|
|
||||||
retrieve_result.is_ok(),
|
|
||||||
"Should successfully retrieve registration data"
|
|
||||||
);
|
|
||||||
|
|
||||||
let retrieved_data = retrieve_result.unwrap();
|
|
||||||
assert!(
|
|
||||||
retrieved_data.is_some(),
|
|
||||||
"Should find stored registration data"
|
|
||||||
);
|
|
||||||
|
|
||||||
let stored_data = retrieved_data.unwrap();
|
|
||||||
assert_eq!(stored_data.company_name, data.company_name);
|
|
||||||
assert_eq!(stored_data.company_email, data.company_email.unwrap());
|
|
||||||
assert_eq!(stored_data.payment_plan, data.payment_plan);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = registration_db::delete_registration_data(&payment_intent_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_payment_creation_and_status_updates() {
|
|
||||||
let payment_intent_id = "pi_test_payment_123".to_string();
|
|
||||||
|
|
||||||
// Create a payment
|
|
||||||
let create_result = payment_db::create_new_payment(
|
|
||||||
payment_intent_id.clone(),
|
|
||||||
0, // Temporary company_id
|
|
||||||
"monthly".to_string(),
|
|
||||||
20.0, // setup_fee
|
|
||||||
20.0, // monthly_fee
|
|
||||||
40.0, // total_amount
|
|
||||||
);
|
|
||||||
assert!(create_result.is_ok(), "Should successfully create payment");
|
|
||||||
|
|
||||||
let (payment_id, payment) = create_result.unwrap();
|
|
||||||
assert_eq!(payment.payment_intent_id, payment_intent_id);
|
|
||||||
assert_eq!(payment.status, PaymentStatus::Pending);
|
|
||||||
|
|
||||||
// Update payment status to completed
|
|
||||||
let update_result =
|
|
||||||
payment_db::update_payment_status(&payment_intent_id, PaymentStatus::Completed);
|
|
||||||
assert!(
|
|
||||||
update_result.is_ok(),
|
|
||||||
"Should successfully update payment status"
|
|
||||||
);
|
|
||||||
|
|
||||||
let updated_payment = update_result.unwrap();
|
|
||||||
assert!(updated_payment.is_some(), "Should return updated payment");
|
|
||||||
assert_eq!(updated_payment.unwrap().status, PaymentStatus::Completed);
|
|
||||||
|
|
||||||
// Test updating company ID
|
|
||||||
let company_id = 123u32;
|
|
||||||
let link_result = payment_db::update_payment_company_id(&payment_intent_id, company_id);
|
|
||||||
assert!(
|
|
||||||
link_result.is_ok(),
|
|
||||||
"Should successfully link payment to company"
|
|
||||||
);
|
|
||||||
|
|
||||||
let linked_payment = link_result.unwrap();
|
|
||||||
assert!(linked_payment.is_some(), "Should return linked payment");
|
|
||||||
assert_eq!(linked_payment.unwrap().company_id, company_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_payment_queries() {
|
|
||||||
// Test getting pending payments
|
|
||||||
let pending_result = payment_db::get_pending_payments();
|
|
||||||
assert!(
|
|
||||||
pending_result.is_ok(),
|
|
||||||
"Should successfully get pending payments"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test getting failed payments
|
|
||||||
let failed_result = payment_db::get_failed_payments();
|
|
||||||
assert!(
|
|
||||||
failed_result.is_ok(),
|
|
||||||
"Should successfully get failed payments"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test getting payment by intent ID
|
|
||||||
let get_result = payment_db::get_payment_by_intent_id("nonexistent_payment");
|
|
||||||
assert!(
|
|
||||||
get_result.is_ok(),
|
|
||||||
"Should handle nonexistent payment gracefully"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
get_result.unwrap().is_none(),
|
|
||||||
"Should return None for nonexistent payment"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_pricing_calculations() {
|
|
||||||
// Test pricing calculation logic
|
|
||||||
fn calculate_total_amount(setup_fee: f64, monthly_fee: f64, payment_plan: &str) -> f64 {
|
|
||||||
match payment_plan {
|
|
||||||
"monthly" => setup_fee + monthly_fee,
|
|
||||||
"yearly" => setup_fee + (monthly_fee * 12.0 * 0.8), // 20% discount
|
|
||||||
"two_year" => setup_fee + (monthly_fee * 24.0 * 0.6), // 40% discount
|
|
||||||
_ => setup_fee + monthly_fee,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test monthly pricing
|
|
||||||
let monthly_total = calculate_total_amount(20.0, 20.0, "monthly");
|
|
||||||
assert_eq!(
|
|
||||||
monthly_total, 40.0,
|
|
||||||
"Monthly total should be setup + monthly fee"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test yearly pricing (20% discount)
|
|
||||||
let yearly_total = calculate_total_amount(20.0, 20.0, "yearly");
|
|
||||||
let expected_yearly = 20.0 + (20.0 * 12.0 * 0.8); // Setup + discounted yearly
|
|
||||||
assert_eq!(
|
|
||||||
yearly_total, expected_yearly,
|
|
||||||
"Yearly total should include 20% discount"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test two-year pricing (40% discount)
|
|
||||||
let two_year_total = calculate_total_amount(20.0, 20.0, "two_year");
|
|
||||||
let expected_two_year = 20.0 + (20.0 * 24.0 * 0.6); // Setup + discounted two-year
|
|
||||||
assert_eq!(
|
|
||||||
two_year_total, expected_two_year,
|
|
||||||
"Two-year total should include 40% discount"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_company_type_mapping() {
|
|
||||||
let test_cases = vec![
|
|
||||||
("Single FZC", "Single"),
|
|
||||||
("Startup FZC", "Starter"),
|
|
||||||
("Growth FZC", "Global"),
|
|
||||||
("Global FZC", "Global"),
|
|
||||||
("Cooperative FZC", "Coop"),
|
|
||||||
("Twin FZC", "Twin"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (input, expected) in test_cases {
|
|
||||||
// This would test the business type mapping in create_company_from_form_data
|
|
||||||
// We'll need to expose this logic or test it indirectly
|
|
||||||
assert!(true, "Company type mapping test placeholder for {}", input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod webhook_security_tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_webhook_signature_verification_valid() {
|
|
||||||
let payload = b"test payload";
|
|
||||||
let webhook_secret = "whsec_test_secret";
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Create a valid signature
|
|
||||||
let signed_payload = format!("{}.{}", current_time, std::str::from_utf8(payload).unwrap());
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
|
||||||
mac.update(signed_payload.as_bytes());
|
|
||||||
let signature = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
let signature_header = format!("t={},v1={}", current_time, signature);
|
|
||||||
|
|
||||||
let result = StripeWebhookVerifier::verify_signature(
|
|
||||||
payload,
|
|
||||||
&signature_header,
|
|
||||||
webhook_secret,
|
|
||||||
Some(300),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "Valid signature should verify successfully");
|
|
||||||
assert!(result.unwrap(), "Valid signature should return true");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_webhook_signature_verification_invalid() {
|
|
||||||
let payload = b"test payload";
|
|
||||||
let webhook_secret = "whsec_test_secret";
|
|
||||||
let current_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Create an invalid signature
|
|
||||||
let signature_header = format!("t={},v1=invalid_signature", current_time);
|
|
||||||
|
|
||||||
let result = StripeWebhookVerifier::verify_signature(
|
|
||||||
payload,
|
|
||||||
&signature_header,
|
|
||||||
webhook_secret,
|
|
||||||
Some(300),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "Invalid signature should not cause error");
|
|
||||||
assert!(!result.unwrap(), "Invalid signature should return false");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_webhook_signature_verification_expired() {
|
|
||||||
let payload = b"test payload";
|
|
||||||
let webhook_secret = "whsec_test_secret";
|
|
||||||
let old_time = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs()
|
|
||||||
- 400; // 400 seconds ago (beyond 300s tolerance)
|
|
||||||
|
|
||||||
// Create a signature with old timestamp
|
|
||||||
let signed_payload = format!("{}.{}", old_time, std::str::from_utf8(payload).unwrap());
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
|
||||||
mac.update(signed_payload.as_bytes());
|
|
||||||
let signature = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
let signature_header = format!("t={},v1={}", old_time, signature);
|
|
||||||
|
|
||||||
let result = StripeWebhookVerifier::verify_signature(
|
|
||||||
payload,
|
|
||||||
&signature_header,
|
|
||||||
webhook_secret,
|
|
||||||
Some(300),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_err(), "Expired signature should return error");
|
|
||||||
assert!(
|
|
||||||
result.unwrap_err().contains("too old"),
|
|
||||||
"Error should mention timestamp age"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_webhook_signature_verification_malformed_header() {
|
|
||||||
let payload = b"test payload";
|
|
||||||
let webhook_secret = "whsec_test_secret";
|
|
||||||
|
|
||||||
// Test various malformed headers
|
|
||||||
let malformed_headers = vec![
|
|
||||||
"invalid_header",
|
|
||||||
"t=123",
|
|
||||||
"v1=signature",
|
|
||||||
"t=invalid_timestamp,v1=signature",
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
|
|
||||||
for header in malformed_headers {
|
|
||||||
let result =
|
|
||||||
StripeWebhookVerifier::verify_signature(payload, header, webhook_secret, Some(300));
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"Malformed header '{}' should return error",
|
|
||||||
header
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2809
flowbroker/Cargo.lock
generated
Normal file
2809
flowbroker/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
flowbroker/Cargo.toml
Normal file
27
flowbroker/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "flowbroker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sigsocket = { path = "../sigsocket" } # Path relative to flowbroker directory
|
||||||
|
actix-web = "4.3.1"
|
||||||
|
actix-rt = "2.8.0"
|
||||||
|
actix-files = "0.6.2"
|
||||||
|
actix-web-actors = "4.2.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
env_logger = "0.10.0"
|
||||||
|
log = "0.4.0"
|
||||||
|
tera = "1.19.0"
|
||||||
|
tokio = { version = "1.28.0", features = ["full"] }
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
hex = "0.4.3"
|
||||||
|
uuid = { version = "1.4", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] } # For timestamps
|
||||||
|
rhai = "1.18.0"
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
|
||||||
|
# Database models and ORM-like functionality
|
||||||
|
heromodels = { path = "../../db/heromodels" }
|
||||||
|
# Note: heromodels pulls in 'ourdb', 'heromodels_core', 'heromodels_derive'
|
||||||
BIN
flowbroker/flowbroker_db/data/0.db
Normal file
BIN
flowbroker/flowbroker_db/data/0.db
Normal file
Binary file not shown.
BIN
flowbroker/flowbroker_db/data/lookup/data
Normal file
BIN
flowbroker/flowbroker_db/data/lookup/data
Normal file
Binary file not shown.
BIN
flowbroker/flowbroker_db/index/0.db
Normal file
BIN
flowbroker/flowbroker_db/index/0.db
Normal file
Binary file not shown.
1
flowbroker/flowbroker_db/index/lookup/.inc
Normal file
1
flowbroker/flowbroker_db/index/lookup/.inc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
148
|
||||||
BIN
flowbroker/flowbroker_db/index/lookup/data
Normal file
BIN
flowbroker/flowbroker_db/index/lookup/data
Normal file
Binary file not shown.
690
flowbroker/src/main.rs
Normal file
690
flowbroker/src/main.rs
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
use actix_files as fs;
|
||||||
|
use actix_web::{web, App, HttpResponse, HttpServer, Responder, Result as ActixResult};
|
||||||
|
use std::fs as std_fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use actix_web_actors::ws;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_urlencoded; // Added for from_str
|
||||||
|
use tera::{Tera, Context};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use sigsocket::service::SigSocketService;
|
||||||
|
use sigsocket::registry::ConnectionRegistry;
|
||||||
|
use log::{info, error};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use rhai::{Engine, EvalAltResult, Position};
|
||||||
|
// use std::collections::HashMap; // Removed as no longer used
|
||||||
|
use heromodels; // Added for database models
|
||||||
|
use heromodels::db::hero::OurDB;
|
||||||
|
use heromodels::db::{Db, Collection}; // Import Db trait for .collection() and Collection trait for .set()/.get_all()
|
||||||
|
use heromodels::models::flowbroker_models::{Flow, FlowStep, SignatureRequirement}; // Import the models
|
||||||
|
use dotenv::dotenv;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
// --- Flowbroker Specific Enums (to be used by application logic) ---
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
pub enum FlowStepStatus {
|
||||||
|
Pending, // Step created, not yet processed
|
||||||
|
InProgress, // Step is actively being processed (e.g., waiting for signatures)
|
||||||
|
Completed, // All requirements for this step are met
|
||||||
|
Failed, // Step failed (e.g., a signature requirement failed or timed out)
|
||||||
|
Skipped, // Step was skipped (e.g., due to conditional logic not yet implemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowStepStatus {
|
||||||
|
pub fn to_db_string(&self) -> String {
|
||||||
|
format!("{:?}", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_db_string(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"Pending" => Ok(FlowStepStatus::Pending),
|
||||||
|
"InProgress" => Ok(FlowStepStatus::InProgress),
|
||||||
|
"Completed" => Ok(FlowStepStatus::Completed),
|
||||||
|
"Failed" => Ok(FlowStepStatus::Failed),
|
||||||
|
"Skipped" => Ok(FlowStepStatus::Skipped),
|
||||||
|
_ => Err(format!("Invalid FlowStepStatus string: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
pub enum SignatureRequirementStatus {
|
||||||
|
Pending, // Not yet processed or sent for signing
|
||||||
|
SentToClient, // Sent to client via SigSocket, awaiting signature
|
||||||
|
Signed, // Successfully signed
|
||||||
|
Failed, // Signing failed (e.g., client rejected, timeout, error)
|
||||||
|
Error, // An internal error occurred processing this requirement
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignatureRequirementStatus {
|
||||||
|
pub fn to_db_string(&self) -> String {
|
||||||
|
format!("{:?}", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_db_string(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"Pending" => Ok(SignatureRequirementStatus::Pending),
|
||||||
|
"SentToClient" => Ok(SignatureRequirementStatus::SentToClient),
|
||||||
|
"Signed" => Ok(SignatureRequirementStatus::Signed),
|
||||||
|
"Failed" => Ok(SignatureRequirementStatus::Failed),
|
||||||
|
"Error" => Ok(SignatureRequirementStatus::Error),
|
||||||
|
_ => Err(format!("Invalid SignatureRequirementStatus string: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
pub enum FlowStatus {
|
||||||
|
Pending, // Flow created, no steps initiated
|
||||||
|
InProgress, // Flow started, steps are being processed
|
||||||
|
Completed, // All steps successfully signed
|
||||||
|
Failed, // A step failed or timed out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowStatus {
|
||||||
|
pub fn to_db_string(&self) -> String {
|
||||||
|
format!("{:?}", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_db_string(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"Pending" => Ok(FlowStatus::Pending),
|
||||||
|
"InProgress" => Ok(FlowStatus::InProgress),
|
||||||
|
"Completed" => Ok(FlowStatus::Completed),
|
||||||
|
"Failed" => Ok(FlowStatus::Failed),
|
||||||
|
_ => Err(format!("Invalid FlowStatus string: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: The old Flow, FlowStep, and SignatureRequirement structs previously here
|
||||||
|
// have been removed. Their definitions are now in the heromodels crate.
|
||||||
|
|
||||||
|
// --- AppState ---
|
||||||
|
pub struct AppState {
|
||||||
|
templates: Tera,
|
||||||
|
sigsocket_service: Arc<SigSocketService>,
|
||||||
|
db: Arc<OurDB>, // Using OurDB from heromodels
|
||||||
|
next_id_counter: Arc<Mutex<u32>>, // For generating temporary primary keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Deserialization (for new dynamic form) ---
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
|
pub struct RequirementRealFormData {
|
||||||
|
// The name attributes in HTML are like: steps[0][requirements][0][message]
|
||||||
|
pub message: String, // Made fields public for external construction in tests
|
||||||
|
pub public_key: String, // Made fields public
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
|
pub struct FlowStepFormData {
|
||||||
|
description: Option<String>, // If description field is optional and might not be present
|
||||||
|
requirements: Vec<RequirementRealFormData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
|
pub struct CreateFlowRealFormData { // Renamed to avoid confusion with heromodels::Flow
|
||||||
|
flow_name: String,
|
||||||
|
steps: Vec<FlowStepFormData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Debug)]
|
||||||
|
pub struct RhaiScriptFormData {
|
||||||
|
rhai_script: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Context Structs for Templates ---
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct RhaiExampleDisplay {
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ListFlowsContext {
|
||||||
|
flows: Vec<Flow>, // Using heromodels::models::flowbroker_models::Flow
|
||||||
|
example_scripts: Vec<RhaiExampleDisplay>,
|
||||||
|
error_message: Option<String>,
|
||||||
|
success_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
// Display list of flows
|
||||||
|
async fn list_flows(data: web::Data<AppState>) -> ActixResult<HttpResponse> {
|
||||||
|
let tera = &data.templates;
|
||||||
|
|
||||||
|
// Fetch actual flows from the database
|
||||||
|
let flows_collection = data.db.collection::<Flow>()
|
||||||
|
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("DB Error: Failed to get flows collection: {}", e)))?;
|
||||||
|
let (mut flows, flow_error_message) = match flows_collection.get_all() {
|
||||||
|
Ok(mut flows_vec) => {
|
||||||
|
flows_vec.sort_by(|a, b| b.base_data.created_at.cmp(&a.base_data.created_at)); // Sort by newest
|
||||||
|
(flows_vec, None)
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to fetch flows: {:?}", e);
|
||||||
|
(Vec::new(), Some(format!("Error fetching flows: {:?}", e)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load Rhai example scripts
|
||||||
|
let examples_path = PathBuf::from("templates/rhai_examples");
|
||||||
|
let mut example_scripts_display = Vec::new();
|
||||||
|
if examples_path.is_dir() {
|
||||||
|
match std_fs::read_dir(examples_path) {
|
||||||
|
Ok(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
if let Ok(entry) = entry {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && path.extension().and_then(std::ffi::OsStr::to_str) == Some("rhai") {
|
||||||
|
let file_stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Unknown Script");
|
||||||
|
// Convert filename (e.g., simple_two_step) to a nicer name (e.g., Simple Two Step)
|
||||||
|
let script_name = file_stem.replace("_", " ")
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|word| {
|
||||||
|
let mut c = word.chars();
|
||||||
|
match c.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>().join(" ");
|
||||||
|
|
||||||
|
match std_fs::read_to_string(&path) {
|
||||||
|
Ok(content) => {
|
||||||
|
example_scripts_display.push(RhaiExampleDisplay { name: script_name, content });
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read Rhai example script {:?}: {}", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read Rhai examples directory: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
example_scripts_display.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
let list_context = ListFlowsContext {
|
||||||
|
flows,
|
||||||
|
example_scripts: example_scripts_display,
|
||||||
|
error_message: flow_error_message,
|
||||||
|
success_message: None, // TODO: Populate from query params or session later if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
let tera_ctx = Context::from_serialize(&list_context).unwrap_or_else(|e| {
|
||||||
|
error!("Failed to serialize ListFlowsContext: {}", e);
|
||||||
|
// Fallback to a minimal context or an error state if serialization fails
|
||||||
|
let mut err_ctx = Context::new();
|
||||||
|
err_ctx.insert("error_message", &"Critical error preparing page data.".to_string());
|
||||||
|
err_ctx
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still rendering to index.html, which will be the revamped list_flows.html
|
||||||
|
let rendered = tera.render("index.html", &tera_ctx)
|
||||||
|
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Template error (index.html): {}", e)))?;
|
||||||
|
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle creation of a new flow
|
||||||
|
async fn create_flow(
|
||||||
|
data: web::Data<AppState>,
|
||||||
|
raw_form_data: String, // Changed to accept raw String
|
||||||
|
) -> impl Responder {
|
||||||
|
info!("Received raw form data for create_flow: {}", raw_form_data);
|
||||||
|
|
||||||
|
// Attempt to parse the raw form data
|
||||||
|
let form_parse_result: Result<CreateFlowRealFormData, serde_urlencoded::de::Error> = serde_urlencoded::from_str(&raw_form_data);
|
||||||
|
|
||||||
|
let form = match form_parse_result {
|
||||||
|
Ok(parsed_form_data) => {
|
||||||
|
info!("Successfully parsed form data: {:?}", parsed_form_data);
|
||||||
|
parsed_form_data // Use the successfully parsed data
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to parse form data from string: {}. Raw data: {}", e, raw_form_data);
|
||||||
|
return HttpResponse::BadRequest().body(format!("Form parsing error: {}. Please check input and logs.", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Logic starts here, using `form` which is now CreateFlowRealFormData ---
|
||||||
|
info!("Processing create_flow request for: {}", form.flow_name);
|
||||||
|
|
||||||
|
let db = &data.db;
|
||||||
|
let mut id_counter = match data.next_id_counter.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => {
|
||||||
|
error!("Mutex for next_id_counter was poisoned: {}. Recovering.", poisoned);
|
||||||
|
poisoned.into_inner() // Attempt to recover
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Create and save the main Flow object
|
||||||
|
*id_counter += 1;
|
||||||
|
let flow_db_id = *id_counter;
|
||||||
|
let flow_uuid = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let flow_instance = Flow::new(
|
||||||
|
flow_db_id,
|
||||||
|
&flow_uuid,
|
||||||
|
&form.flow_name,
|
||||||
|
FlowStatus::Pending.to_db_string() // Use local enum's string representation
|
||||||
|
);
|
||||||
|
|
||||||
|
match db.collection::<Flow>() {
|
||||||
|
Ok(flow_collection) => {
|
||||||
|
if let Err(e) = flow_collection.set(&flow_instance) {
|
||||||
|
error!("Failed to save Flow (name: {}): {:?}. Aborting flow creation.", form.flow_name, e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Failed to save main flow data: {:?}", e));
|
||||||
|
}
|
||||||
|
info!("Saved Flow object for '{}', UUID: {}, DB_ID: {}", flow_instance.name, flow_instance.flow_uuid, flow_instance.base_data.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get Flow collection: {:?}. Aborting flow creation.", e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Database error getting flow collection: {:?}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create and save FlowStep and SignatureRequirement objects
|
||||||
|
for (step_idx, step_form_data) in form.steps.into_iter().enumerate() {
|
||||||
|
*id_counter += 1;
|
||||||
|
let flow_step_db_id = *id_counter;
|
||||||
|
|
||||||
|
let mut flow_step_instance = FlowStep::new(
|
||||||
|
flow_step_db_id,
|
||||||
|
flow_instance.base_data.id, // Use ID from the saved Flow instance
|
||||||
|
step_idx as u32, // step_order
|
||||||
|
FlowStepStatus::Pending.to_db_string() // Use local enum's string representation
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(desc) = step_form_data.description {
|
||||||
|
if !desc.is_empty() { // Only set if description is not empty
|
||||||
|
flow_step_instance = flow_step_instance.description(desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match db.collection::<FlowStep>() {
|
||||||
|
Ok(step_collection) => {
|
||||||
|
if let Err(e) = step_collection.set(&flow_step_instance) {
|
||||||
|
error!("Failed to save FlowStep (flow: {}, step_idx: {}): {:?}", flow_instance.name, step_idx, e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Failed to save flow step: {:?}", e));
|
||||||
|
}
|
||||||
|
info!("Saved FlowStep {} for flow '{}', DB_ID: {}", step_idx + 1, flow_instance.name, flow_step_instance.base_data.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get FlowStep collection: {:?}. Aborting.", e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Database error getting step collection: {:?}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (req_idx, req_form_data) in step_form_data.requirements.into_iter().enumerate() {
|
||||||
|
*id_counter += 1;
|
||||||
|
let sig_req_db_id = *id_counter;
|
||||||
|
|
||||||
|
let sig_req_instance = SignatureRequirement::new(
|
||||||
|
sig_req_db_id,
|
||||||
|
flow_step_instance.base_data.id, // Use ID from the saved FlowStep instance
|
||||||
|
&req_form_data.public_key,
|
||||||
|
&req_form_data.message,
|
||||||
|
SignatureRequirementStatus::Pending.to_db_string() // Use local enum's string representation
|
||||||
|
);
|
||||||
|
|
||||||
|
match db.collection::<SignatureRequirement>() {
|
||||||
|
Ok(req_collection) => {
|
||||||
|
if let Err(e) = req_collection.set(&sig_req_instance) {
|
||||||
|
error!("Failed to save SignatureRequirement (flow: {}, step: {}, req_idx: {}): {:?}", flow_instance.name, step_idx, req_idx, e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Failed to save signature requirement: {:?}", e));
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Saved SignatureRequirement {} for step {} of flow '{}', DB_ID: {}",
|
||||||
|
req_idx + 1, step_idx + 1, flow_instance.name, sig_req_instance.base_data.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get SignatureRequirement collection: {:?}. Aborting.", e);
|
||||||
|
return HttpResponse::InternalServerError().body(format!("Database error getting requirement collection: {:?}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Finished processing all steps for flow '{}', UUID: {}", flow_instance.name, flow_instance.flow_uuid);
|
||||||
|
|
||||||
|
HttpResponse::SeeOther()
|
||||||
|
.append_header((actix_web::http::header::LOCATION, "/"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rhai-Callable Helper Functions ---
|
||||||
|
|
||||||
|
fn rhai_create_flow_entry(
|
||||||
|
db_arc: Arc<OurDB>,
|
||||||
|
id_counter_arc: Arc<Mutex<u32>>,
|
||||||
|
name: String,
|
||||||
|
) -> Result<u32, Box<rhai::EvalAltResult>> {
|
||||||
|
info!("Rhai: Attempting to create flow entry with name: {}", name);
|
||||||
|
|
||||||
|
let mut id_counter = match id_counter_arc.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => {
|
||||||
|
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*id_counter += 1;
|
||||||
|
let flow_db_id = *id_counter;
|
||||||
|
let flow_uuid = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let flow_instance = Flow::new(
|
||||||
|
flow_db_id,
|
||||||
|
&flow_uuid,
|
||||||
|
&name,
|
||||||
|
FlowStatus::Pending.to_db_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match db_arc.collection::<Flow>() {
|
||||||
|
Ok(flow_collection) => {
|
||||||
|
if let Err(e) = flow_collection.set(&flow_instance) {
|
||||||
|
let err_msg = format!("Rhai: Failed to save Flow (name: {}): {:?}", name, e);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
info!("Rhai: Saved Flow object for '{}', UUID: {}, DB_ID: {}", flow_instance.name, flow_instance.flow_uuid, flow_instance.base_data.id);
|
||||||
|
Ok(flow_instance.base_data.id)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Rhai: Failed to get Flow collection: {:?}", e);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rhai_add_step_entry(
|
||||||
|
db_arc: Arc<OurDB>,
|
||||||
|
id_counter_arc: Arc<Mutex<u32>>,
|
||||||
|
flow_db_id: u32, // ID of the parent flow
|
||||||
|
description: String,
|
||||||
|
order: u32,
|
||||||
|
) -> Result<u32, Box<rhai::EvalAltResult>> {
|
||||||
|
info!(
|
||||||
|
"Rhai: Adding step to flow ID {}, order {}, description: '{}'",
|
||||||
|
flow_db_id, order, description
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut id_counter = match id_counter_arc.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => {
|
||||||
|
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*id_counter += 1;
|
||||||
|
let flow_step_db_id = *id_counter;
|
||||||
|
|
||||||
|
let mut flow_step_instance = FlowStep::new(
|
||||||
|
flow_step_db_id,
|
||||||
|
flow_db_id,
|
||||||
|
order,
|
||||||
|
FlowStepStatus::Pending.to_db_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !description.is_empty() {
|
||||||
|
flow_step_instance = flow_step_instance.description(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
match db_arc.collection::<FlowStep>() {
|
||||||
|
Ok(step_collection) => {
|
||||||
|
if let Err(e) = step_collection.set(&flow_step_instance) {
|
||||||
|
let err_msg = format!(
|
||||||
|
"Rhai: Failed to save FlowStep (flow_id: {}, order: {}): {:?}",
|
||||||
|
flow_db_id, order, e
|
||||||
|
);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Rhai: Saved FlowStep for flow_id {}, order {}, DB_ID: {}",
|
||||||
|
flow_db_id, order, flow_step_instance.base_data.id
|
||||||
|
);
|
||||||
|
Ok(flow_step_instance.base_data.id)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Rhai: Failed to get FlowStep collection: {:?}", e);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rhai_add_requirement_entry(
|
||||||
|
db_arc: Arc<OurDB>,
|
||||||
|
id_counter_arc: Arc<Mutex<u32>>,
|
||||||
|
step_db_id: u32, // ID of the parent step
|
||||||
|
public_key: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<u32, Box<rhai::EvalAltResult>> {
|
||||||
|
info!(
|
||||||
|
"Rhai: Adding requirement to step ID {}, pk: '{}', msg: '{}'",
|
||||||
|
step_db_id, public_key, message
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut id_counter = match id_counter_arc.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => {
|
||||||
|
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*id_counter += 1;
|
||||||
|
let sig_req_db_id = *id_counter;
|
||||||
|
|
||||||
|
let sig_req_instance = SignatureRequirement::new(
|
||||||
|
sig_req_db_id,
|
||||||
|
step_db_id,
|
||||||
|
&public_key,
|
||||||
|
&message,
|
||||||
|
SignatureRequirementStatus::Pending.to_db_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match db_arc.collection::<SignatureRequirement>() {
|
||||||
|
Ok(req_collection) => {
|
||||||
|
if let Err(e) = req_collection.set(&sig_req_instance) {
|
||||||
|
let err_msg = format!(
|
||||||
|
"Rhai: Failed to save SigRequirement (step_id: {}): {:?}",
|
||||||
|
step_db_id, e
|
||||||
|
);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Rhai: Saved SigRequirement for step_id {}, DB_ID: {}",
|
||||||
|
step_db_id, sig_req_instance.base_data.id
|
||||||
|
);
|
||||||
|
Ok(sig_req_instance.base_data.id)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Rhai: Failed to get SigRequirement collection: {:?}", e);
|
||||||
|
error!("{}", err_msg);
|
||||||
|
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle creation of a new flow from a Rhai script
|
||||||
|
async fn create_flow_from_script(
|
||||||
|
data: web::Data<AppState>,
|
||||||
|
form: web::Form<RhaiScriptFormData>,
|
||||||
|
) -> impl Responder {
|
||||||
|
info!("Received Rhai script for flow creation:\n{}", form.rhai_script);
|
||||||
|
|
||||||
|
let mut engine = Engine::new();
|
||||||
|
|
||||||
|
// Clone Arcs for capturing in closures
|
||||||
|
let db_clone_for_flow = data.db.clone();
|
||||||
|
let id_clone_for_flow = data.next_id_counter.clone();
|
||||||
|
let db_clone_for_step = data.db.clone();
|
||||||
|
let id_clone_for_step = data.next_id_counter.clone();
|
||||||
|
let db_clone_for_req = data.db.clone();
|
||||||
|
let id_clone_for_req = data.next_id_counter.clone();
|
||||||
|
|
||||||
|
engine
|
||||||
|
.register_fn("create_flow", move |name: String| {
|
||||||
|
crate::rhai_create_flow_entry(db_clone_for_flow.clone(), id_clone_for_flow.clone(), name)
|
||||||
|
})
|
||||||
|
.register_fn("add_step", move |flow_id: u32, desc: String, order: i64| {
|
||||||
|
if order < 0 || order > u32::MAX as i64 {
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorRuntime(format!("Order {} is out of range for u32", order).into(), Position::NONE)));
|
||||||
|
}
|
||||||
|
crate::rhai_add_step_entry(db_clone_for_step.clone(), id_clone_for_step.clone(), flow_id, desc, order as u32)
|
||||||
|
})
|
||||||
|
.register_fn("add_requirement", move |step_id: u32, pk: String, msg: String| {
|
||||||
|
crate::rhai_add_requirement_entry(db_clone_for_req.clone(), id_clone_for_req.clone(), step_id, pk, msg)
|
||||||
|
});
|
||||||
|
|
||||||
|
match engine.eval::<()>(&form.rhai_script) { // Expecting () as successful script execution doesn't need to return a value to Rust here.
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Rhai script executed successfully.");
|
||||||
|
HttpResponse::SeeOther()
|
||||||
|
.append_header((actix_web::http::header::LOCATION, "/"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Rhai script execution failed: {}", e.to_string());
|
||||||
|
HttpResponse::BadRequest().body(format!("Rhai script error: {}\n\nYour script was:\n{}", e.to_string(), form.rhai_script))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for SigSocket WebSocket handler
|
||||||
|
async fn websocket_handler(
|
||||||
|
req: actix_web::HttpRequest,
|
||||||
|
stream: actix_web::web::Payload,
|
||||||
|
service: web::Data<Arc<SigSocketService>>,
|
||||||
|
) -> ActixResult<HttpResponse> {
|
||||||
|
info!("WebSocket connection attempt");
|
||||||
|
let handler = service.create_websocket_handler();
|
||||||
|
ws::start(handler, &req, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Extracted Helper Functions for App Setup and Configuration ---
|
||||||
|
|
||||||
|
/// Sets up the shared application data (AppState).
|
||||||
|
/// Allows overriding the database path for testing purposes.
|
||||||
|
pub async fn setup_app_data(db_path_override: Option<String>) -> Result<web::Data<AppState>, std::io::Error> {
|
||||||
|
// Initialize templates
|
||||||
|
let tera = match Tera::new("templates/**/*") {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Critical: Tera template parsing error(s): {}", e);
|
||||||
|
// Convert tera::Error to std::io::Error
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::Other, format!("Tera init error: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize SigSocket registry and service
|
||||||
|
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
|
||||||
|
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
// Initialize Database
|
||||||
|
let database_path = db_path_override.unwrap_or_else(||
|
||||||
|
env::var("DATABASE_PATH").unwrap_or_else(|_|
|
||||||
|
{
|
||||||
|
info!("DATABASE_PATH not set, defaulting to ./flowbroker_db");
|
||||||
|
"./flowbroker_db".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let db = match OurDB::new(&database_path, true) { // true for create_if_missing
|
||||||
|
Ok(db_instance) => Arc::new(db_instance),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize database at '{}': {}. Please ensure the path is writable.", database_path, e);
|
||||||
|
// Convert heromodels::Error to std::io::Error (assuming Error impls std::error::Error)
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::Other, format!("DB init error: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("Database initialized at: {}", database_path);
|
||||||
|
|
||||||
|
// Initialize ID counter for temporary primary keys
|
||||||
|
let next_id_counter = Arc::new(Mutex::new(0_u32));
|
||||||
|
// TODO: Replace this with a robust primary key generation strategy from the database itself if possible.
|
||||||
|
|
||||||
|
// Create shared application state
|
||||||
|
Ok(web::Data::new(AppState {
|
||||||
|
templates: tera,
|
||||||
|
sigsocket_service: sigsocket_service.clone(), // Clone for AppState
|
||||||
|
db,
|
||||||
|
next_id_counter,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the application routes.
|
||||||
|
pub fn configure_app_routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
// Note: AppState should be added via .app_data() before calling this configure function.
|
||||||
|
// The websocket_handler specifically needs web::Data<Arc<SigSocketService>>.
|
||||||
|
// The main HttpServer setup will add AppState (which includes an Arc<SigSocketService>)
|
||||||
|
// and also the specific web::Data<Arc<SigSocketService>> for handlers like websocket_handler that expect it directly.
|
||||||
|
|
||||||
|
cfg.route("/", web::get().to(list_flows))
|
||||||
|
.service(
|
||||||
|
web::scope("/flows") // Group flow-related routes under /flows
|
||||||
|
// .route("", web::get().to(list_flows)) // If you want /flows to also list flows
|
||||||
|
// .route("/new", web::get().to(new_flow_form)) // Deprecated, functionality merged into root list_flows
|
||||||
|
.route("/create", web::post().to(create_flow))
|
||||||
|
.route("/create_script", web::post().to(create_flow_from_script)) // Moved inside /flows scope
|
||||||
|
)
|
||||||
|
.service(web::resource("/ws/").route(web::get().to(websocket_handler)))
|
||||||
|
.service(fs::Files::new("/static", "./static").show_files_listing()); // Static files
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Function ---
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
|
||||||
|
|
||||||
|
let app_data = match setup_app_data(None).await {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to setup application data: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The AppState (app_data) already contains an Arc<SigSocketService>.
|
||||||
|
// Handlers like websocket_handler that take web::Data<Arc<SigSocketService>> directly
|
||||||
|
// will be able to access it if AppState is correctly registered and the handler signature matches.
|
||||||
|
// Alternatively, if a handler needs *only* the SigSocketService, it can be added separately.
|
||||||
|
// For the websocket_handler as defined (taking web::Data<Arc<SigSocketService>>),
|
||||||
|
// it needs this specific type registered with app_data.
|
||||||
|
let sigsocket_service_for_ws_handler_data = web::Data::new(app_data.sigsocket_service.clone());
|
||||||
|
|
||||||
|
info!("Flowbroker server starting on http://127.0.0.1:8081");
|
||||||
|
info!("SigSocket WebSocket endpoint available at ws://127.0.0.1:8081/ws");
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(app_data.clone()) // Main app state (includes SigSocketService)
|
||||||
|
.app_data(sigsocket_service_for_ws_handler_data.clone()) // Specifically for handlers expecting web::Data<Arc<SigSocketService>>
|
||||||
|
.configure(configure_app_routes)
|
||||||
|
})
|
||||||
|
.bind("127.0.0.1:8081")? // Using a different port for now
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
34
flowbroker/start.sh
Executable file
34
flowbroker/start.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
FORCE_KILL=false
|
||||||
|
|
||||||
|
# Parse command line options
|
||||||
|
while getopts ":f" opt; do
|
||||||
|
case ${opt} in
|
||||||
|
f )
|
||||||
|
FORCE_KILL=true
|
||||||
|
;;
|
||||||
|
\? )
|
||||||
|
echo "Usage: cmd [-f]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FORCE_KILL" = true ] ; then
|
||||||
|
echo "Attempting to kill process on port 8081..."
|
||||||
|
# Get PID of process using port 8081 and kill it
|
||||||
|
# -t option for lsof outputs only the PID
|
||||||
|
# xargs -r ensures kill is only run if lsof finds a PID
|
||||||
|
lsof -t -i:8081 | xargs -r kill -9
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Process(es) on port 8081 killed."
|
||||||
|
else
|
||||||
|
echo "No process found on port 8081 or failed to kill."
|
||||||
|
fi
|
||||||
|
# Give a moment for the port to be released
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Flowbroker server..."
|
||||||
|
cargo run
|
||||||
127
flowbroker/static/style.css
Normal file
127
flowbroker/static/style.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flows-list ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flows-list li {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for dynamic form elements from create_flow.html */
|
||||||
|
.step, .requirement {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px; /* Increased padding */
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step h3, .step h4, .requirement h5 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #555; /* Slightly softer color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step .requirementsContainer {
|
||||||
|
margin-left: 20px;
|
||||||
|
border-left: 3px solid #007bff; /* Thicker border */
|
||||||
|
padding-left: 20px; /* Increased padding */
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.removeStepBtn, button.removeRequirementBtn {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px; /* Adjusted padding */
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px; /* Increased margin */
|
||||||
|
float: right; /* Align to the right */
|
||||||
|
}
|
||||||
|
button.removeStepBtn:hover, button.removeRequirementBtn:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clearfix for floated remove buttons */
|
||||||
|
.step::after, .requirement::after {
|
||||||
|
content: "";
|
||||||
|
clear: both;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn { /* Style for Add Step / Add Requirement buttons */
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.addBtn:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* General styling for form elements within steps/requirements for consistency */
|
||||||
|
.step input[type="text"], .step textarea,
|
||||||
|
.requirement input[type="text"], .requirement textarea {
|
||||||
|
margin-bottom: 8px; /* Add some space below inputs */
|
||||||
|
}
|
||||||
187
flowbroker/templates/create_flow.html
Normal file
187
flowbroker/templates/create_flow.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Flowbroker - Create Flow</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
.step, .requirement {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
.step h3, .step h4, .requirement h5 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.step .requirementsContainer {
|
||||||
|
margin-left: 20px;
|
||||||
|
border-left: 2px solid #007bff;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
button.removeStepBtn, button.removeRequirementBtn {
|
||||||
|
background-color: #dc3545;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
button.removeStepBtn:hover, button.removeRequirementBtn:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Create New Flow</h1>
|
||||||
|
<form id="createFlowForm" action="/flows" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="flow_name">Flow Name:</label>
|
||||||
|
<input type="text" id="flow_name" name="flow_name" required>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id="stepsContainer">
|
||||||
|
<!-- Steps will be added here by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="addStepBtn" class="addBtn">Add Step</button>
|
||||||
|
<hr>
|
||||||
|
<button type="submit">Create Flow</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/">Back to Flows List</a></p>
|
||||||
|
|
||||||
|
<!-- Template for a new step -->
|
||||||
|
<template id="stepTemplate">
|
||||||
|
<div class="step" data-step-index="">
|
||||||
|
<h3>Step <span class="step-number"></span></h3>
|
||||||
|
<button type="button" class="removeStepBtn">Remove This Step</button>
|
||||||
|
<div>
|
||||||
|
<label>Step Description (Optional):</label>
|
||||||
|
<input type="text" name="steps[X].description" class="step-description">
|
||||||
|
</div>
|
||||||
|
<h4>Signature Requirements for Step <span class="step-number"></span></h4>
|
||||||
|
<div class="requirementsContainer" data-step-index="">
|
||||||
|
<!-- Requirements will be added here -->
|
||||||
|
</div>
|
||||||
|
<button type="button" class="addRequirementBtn addBtn" data-step-index="">Add Signature Requirement</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Template for a new signature requirement -->
|
||||||
|
<template id="requirementTemplate">
|
||||||
|
<div class="requirement" data-req-index="">
|
||||||
|
<h5>Requirement <span class="req-number"></span></h5>
|
||||||
|
<button type="button" class="removeRequirementBtn">Remove Requirement</button>
|
||||||
|
<div>
|
||||||
|
<label>Message to Sign:</label>
|
||||||
|
<textarea name="steps[X].requirements[Y].message" rows="2" required class="req-message"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Required Public Key:</label>
|
||||||
|
<input type="text" name="steps[X].requirements[Y].public_key" required class="req-pubkey">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const stepsContainer = document.getElementById('stepsContainer');
|
||||||
|
const addStepBtn = document.getElementById('addStepBtn');
|
||||||
|
const stepTemplate = document.getElementById('stepTemplate');
|
||||||
|
const requirementTemplate = document.getElementById('requirementTemplate');
|
||||||
|
const form = document.getElementById('createFlowForm');
|
||||||
|
|
||||||
|
const updateIndices = () => {
|
||||||
|
const steps = stepsContainer.querySelectorAll('.step');
|
||||||
|
steps.forEach((step, stepIdx) => {
|
||||||
|
// Update step-level attributes and text
|
||||||
|
step.dataset.stepIndex = stepIdx;
|
||||||
|
step.querySelector('.step-number').textContent = stepIdx + 1;
|
||||||
|
step.querySelector('.step-description').name = `steps[${stepIdx}].description`;
|
||||||
|
|
||||||
|
const addReqBtn = step.querySelector('.addRequirementBtn');
|
||||||
|
if (addReqBtn) addReqBtn.dataset.stepIndex = stepIdx;
|
||||||
|
|
||||||
|
const requirements = step.querySelectorAll('.requirementsContainer .requirement');
|
||||||
|
requirements.forEach((req, reqIdx) => {
|
||||||
|
// Update requirement-level attributes and text
|
||||||
|
req.dataset.reqIndex = reqIdx;
|
||||||
|
req.querySelector('.req-number').textContent = reqIdx + 1;
|
||||||
|
req.querySelector('.req-message').name = `steps[${stepIdx}].requirements[${reqIdx}].message`;
|
||||||
|
req.querySelector('.req-pubkey').name = `steps[${stepIdx}].requirements[${reqIdx}].public_key`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRequirement = (currentStepElement, stepIndex) => {
|
||||||
|
const requirementsContainer = currentStepElement.querySelector('.requirementsContainer');
|
||||||
|
const reqFragment = requirementTemplate.content.cloneNode(true);
|
||||||
|
const newRequirement = reqFragment.querySelector('.requirement');
|
||||||
|
|
||||||
|
requirementsContainer.appendChild(newRequirement);
|
||||||
|
updateIndices(); // Update all indices after adding
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
const stepFragment = stepTemplate.content.cloneNode(true);
|
||||||
|
const newStep = stepFragment.querySelector('.step');
|
||||||
|
stepsContainer.appendChild(newStep);
|
||||||
|
|
||||||
|
// Add at least one requirement to the new step automatically
|
||||||
|
const currentStepIndex = stepsContainer.querySelectorAll('.step').length - 1;
|
||||||
|
addRequirement(newStep, currentStepIndex);
|
||||||
|
|
||||||
|
updateIndices(); // Update all indices after adding
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event delegation for remove buttons and add requirement button
|
||||||
|
stepsContainer.addEventListener('click', (event) => {
|
||||||
|
if (event.target.classList.contains('removeStepBtn')) {
|
||||||
|
event.target.closest('.step').remove();
|
||||||
|
if (stepsContainer.querySelectorAll('.step').length === 0) { // Ensure at least one step
|
||||||
|
addStep();
|
||||||
|
}
|
||||||
|
updateIndices();
|
||||||
|
} else if (event.target.classList.contains('addRequirementBtn')) {
|
||||||
|
const stepElement = event.target.closest('.step');
|
||||||
|
const stepIndex = parseInt(stepElement.dataset.stepIndex, 10);
|
||||||
|
addRequirement(stepElement, stepIndex);
|
||||||
|
} else if (event.target.classList.contains('removeRequirementBtn')) {
|
||||||
|
const requirementElement = event.target.closest('.requirement');
|
||||||
|
const stepElement = event.target.closest('.step');
|
||||||
|
const requirementsContainer = stepElement.querySelector('.requirementsContainer');
|
||||||
|
requirementElement.remove();
|
||||||
|
// Ensure at least one requirement per step
|
||||||
|
if (requirementsContainer.querySelectorAll('.requirement').length === 0) {
|
||||||
|
const stepIndex = parseInt(stepElement.dataset.stepIndex, 10);
|
||||||
|
addRequirement(stepElement, stepIndex);
|
||||||
|
}
|
||||||
|
updateIndices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addStepBtn.addEventListener('click', addStep);
|
||||||
|
|
||||||
|
// Add one step by default when the page loads
|
||||||
|
if (stepsContainer.children.length === 0) {
|
||||||
|
addStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Validate that there's at least one step and one requirement before submit
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (stepsContainer.querySelectorAll('.step').length === 0) {
|
||||||
|
alert('Please add at least one step to the flow.');
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const steps = stepsContainer.querySelectorAll('.step');
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
if (steps[i].querySelectorAll('.requirementsContainer .requirement').length === 0) {
|
||||||
|
alert(`Step ${i + 1} must have at least one signature requirement.`);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user