Compare commits
28 Commits
main
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
|
d3a66d4fc8 | ||
|
464e253739 | ||
|
7e95391a9c | ||
|
9802d51acc | ||
|
2299b61e79 | ||
|
b8928379de | ||
|
45c4f4985e | ||
|
58d1cde1ce | ||
|
d815d9d365 | ||
|
2827cfebc9 | ||
|
7b15606da5 | ||
|
11d7ae37b6 | ||
|
70ca9f1605 | ||
|
d12a082ca1 | ||
|
97e7a04827 | ||
|
3d8aca19cc | ||
|
52fbc77e3e | ||
|
fad288f67d | ||
|
4659697ae2 | ||
|
67b80f237d | ||
|
b606923102 | ||
|
8f1438dc01 | ||
|
916f435dbc | ||
|
5d9eaac1f8 | ||
|
9c71c63ec5 | ||
|
4a2f1c7282 | ||
|
60198dc2d4 | ||
|
e4e403e231 |
2
actix_mvc_app/.cargo/config.toml
Normal file
2
actix_mvc_app/.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
21
actix_mvc_app/.env.example
Normal file
21
actix_mvc_app/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# 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
Normal file
53
actix_mvc_app/.gitignore
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
# 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
|
1233
actix_mvc_app/Cargo.lock
generated
1233
actix_mvc_app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,14 @@ name = "actix_mvc_app"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "actix_mvc_app"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "actix_mvc_app"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
actix-multipart = "0.6.1"
|
||||
futures-util = "0.3.30"
|
||||
@ -15,6 +23,8 @@ env_logger = "0.11.2"
|
||||
log = "0.4.21"
|
||||
dotenv = "0.15.0"
|
||||
chrono = { version = "0.4.35", features = ["serde"] }
|
||||
heromodels = { path = "../../db/heromodels" }
|
||||
heromodels_core = { path = "../../db/heromodels_core" }
|
||||
config = "0.14.0"
|
||||
num_cpus = "1.16.0"
|
||||
futures = "0.3.30"
|
||||
@ -27,3 +37,24 @@ redis = { version = "0.23.0", features = ["tokio-comp"] }
|
||||
jsonwebtoken = "8.3.0"
|
||||
pulldown-cmark = "0.13.0"
|
||||
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" }
|
||||
|
||||
|
69
actix_mvc_app/Dockerfile.prod
Normal file
69
actix_mvc_app/Dockerfile.prod
Normal file
@ -0,0 +1,69 @@
|
||||
# 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"]
|
180
actix_mvc_app/PRODUCTION_CHECKLIST.md
Normal file
180
actix_mvc_app/PRODUCTION_CHECKLIST.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 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! 🎉
|
410
actix_mvc_app/PRODUCTION_DEPLOYMENT.md
Normal file
410
actix_mvc_app/PRODUCTION_DEPLOYMENT.md
Normal file
@ -0,0 +1,410 @@
|
||||
# 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.
|
100
actix_mvc_app/STRIPE_SETUP.md
Normal file
100
actix_mvc_app/STRIPE_SETUP.md
Normal file
@ -0,0 +1,100 @@
|
||||
# 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
|
17
actix_mvc_app/config/default.toml
Normal file
17
actix_mvc_app/config/default.toml
Normal file
@ -0,0 +1,17 @@
|
||||
# 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
|
18
actix_mvc_app/config/local.toml.example
Normal file
18
actix_mvc_app/config/local.toml.example
Normal file
@ -0,0 +1,18 @@
|
||||
# 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"
|
170
actix_mvc_app/docker-compose.prod.yml
Normal file
170
actix_mvc_app/docker-compose.prod.yml
Normal file
@ -0,0 +1,170 @@
|
||||
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 serde::Deserialize;
|
||||
use std::env;
|
||||
|
||||
/// Application configuration
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@ -9,10 +9,13 @@ pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
/// Template configuration
|
||||
pub templates: TemplateConfig,
|
||||
/// Stripe configuration
|
||||
pub stripe: StripeConfig,
|
||||
}
|
||||
|
||||
/// Server configuration
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ServerConfig {
|
||||
/// Host address to bind to
|
||||
pub host: String,
|
||||
@ -29,6 +32,17 @@ pub struct TemplateConfig {
|
||||
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 {
|
||||
/// Loads configuration from files and environment variables
|
||||
pub fn new() -> Result<Self, ConfigError> {
|
||||
@ -37,7 +51,10 @@ impl AppConfig {
|
||||
.set_default("server.host", "127.0.0.1")?
|
||||
.set_default("server.port", 9999)?
|
||||
.set_default("server.workers", None::<u32>)?
|
||||
.set_default("templates.dir", "./src/views")?;
|
||||
.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
|
||||
if let Ok(config_path) = env::var("APP_CONFIG") {
|
||||
@ -50,7 +67,8 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
|
||||
config_builder = config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
||||
config_builder =
|
||||
config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
||||
|
||||
// Build and deserialize the config
|
||||
let config = config_builder.build()?;
|
||||
|
@ -1,12 +1,13 @@
|
||||
use actix_web::{web, HttpResponse, Result};
|
||||
use tera::{Context, Tera};
|
||||
use chrono::{Utc, Duration};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use crate::models::asset::{Asset, AssetType, AssetStatus, BlockchainInfo, ValuationPoint, AssetTransaction, AssetStatistics};
|
||||
use crate::models::asset::{Asset, AssetStatistics, AssetStatus, AssetType, BlockchainInfo};
|
||||
use crate::utils::render_template;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AssetForm {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
@ -14,6 +15,7 @@ pub struct AssetForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ValuationForm {
|
||||
pub value: f64,
|
||||
pub currency: String,
|
||||
@ -22,6 +24,7 @@ pub struct ValuationForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TransactionForm {
|
||||
pub transaction_type: String,
|
||||
pub from_address: Option<String>,
|
||||
@ -80,10 +83,19 @@ impl AssetController {
|
||||
.map(|asset_type| {
|
||||
let mut map = serde_json::Map::new();
|
||||
let type_str = asset_type.as_str();
|
||||
let count = assets.iter().filter(|a| a.asset_type == *asset_type).count();
|
||||
let count = assets
|
||||
.iter()
|
||||
.filter(|a| a.asset_type == *asset_type)
|
||||
.count();
|
||||
|
||||
map.insert("type".to_string(), serde_json::Value::String(type_str.to_string()));
|
||||
map.insert("count".to_string(), serde_json::Value::Number(serde_json::Number::from(count)));
|
||||
map.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String(type_str.to_string()),
|
||||
);
|
||||
map.insert(
|
||||
"count".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(count)),
|
||||
);
|
||||
|
||||
map
|
||||
})
|
||||
@ -106,10 +118,8 @@ impl AssetController {
|
||||
let assets = Self::get_mock_assets();
|
||||
println!("DEBUG: Generated {} mock assets", assets.len());
|
||||
|
||||
let assets_data: Vec<serde_json::Map<String, serde_json::Value>> = assets
|
||||
.iter()
|
||||
.map(|a| Self::asset_to_json(a))
|
||||
.collect();
|
||||
let assets_data: Vec<serde_json::Map<String, serde_json::Value>> =
|
||||
assets.iter().map(|a| Self::asset_to_json(a)).collect();
|
||||
|
||||
// Add active_page for navigation highlighting
|
||||
context.insert("active_page", &"assets");
|
||||
@ -132,10 +142,8 @@ impl AssetController {
|
||||
let assets = Self::get_mock_assets();
|
||||
println!("DEBUG: Generated {} mock assets", assets.len());
|
||||
|
||||
let assets_data: Vec<serde_json::Map<String, serde_json::Value>> = assets
|
||||
.iter()
|
||||
.map(|a| Self::asset_to_json(a))
|
||||
.collect();
|
||||
let assets_data: Vec<serde_json::Map<String, serde_json::Value>> =
|
||||
assets.iter().map(|a| Self::asset_to_json(a)).collect();
|
||||
|
||||
// Add active_page for navigation highlighting
|
||||
context.insert("active_page", &"assets");
|
||||
@ -177,9 +185,20 @@ impl AssetController {
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("date".to_string(), serde_json::Value::String(v.date.format("%Y-%m-%d").to_string()));
|
||||
map.insert("value".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(v.value).unwrap()));
|
||||
map.insert("currency".to_string(), serde_json::Value::String(v.currency.clone()));
|
||||
map.insert(
|
||||
"date".to_string(),
|
||||
serde_json::Value::String(v.date.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
map.insert(
|
||||
"value".to_string(),
|
||||
serde_json::Value::Number(
|
||||
serde_json::Number::from_f64(v.value).unwrap(),
|
||||
),
|
||||
);
|
||||
map.insert(
|
||||
"currency".to_string(),
|
||||
serde_json::Value::String(v.currency.clone()),
|
||||
);
|
||||
map
|
||||
})
|
||||
.collect();
|
||||
@ -190,7 +209,7 @@ impl AssetController {
|
||||
let response = render_template(&tmpl, "assets/detail.html", &context);
|
||||
println!("DEBUG: Finished rendering asset detail template");
|
||||
response
|
||||
},
|
||||
}
|
||||
None => {
|
||||
println!("DEBUG: Asset not found with ID {}", asset_id);
|
||||
Ok(HttpResponse::NotFound().finish())
|
||||
@ -216,7 +235,7 @@ impl AssetController {
|
||||
("Share", "Share"),
|
||||
("Bond", "Bond"),
|
||||
("IntellectualProperty", "Intellectual Property"),
|
||||
("Other", "Other")
|
||||
("Other", "Other"),
|
||||
];
|
||||
|
||||
context.insert("asset_types", &asset_types);
|
||||
@ -237,7 +256,9 @@ impl AssetController {
|
||||
// In a real application, we would save the asset to the database
|
||||
// For now, we'll just redirect to the assets list
|
||||
|
||||
Ok(HttpResponse::Found().append_header(("Location", "/assets")).finish())
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/assets"))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Add a valuation to an asset
|
||||
@ -253,7 +274,9 @@ impl AssetController {
|
||||
// In a real application, we would update the asset in the database
|
||||
// For now, we'll just redirect to the asset detail page
|
||||
|
||||
Ok(HttpResponse::Found().append_header(("Location", format!("/assets/{}", asset_id))).finish())
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/assets/{}", asset_id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Add a transaction to an asset
|
||||
@ -269,7 +292,9 @@ impl AssetController {
|
||||
// In a real application, we would update the asset in the database
|
||||
// For now, we'll just redirect to the asset detail page
|
||||
|
||||
Ok(HttpResponse::Found().append_header(("Location", format!("/assets/{}", asset_id))).finish())
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/assets/{}", asset_id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Update the status of an asset
|
||||
@ -284,7 +309,9 @@ impl AssetController {
|
||||
// In a real application, we would update the asset in the database
|
||||
// For now, we'll just redirect to the asset detail page
|
||||
|
||||
Ok(HttpResponse::Found().append_header(("Location", format!("/assets/{}", asset_id))).finish())
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/assets/{}", asset_id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Test method to render a simple test page
|
||||
@ -325,118 +352,233 @@ impl AssetController {
|
||||
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut map = serde_json::Map::new();
|
||||
|
||||
map.insert("id".to_string(), serde_json::Value::String(asset.id.clone()));
|
||||
map.insert("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()));
|
||||
map.insert("owner_id".to_string(), serde_json::Value::String(asset.owner_id.clone()));
|
||||
map.insert("owner_name".to_string(), serde_json::Value::String(asset.owner_name.clone()));
|
||||
map.insert("created_at".to_string(), serde_json::Value::String(asset.created_at.format("%Y-%m-%d").to_string()));
|
||||
map.insert("updated_at".to_string(), serde_json::Value::String(asset.updated_at.format("%Y-%m-%d").to_string()));
|
||||
map.insert(
|
||||
"id".to_string(),
|
||||
serde_json::Value::String(asset.id.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"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()),
|
||||
);
|
||||
map.insert(
|
||||
"owner_id".to_string(),
|
||||
serde_json::Value::String(asset.owner_id.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"owner_name".to_string(),
|
||||
serde_json::Value::String(asset.owner_name.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"created_at".to_string(),
|
||||
serde_json::Value::String(asset.created_at.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
map.insert(
|
||||
"updated_at".to_string(),
|
||||
serde_json::Value::String(asset.updated_at.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
|
||||
// Add current valuation if available
|
||||
if let Some(current_valuation) = asset.current_valuation {
|
||||
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(current_valuation).unwrap()));
|
||||
map.insert(
|
||||
"current_valuation".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(current_valuation).unwrap()),
|
||||
);
|
||||
|
||||
if let Some(valuation_currency) = &asset.valuation_currency {
|
||||
map.insert("valuation_currency".to_string(), serde_json::Value::String(valuation_currency.clone()));
|
||||
map.insert(
|
||||
"valuation_currency".to_string(),
|
||||
serde_json::Value::String(valuation_currency.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(valuation_date) = asset.valuation_date {
|
||||
map.insert("valuation_date".to_string(), serde_json::Value::String(valuation_date.format("%Y-%m-%d").to_string()));
|
||||
map.insert(
|
||||
"valuation_date".to_string(),
|
||||
serde_json::Value::String(valuation_date.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add blockchain info if available
|
||||
if let Some(blockchain_info) = &asset.blockchain_info {
|
||||
let mut blockchain_map = serde_json::Map::new();
|
||||
blockchain_map.insert("blockchain".to_string(), serde_json::Value::String(blockchain_info.blockchain.clone()));
|
||||
blockchain_map.insert("token_id".to_string(), serde_json::Value::String(blockchain_info.token_id.clone()));
|
||||
blockchain_map.insert("contract_address".to_string(), serde_json::Value::String(blockchain_info.contract_address.clone()));
|
||||
blockchain_map.insert("owner_address".to_string(), serde_json::Value::String(blockchain_info.owner_address.clone()));
|
||||
blockchain_map.insert(
|
||||
"blockchain".to_string(),
|
||||
serde_json::Value::String(blockchain_info.blockchain.clone()),
|
||||
);
|
||||
blockchain_map.insert(
|
||||
"token_id".to_string(),
|
||||
serde_json::Value::String(blockchain_info.token_id.clone()),
|
||||
);
|
||||
blockchain_map.insert(
|
||||
"contract_address".to_string(),
|
||||
serde_json::Value::String(blockchain_info.contract_address.clone()),
|
||||
);
|
||||
blockchain_map.insert(
|
||||
"owner_address".to_string(),
|
||||
serde_json::Value::String(blockchain_info.owner_address.clone()),
|
||||
);
|
||||
|
||||
if let Some(transaction_hash) = &blockchain_info.transaction_hash {
|
||||
blockchain_map.insert("transaction_hash".to_string(), serde_json::Value::String(transaction_hash.clone()));
|
||||
blockchain_map.insert(
|
||||
"transaction_hash".to_string(),
|
||||
serde_json::Value::String(transaction_hash.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(block_number) = blockchain_info.block_number {
|
||||
blockchain_map.insert("block_number".to_string(), serde_json::Value::Number(serde_json::Number::from(block_number)));
|
||||
blockchain_map.insert(
|
||||
"block_number".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(block_number)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(timestamp) = blockchain_info.timestamp {
|
||||
blockchain_map.insert("timestamp".to_string(), serde_json::Value::String(timestamp.format("%Y-%m-%d").to_string()));
|
||||
blockchain_map.insert(
|
||||
"timestamp".to_string(),
|
||||
serde_json::Value::String(timestamp.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
map.insert("blockchain_info".to_string(), serde_json::Value::Object(blockchain_map));
|
||||
map.insert(
|
||||
"blockchain_info".to_string(),
|
||||
serde_json::Value::Object(blockchain_map),
|
||||
);
|
||||
}
|
||||
|
||||
// Add valuation history
|
||||
let valuation_history: Vec<serde_json::Value> = asset.valuation_history.iter()
|
||||
let valuation_history: Vec<serde_json::Value> = asset
|
||||
.valuation_history
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut valuation_map = serde_json::Map::new();
|
||||
valuation_map.insert("id".to_string(), serde_json::Value::String(v.id.clone()));
|
||||
valuation_map.insert("date".to_string(), serde_json::Value::String(v.date.format("%Y-%m-%d").to_string()));
|
||||
valuation_map.insert("value".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(v.value).unwrap()));
|
||||
valuation_map.insert("currency".to_string(), serde_json::Value::String(v.currency.clone()));
|
||||
valuation_map.insert("source".to_string(), serde_json::Value::String(v.source.clone()));
|
||||
valuation_map.insert(
|
||||
"date".to_string(),
|
||||
serde_json::Value::String(v.date.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
valuation_map.insert(
|
||||
"value".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(v.value).unwrap()),
|
||||
);
|
||||
valuation_map.insert(
|
||||
"currency".to_string(),
|
||||
serde_json::Value::String(v.currency.clone()),
|
||||
);
|
||||
valuation_map.insert(
|
||||
"source".to_string(),
|
||||
serde_json::Value::String(v.source.clone()),
|
||||
);
|
||||
|
||||
if let Some(notes) = &v.notes {
|
||||
valuation_map.insert("notes".to_string(), serde_json::Value::String(notes.clone()));
|
||||
valuation_map.insert(
|
||||
"notes".to_string(),
|
||||
serde_json::Value::String(notes.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(valuation_map)
|
||||
})
|
||||
.collect();
|
||||
|
||||
map.insert("valuation_history".to_string(), serde_json::Value::Array(valuation_history));
|
||||
map.insert(
|
||||
"valuation_history".to_string(),
|
||||
serde_json::Value::Array(valuation_history),
|
||||
);
|
||||
|
||||
// Add transaction history
|
||||
let transaction_history: Vec<serde_json::Value> = asset.transaction_history.iter()
|
||||
let transaction_history: Vec<serde_json::Value> = asset
|
||||
.transaction_history
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut transaction_map = serde_json::Map::new();
|
||||
transaction_map.insert("id".to_string(), serde_json::Value::String(t.id.clone()));
|
||||
transaction_map.insert("transaction_type".to_string(), serde_json::Value::String(t.transaction_type.clone()));
|
||||
transaction_map.insert("date".to_string(), serde_json::Value::String(t.date.format("%Y-%m-%d").to_string()));
|
||||
transaction_map.insert(
|
||||
"transaction_type".to_string(),
|
||||
serde_json::Value::String(t.transaction_type.clone()),
|
||||
);
|
||||
transaction_map.insert(
|
||||
"date".to_string(),
|
||||
serde_json::Value::String(t.date.format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
|
||||
if let Some(from_address) = &t.from_address {
|
||||
transaction_map.insert("from_address".to_string(), serde_json::Value::String(from_address.clone()));
|
||||
transaction_map.insert(
|
||||
"from_address".to_string(),
|
||||
serde_json::Value::String(from_address.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(to_address) = &t.to_address {
|
||||
transaction_map.insert("to_address".to_string(), serde_json::Value::String(to_address.clone()));
|
||||
transaction_map.insert(
|
||||
"to_address".to_string(),
|
||||
serde_json::Value::String(to_address.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(amount) = t.amount {
|
||||
transaction_map.insert("amount".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(amount).unwrap()));
|
||||
transaction_map.insert(
|
||||
"amount".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(amount).unwrap()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(currency) = &t.currency {
|
||||
transaction_map.insert("currency".to_string(), serde_json::Value::String(currency.clone()));
|
||||
transaction_map.insert(
|
||||
"currency".to_string(),
|
||||
serde_json::Value::String(currency.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(transaction_hash) = &t.transaction_hash {
|
||||
transaction_map.insert("transaction_hash".to_string(), serde_json::Value::String(transaction_hash.clone()));
|
||||
transaction_map.insert(
|
||||
"transaction_hash".to_string(),
|
||||
serde_json::Value::String(transaction_hash.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(notes) = &t.notes {
|
||||
transaction_map.insert("notes".to_string(), serde_json::Value::String(notes.clone()));
|
||||
transaction_map.insert(
|
||||
"notes".to_string(),
|
||||
serde_json::Value::String(notes.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(transaction_map)
|
||||
})
|
||||
.collect();
|
||||
|
||||
map.insert("transaction_history".to_string(), serde_json::Value::Array(transaction_history));
|
||||
map.insert(
|
||||
"transaction_history".to_string(),
|
||||
serde_json::Value::Array(transaction_history),
|
||||
);
|
||||
|
||||
// Add image URL if available
|
||||
if let Some(image_url) = &asset.image_url {
|
||||
map.insert("image_url".to_string(), serde_json::Value::String(image_url.clone()));
|
||||
map.insert(
|
||||
"image_url".to_string(),
|
||||
serde_json::Value::String(image_url.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// Add external URL if available
|
||||
if let Some(external_url) = &asset.external_url {
|
||||
map.insert("external_url".to_string(), serde_json::Value::String(external_url.clone()));
|
||||
map.insert(
|
||||
"external_url".to_string(),
|
||||
serde_json::Value::String(external_url.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
map
|
||||
@ -481,14 +623,31 @@ impl AssetController {
|
||||
token_id: "ZRESORT".to_string(),
|
||||
contract_address: "0x123456789abcdef123456789abcdef12345678ab".to_string(),
|
||||
owner_address: "0xc3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4".to_string(),
|
||||
transaction_hash: Some("0xabcdef123456789abcdef123456789abcdef123456789abcdef123456789abcd".to_string()),
|
||||
transaction_hash: Some(
|
||||
"0xabcdef123456789abcdef123456789abcdef123456789abcdef123456789abcd".to_string(),
|
||||
),
|
||||
block_number: Some(9876543),
|
||||
timestamp: Some(now - Duration::days(120)),
|
||||
});
|
||||
|
||||
zanzibar_resort.add_valuation(650000.0, "USD", "ZDFZ Property Registry", Some("Initial tokenization valuation".to_string()));
|
||||
zanzibar_resort.add_valuation(700000.0, "USD", "International Property Appraisers", Some("Independent third-party valuation".to_string()));
|
||||
zanzibar_resort.add_valuation(750000.0, "USD", "ZDFZ Property Registry", Some("Updated valuation after infrastructure improvements".to_string()));
|
||||
zanzibar_resort.add_valuation(
|
||||
650000.0,
|
||||
"USD",
|
||||
"ZDFZ Property Registry",
|
||||
Some("Initial tokenization valuation".to_string()),
|
||||
);
|
||||
zanzibar_resort.add_valuation(
|
||||
700000.0,
|
||||
"USD",
|
||||
"International Property Appraisers",
|
||||
Some("Independent third-party valuation".to_string()),
|
||||
);
|
||||
zanzibar_resort.add_valuation(
|
||||
750000.0,
|
||||
"USD",
|
||||
"ZDFZ Property Registry",
|
||||
Some("Updated valuation after infrastructure improvements".to_string()),
|
||||
);
|
||||
|
||||
zanzibar_resort.add_transaction(
|
||||
"Tokenization",
|
||||
@ -550,9 +709,24 @@ impl AssetController {
|
||||
timestamp: Some(now - Duration::days(365)),
|
||||
});
|
||||
|
||||
zaz_token.add_valuation(300000.0, "USD", "ZDFZ Token Exchange", Some("Initial valuation at launch".to_string()));
|
||||
zaz_token.add_valuation(320000.0, "USD", "ZDFZ Token Exchange", Some("Valuation after successful governance implementation".to_string()));
|
||||
zaz_token.add_valuation(350000.0, "USD", "ZDFZ Token Exchange", Some("Current market valuation".to_string()));
|
||||
zaz_token.add_valuation(
|
||||
300000.0,
|
||||
"USD",
|
||||
"ZDFZ Token Exchange",
|
||||
Some("Initial valuation at launch".to_string()),
|
||||
);
|
||||
zaz_token.add_valuation(
|
||||
320000.0,
|
||||
"USD",
|
||||
"ZDFZ Token Exchange",
|
||||
Some("Valuation after successful governance implementation".to_string()),
|
||||
);
|
||||
zaz_token.add_valuation(
|
||||
350000.0,
|
||||
"USD",
|
||||
"ZDFZ Token Exchange",
|
||||
Some("Current market valuation".to_string()),
|
||||
);
|
||||
|
||||
zaz_token.add_transaction(
|
||||
"Distribution",
|
||||
@ -610,14 +784,31 @@ impl AssetController {
|
||||
token_id: "SPICE".to_string(),
|
||||
contract_address: "0x3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4".to_string(),
|
||||
owner_address: "0x6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b".to_string(),
|
||||
transaction_hash: Some("0x789abcdef123456789abcdef123456789abcdef123456789abcdef123456789a".to_string()),
|
||||
transaction_hash: Some(
|
||||
"0x789abcdef123456789abcdef123456789abcdef123456789abcdef123456789a".to_string(),
|
||||
),
|
||||
block_number: Some(7654321),
|
||||
timestamp: Some(now - Duration::days(180)),
|
||||
});
|
||||
|
||||
spice_trade_shares.add_valuation(150000.0, "USD", "ZDFZ Business Registry", Some("Initial company valuation at incorporation".to_string()));
|
||||
spice_trade_shares.add_valuation(175000.0, "USD", "ZDFZ Business Registry", Some("Valuation after first export contracts".to_string()));
|
||||
spice_trade_shares.add_valuation(200000.0, "USD", "ZDFZ Business Registry", Some("Current valuation after expansion to European markets".to_string()));
|
||||
spice_trade_shares.add_valuation(
|
||||
150000.0,
|
||||
"USD",
|
||||
"ZDFZ Business Registry",
|
||||
Some("Initial company valuation at incorporation".to_string()),
|
||||
);
|
||||
spice_trade_shares.add_valuation(
|
||||
175000.0,
|
||||
"USD",
|
||||
"ZDFZ Business Registry",
|
||||
Some("Valuation after first export contracts".to_string()),
|
||||
);
|
||||
spice_trade_shares.add_valuation(
|
||||
200000.0,
|
||||
"USD",
|
||||
"ZDFZ Business Registry",
|
||||
Some("Current valuation after expansion to European markets".to_string()),
|
||||
);
|
||||
|
||||
spice_trade_shares.add_transaction(
|
||||
"Share Issuance",
|
||||
@ -675,14 +866,31 @@ impl AssetController {
|
||||
token_id: "TIDALIP".to_string(),
|
||||
contract_address: "0x2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3".to_string(),
|
||||
owner_address: "0x4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f".to_string(),
|
||||
transaction_hash: Some("0x56789abcdef123456789abcdef123456789abcdef123456789abcdef12345678".to_string()),
|
||||
transaction_hash: Some(
|
||||
"0x56789abcdef123456789abcdef123456789abcdef123456789abcdef12345678".to_string(),
|
||||
),
|
||||
block_number: Some(5432109),
|
||||
timestamp: Some(now - Duration::days(120)),
|
||||
});
|
||||
|
||||
tidal_energy_patent.add_valuation(80000.0, "USD", "ZDFZ IP Registry", Some("Initial patent valuation upon filing".to_string()));
|
||||
tidal_energy_patent.add_valuation(100000.0, "USD", "ZDFZ IP Registry", Some("Valuation after successful prototype testing".to_string()));
|
||||
tidal_energy_patent.add_valuation(120000.0, "USD", "ZDFZ IP Registry", Some("Current valuation after pilot implementation".to_string()));
|
||||
tidal_energy_patent.add_valuation(
|
||||
80000.0,
|
||||
"USD",
|
||||
"ZDFZ IP Registry",
|
||||
Some("Initial patent valuation upon filing".to_string()),
|
||||
);
|
||||
tidal_energy_patent.add_valuation(
|
||||
100000.0,
|
||||
"USD",
|
||||
"ZDFZ IP Registry",
|
||||
Some("Valuation after successful prototype testing".to_string()),
|
||||
);
|
||||
tidal_energy_patent.add_valuation(
|
||||
120000.0,
|
||||
"USD",
|
||||
"ZDFZ IP Registry",
|
||||
Some("Current valuation after pilot implementation".to_string()),
|
||||
);
|
||||
|
||||
tidal_energy_patent.add_transaction(
|
||||
"Registration",
|
||||
@ -740,14 +948,31 @@ impl AssetController {
|
||||
token_id: "HERITAGE1".to_string(),
|
||||
contract_address: "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d".to_string(),
|
||||
owner_address: "0xb794f5ea0ba39494ce839613fffba74279579268".to_string(),
|
||||
transaction_hash: Some("0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string()),
|
||||
transaction_hash: Some(
|
||||
"0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(),
|
||||
),
|
||||
block_number: Some(12345678),
|
||||
timestamp: Some(now - Duration::days(90)),
|
||||
});
|
||||
|
||||
zanzibar_heritage_nft.add_valuation(5000.0, "USD", "ZDFZ Artwork Marketplace", Some("Initial offering price".to_string()));
|
||||
zanzibar_heritage_nft.add_valuation(5500.0, "USD", "ZDFZ Artwork Marketplace", Some("Valuation after artist exhibition".to_string()));
|
||||
zanzibar_heritage_nft.add_valuation(6000.0, "USD", "ZDFZ Artwork Marketplace", Some("Current market valuation".to_string()));
|
||||
zanzibar_heritage_nft.add_valuation(
|
||||
5000.0,
|
||||
"USD",
|
||||
"ZDFZ Artwork Marketplace",
|
||||
Some("Initial offering price".to_string()),
|
||||
);
|
||||
zanzibar_heritage_nft.add_valuation(
|
||||
5500.0,
|
||||
"USD",
|
||||
"ZDFZ Artwork Marketplace",
|
||||
Some("Valuation after artist exhibition".to_string()),
|
||||
);
|
||||
zanzibar_heritage_nft.add_valuation(
|
||||
6000.0,
|
||||
"USD",
|
||||
"ZDFZ Artwork Marketplace",
|
||||
Some("Current market valuation".to_string()),
|
||||
);
|
||||
|
||||
zanzibar_heritage_nft.add_transaction(
|
||||
"Minting",
|
||||
|
@ -25,6 +25,7 @@ lazy_static! {
|
||||
/// Controller for handling authentication-related routes
|
||||
pub struct AuthController;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AuthController {
|
||||
/// Generate a JWT token for a user
|
||||
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
|
@ -1,12 +1,17 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Result};
|
||||
use actix_session::Session;
|
||||
use actix_web::{HttpResponse, Responder, Result, web};
|
||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tera::Tera;
|
||||
use serde_json::Value;
|
||||
use tera::Tera;
|
||||
|
||||
use crate::models::{CalendarEvent, CalendarViewMode};
|
||||
use crate::utils::{RedisCalendarService, render_template};
|
||||
use crate::db::calendar::{
|
||||
add_event_to_calendar, create_new_event, delete_event, get_events, get_or_create_user_calendar,
|
||||
};
|
||||
use crate::models::CalendarViewMode;
|
||||
use crate::utils::render_template;
|
||||
use heromodels::models::calendar::Event;
|
||||
use heromodels_core::Model;
|
||||
|
||||
/// Controller for handling calendar-related routes
|
||||
pub struct CalendarController;
|
||||
@ -14,9 +19,11 @@ pub struct CalendarController;
|
||||
impl CalendarController {
|
||||
/// Helper function to get user from session
|
||||
fn get_user_from_session(session: &Session) -> Option<Value> {
|
||||
session.get::<String>("user").ok().flatten().and_then(|user_json| {
|
||||
serde_json::from_str(&user_json).ok()
|
||||
})
|
||||
session
|
||||
.get::<String>("user")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|user_json| serde_json::from_str(&user_json).ok())
|
||||
}
|
||||
|
||||
/// Handles the calendar page route
|
||||
@ -29,13 +36,16 @@ impl CalendarController {
|
||||
ctx.insert("active_page", "calendar");
|
||||
|
||||
// Parse the view mode from the query parameters
|
||||
let view_mode = CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
||||
let view_mode =
|
||||
CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
||||
ctx.insert("view_mode", &view_mode.to_str());
|
||||
|
||||
// Parse the date from the query parameters or use the current date
|
||||
let date = if let Some(date_str) = &query.date {
|
||||
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
Ok(naive_date) => Utc.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap()).into(),
|
||||
Ok(naive_date) => Utc
|
||||
.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap())
|
||||
.into(),
|
||||
Err(_) => Utc::now(),
|
||||
}
|
||||
} else {
|
||||
@ -47,44 +57,99 @@ impl CalendarController {
|
||||
ctx.insert("current_month", &date.month());
|
||||
ctx.insert("current_day", &date.day());
|
||||
|
||||
// Add user to context if available
|
||||
// Add user to context if available and ensure user has a calendar
|
||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||
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
|
||||
let (start_date, end_date) = match view_mode {
|
||||
CalendarViewMode::Year => {
|
||||
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
|
||||
let end = Utc.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59).unwrap();
|
||||
let end = Utc
|
||||
.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59)
|
||||
.unwrap();
|
||||
(start, end)
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Month => {
|
||||
let start = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
||||
let start = Utc
|
||||
.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 end = Utc.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59).unwrap();
|
||||
let end = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59)
|
||||
.unwrap();
|
||||
(start, end)
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Week => {
|
||||
// Calculate the start of the week (Sunday)
|
||||
let _weekday = date.weekday().num_days_from_sunday();
|
||||
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();
|
||||
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();
|
||||
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
|
||||
let end = start + chrono::Duration::days(7);
|
||||
(start, end)
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Day => {
|
||||
let start = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0).unwrap();
|
||||
let end = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59).unwrap();
|
||||
let start = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
||||
.unwrap();
|
||||
let end = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59)
|
||||
.unwrap();
|
||||
(start, end)
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// Get events from Redis
|
||||
let events = match RedisCalendarService::get_events_in_range(start_date, end_date) {
|
||||
Ok(events) => events,
|
||||
// Get events from database
|
||||
let events = match get_events() {
|
||||
Ok(db_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) => {
|
||||
log::error!("Failed to get events from Redis: {}", e);
|
||||
log::error!("Failed to get events from database: {}", e);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
@ -94,7 +159,8 @@ impl CalendarController {
|
||||
// Generate calendar data based on the view mode
|
||||
match view_mode {
|
||||
CalendarViewMode::Year => {
|
||||
let months = (1..=12).map(|month| {
|
||||
let months = (1..=12)
|
||||
.map(|month| {
|
||||
let month_name = match month {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
@ -111,7 +177,8 @@ impl CalendarController {
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let month_events = events.iter()
|
||||
let month_events = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.start_time.month() == month || event.end_time.month() == month
|
||||
})
|
||||
@ -123,13 +190,16 @@ impl CalendarController {
|
||||
name: month_name.to_string(),
|
||||
events: month_events,
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ctx.insert("months", &months);
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Month => {
|
||||
let days_in_month = Self::last_day_of_month(date.year(), date.month());
|
||||
let first_day = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
||||
let first_day = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
|
||||
.unwrap();
|
||||
let first_weekday = first_day.weekday().num_days_from_sunday();
|
||||
|
||||
let mut calendar_days = Vec::new();
|
||||
@ -145,13 +215,20 @@ impl CalendarController {
|
||||
|
||||
// Add days for the current month
|
||||
for day in 1..=days_in_month {
|
||||
let day_events = events.iter()
|
||||
let day_events = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
let day_start = Utc.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0).unwrap();
|
||||
let day_end = Utc.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59).unwrap();
|
||||
let day_start = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0)
|
||||
.unwrap();
|
||||
let day_end = Utc
|
||||
.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59)
|
||||
.unwrap();
|
||||
|
||||
(event.start_time <= day_end && event.end_time >= day_start) ||
|
||||
(event.all_day && event.start_time.day() <= day && event.end_time.day() >= day)
|
||||
(event.start_time <= day_end && event.end_time >= day_start)
|
||||
|| (event.all_day
|
||||
&& event.start_time.day() <= day
|
||||
&& event.end_time.day() >= day)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
@ -175,7 +252,7 @@ impl CalendarController {
|
||||
|
||||
ctx.insert("calendar_days", &calendar_days);
|
||||
ctx.insert("month_name", &Self::month_name(date.month()));
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Week => {
|
||||
// Calculate the start of the week (Sunday)
|
||||
let weekday = date.weekday().num_days_from_sunday();
|
||||
@ -184,13 +261,34 @@ impl CalendarController {
|
||||
let mut week_days = Vec::new();
|
||||
for i in 0..7 {
|
||||
let day_date = week_start + chrono::Duration::days(i);
|
||||
let day_events = events.iter()
|
||||
let day_events = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
let day_start = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), 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();
|
||||
let day_start = Utc
|
||||
.with_ymd_and_hms(
|
||||
day_date.year(),
|
||||
day_date.month(),
|
||||
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())
|
||||
(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()
|
||||
.collect::<Vec<_>>();
|
||||
@ -203,16 +301,22 @@ impl CalendarController {
|
||||
}
|
||||
|
||||
ctx.insert("week_days", &week_days);
|
||||
},
|
||||
}
|
||||
CalendarViewMode::Day => {
|
||||
log::info!("Day view selected");
|
||||
ctx.insert("day_name", &Self::day_name(date.weekday().num_days_from_sunday()));
|
||||
ctx.insert(
|
||||
"day_name",
|
||||
&Self::day_name(date.weekday().num_days_from_sunday()),
|
||||
);
|
||||
|
||||
// Add debug info
|
||||
log::info!("Events count: {}", events.len());
|
||||
log::info!("Current date: {}", date.format("%Y-%m-%d"));
|
||||
log::info!("Day name: {}", Self::day_name(date.weekday().num_days_from_sunday()));
|
||||
},
|
||||
log::info!(
|
||||
"Day name: {}",
|
||||
Self::day_name(date.weekday().num_days_from_sunday())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render_template(&tmpl, "calendar/index.html", &ctx)
|
||||
@ -223,9 +327,24 @@ impl CalendarController {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "calendar");
|
||||
|
||||
// Add user to context if available
|
||||
// Add user to context if available and ensure user has a calendar
|
||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||
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)
|
||||
@ -237,44 +356,91 @@ impl CalendarController {
|
||||
tmpl: web::Data<Tera>,
|
||||
_session: Session,
|
||||
) -> 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
|
||||
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
|
||||
Ok(dt) => dt.with_timezone(&Utc),
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse start time: {}", e);
|
||||
return Ok(HttpResponse::BadRequest().body("Invalid start time"));
|
||||
log::error!("Failed to parse start time '{}': {}", form.start_time, e);
|
||||
return Ok(HttpResponse::BadRequest().body("Invalid start time format"));
|
||||
}
|
||||
};
|
||||
|
||||
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
|
||||
Ok(dt) => dt.with_timezone(&Utc),
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse end time: {}", e);
|
||||
return Ok(HttpResponse::BadRequest().body("Invalid end time"));
|
||||
log::error!("Failed to parse end time '{}': {}", form.end_time, e);
|
||||
return Ok(HttpResponse::BadRequest().body("Invalid end time format"));
|
||||
}
|
||||
};
|
||||
|
||||
// Create the event
|
||||
let event = CalendarEvent::new(
|
||||
form.title.clone(),
|
||||
form.description.clone(),
|
||||
// Get user information from session
|
||||
let user_info = Self::get_user_from_session(&_session);
|
||||
let (user_id, user_name) = if let Some(user) = &user_info {
|
||||
let id = user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||
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,
|
||||
end_time,
|
||||
Some(form.color.clone()),
|
||||
None, // location
|
||||
Some(&form.color),
|
||||
form.all_day,
|
||||
None, // User ID would come from session in a real app
|
||||
);
|
||||
user_id,
|
||||
None, // category
|
||||
None, // reminder_minutes
|
||||
) {
|
||||
Ok((event_id, _saved_event)) => {
|
||||
log::info!("Created event with ID: {}", event_id);
|
||||
|
||||
// Save the event to Redis
|
||||
match RedisCalendarService::save_event(&event) {
|
||||
// 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
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", "/calendar"))
|
||||
.finish())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to save event to Redis: {}", e);
|
||||
log::error!("Failed to save event to database: {}", e);
|
||||
|
||||
// Show an error message
|
||||
let mut ctx = tera::Context::new();
|
||||
@ -282,13 +448,15 @@ impl CalendarController {
|
||||
ctx.insert("error", "Failed to save event");
|
||||
|
||||
// Add user to context if available
|
||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||
if let Some(user) = user_info {
|
||||
ctx.insert("user", &user);
|
||||
}
|
||||
|
||||
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
|
||||
|
||||
Ok(HttpResponse::InternalServerError().content_type("text/html").body(result.into_body()))
|
||||
Ok(HttpResponse::InternalServerError()
|
||||
.content_type("text/html")
|
||||
.body(result.into_body()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -300,16 +468,26 @@ impl CalendarController {
|
||||
) -> Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
// Delete the event from Redis
|
||||
match RedisCalendarService::delete_event(&id) {
|
||||
// Parse the event ID
|
||||
let event_id = match id.parse::<u32>() {
|
||||
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(_) => {
|
||||
log::info!("Deleted event with ID: {}", event_id);
|
||||
// Redirect to the calendar page
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", "/calendar"))
|
||||
.finish())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to delete event from Redis: {}", e);
|
||||
log::error!("Failed to delete event from database: {}", e);
|
||||
Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
|
||||
}
|
||||
}
|
||||
@ -326,7 +504,7 @@ impl CalendarController {
|
||||
} else {
|
||||
28
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => 30, // Default to 30 days
|
||||
}
|
||||
}
|
||||
@ -387,7 +565,7 @@ pub struct EventForm {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CalendarDay {
|
||||
day: u32,
|
||||
events: Vec<CalendarEvent>,
|
||||
events: Vec<Event>,
|
||||
is_current_month: bool,
|
||||
}
|
||||
|
||||
@ -396,5 +574,5 @@ struct CalendarDay {
|
||||
struct CalendarMonth {
|
||||
month: u32,
|
||||
name: String,
|
||||
events: Vec<CalendarEvent>,
|
||||
events: Vec<Event>,
|
||||
}
|
@ -1,12 +1,20 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Result};
|
||||
use actix_web::HttpRequest;
|
||||
use tera::{Context, Tera};
|
||||
use serde::Deserialize;
|
||||
use chrono::Utc;
|
||||
use crate::config::get_config;
|
||||
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::{HttpResponse, Result, web};
|
||||
|
||||
use heromodels::models::biz::{BusinessType, CompanyStatus};
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
// Form structs for company operations
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CompanyRegistrationForm {
|
||||
pub company_name: String,
|
||||
pub company_type: String,
|
||||
@ -14,25 +22,54 @@ pub struct CompanyRegistrationForm {
|
||||
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;
|
||||
|
||||
impl CompanyController {
|
||||
// Display the company management dashboard
|
||||
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
||||
let mut context = Context::new();
|
||||
|
||||
println!("DEBUG: Starting Company dashboard rendering");
|
||||
let config = get_config();
|
||||
|
||||
// Add active_page for navigation highlighting
|
||||
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
|
||||
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 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);
|
||||
@ -41,145 +78,226 @@ impl CompanyController {
|
||||
// Check for entity context
|
||||
if let Some(pos) = query_string.find("entity=") {
|
||||
let start = pos + 7; // length of "entity="
|
||||
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start);
|
||||
let end = query_string[start..]
|
||||
.find('&')
|
||||
.map_or(query_string.len(), |e| e + start);
|
||||
let entity = &query_string[start..end];
|
||||
context.insert("entity", &entity);
|
||||
|
||||
// Also get entity name if present
|
||||
if let Some(pos) = query_string.find("entity_name=") {
|
||||
let start = pos + 12; // length of "entity_name="
|
||||
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start);
|
||||
let end = query_string[start..]
|
||||
.find('&')
|
||||
.map_or(query_string.len(), |e| e + start);
|
||||
let entity_name = &query_string[start..end];
|
||||
let decoded_name = urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
|
||||
let decoded_name =
|
||||
urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
|
||||
context.insert("entity_name", &decoded_name);
|
||||
println!("DEBUG: Entity context set to {} ({})", entity, decoded_name);
|
||||
}
|
||||
}
|
||||
|
||||
println!("DEBUG: Rendering Company dashboard template");
|
||||
let response = render_template(&tmpl, "company/index.html", &context);
|
||||
println!("DEBUG: Finished rendering Company dashboard template");
|
||||
response
|
||||
render_template(&tmpl, "company/index.html", &context)
|
||||
}
|
||||
|
||||
// View company details
|
||||
pub async fn view_company(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> {
|
||||
let company_id = path.into_inner();
|
||||
// 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();
|
||||
|
||||
println!("DEBUG: Viewing company details for {}", company_id);
|
||||
|
||||
// Add active_page for navigation highlighting
|
||||
context.insert("active_page", &"company");
|
||||
context.insert("company_id", &company_id);
|
||||
|
||||
// In a real application, we would fetch company data from a database
|
||||
// For now, we'll use mock data based on the company_id
|
||||
match company_id.as_str() {
|
||||
"company1" => {
|
||||
context.insert("company_name", &"Zanzibar Digital Solutions");
|
||||
context.insert("company_type", &"Startup FZC");
|
||||
context.insert("status", &"Active");
|
||||
context.insert("registration_date", &"2025-04-01");
|
||||
context.insert("purpose", &"Digital solutions and blockchain development");
|
||||
context.insert("plan", &"Startup FZC - $50/month");
|
||||
context.insert("next_billing", &"2025-06-01");
|
||||
context.insert("payment_method", &"Credit Card (****4582)");
|
||||
// Parse query parameters for success/error messages
|
||||
let query_string = req.query_string();
|
||||
|
||||
// Shareholders data
|
||||
let shareholders = vec![
|
||||
("John Smith", "60%"),
|
||||
("Sarah Johnson", "40%"),
|
||||
];
|
||||
context.insert("shareholders", &shareholders);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Contracts data
|
||||
let contracts = vec![
|
||||
("Articles of Incorporation", "Signed"),
|
||||
("Terms & Conditions", "Signed"),
|
||||
("Digital Asset Issuance", "Signed"),
|
||||
];
|
||||
context.insert("contracts", &contracts);
|
||||
},
|
||||
"company2" => {
|
||||
context.insert("company_name", &"Blockchain Innovations Ltd");
|
||||
context.insert("company_type", &"Growth FZC");
|
||||
context.insert("status", &"Active");
|
||||
context.insert("registration_date", &"2025-03-15");
|
||||
context.insert("purpose", &"Blockchain technology research and development");
|
||||
context.insert("plan", &"Growth FZC - $100/month");
|
||||
context.insert("next_billing", &"2025-06-15");
|
||||
context.insert("payment_method", &"Bank Transfer");
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Shareholders data
|
||||
let shareholders = vec![
|
||||
("Michael Chen", "35%"),
|
||||
("Aisha Patel", "35%"),
|
||||
("David Okonkwo", "30%"),
|
||||
];
|
||||
context.insert("shareholders", &shareholders);
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Contracts data
|
||||
let contracts = vec![
|
||||
("Articles of Incorporation", "Signed"),
|
||||
("Terms & Conditions", "Signed"),
|
||||
("Digital Asset Issuance", "Signed"),
|
||||
("Physical Asset Holding", "Signed"),
|
||||
];
|
||||
context.insert("contracts", &contracts);
|
||||
},
|
||||
"company3" => {
|
||||
context.insert("company_name", &"Sustainable Energy Cooperative");
|
||||
context.insert("company_type", &"Cooperative FZC");
|
||||
context.insert("status", &"Pending");
|
||||
context.insert("registration_date", &"2025-05-01");
|
||||
context.insert("purpose", &"Renewable energy production and distribution");
|
||||
context.insert("plan", &"Cooperative FZC - $200/month");
|
||||
context.insert("next_billing", &"Pending Activation");
|
||||
context.insert("payment_method", &"Pending");
|
||||
// Fetch company from database
|
||||
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
||||
context.insert("company", &company);
|
||||
|
||||
// Shareholders data
|
||||
let shareholders = vec![
|
||||
("Community Energy Group", "40%"),
|
||||
("Green Future Initiative", "30%"),
|
||||
("Sustainable Living Collective", "30%"),
|
||||
];
|
||||
context.insert("shareholders", &shareholders);
|
||||
// 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);
|
||||
|
||||
// Contracts data
|
||||
let contracts = vec![
|
||||
("Articles of Incorporation", "Signed"),
|
||||
("Terms & Conditions", "Signed"),
|
||||
("Cooperative Governance", "Pending"),
|
||||
];
|
||||
context.insert("contracts", &contracts);
|
||||
},
|
||||
_ => {
|
||||
// If company_id is not recognized, redirect to company index
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/company"))
|
||||
.finish());
|
||||
render_template(&tmpl, "company/edit.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
|
||||
// View company details
|
||||
pub async fn view_company(
|
||||
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");
|
||||
context.insert("company_id", &company_id_str);
|
||||
|
||||
// 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);
|
||||
|
||||
// Get shareholders for this company
|
||||
let shareholders = match get_company_shareholders(company_id) {
|
||||
Ok(shareholders) => shareholders,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to get shareholders for company {}: {}",
|
||||
company_id,
|
||||
e
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
context.insert("shareholders", &shareholders);
|
||||
|
||||
// Get payment information for this company
|
||||
if let Some(payment_info) =
|
||||
crate::controllers::payment::PaymentController::get_company_payment_info(company_id)
|
||||
.await
|
||||
{
|
||||
context.insert("payment_info", &payment_info);
|
||||
|
||||
// Format payment dates for display
|
||||
// Format timestamps from i64 to readable format
|
||||
let payment_created = chrono::DateTime::from_timestamp(payment_info.created_at, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
context.insert("payment_created_formatted", &payment_created);
|
||||
|
||||
if let Some(completed_at) = payment_info.completed_at {
|
||||
let payment_completed = chrono::DateTime::from_timestamp(completed_at, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
context.insert("payment_completed_formatted", &payment_completed);
|
||||
}
|
||||
|
||||
// Format payment plan for display
|
||||
let payment_plan_display = match payment_info.payment_plan.as_str() {
|
||||
"monthly" => "Monthly",
|
||||
"yearly" => "Yearly (20% discount)",
|
||||
"two_year" => "2-Year (40% discount)",
|
||||
_ => &payment_info.payment_plan,
|
||||
};
|
||||
context.insert("payment_plan_display", &payment_plan_display);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to entity context
|
||||
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
|
||||
let company_id = path.into_inner();
|
||||
let company_id_str = path.into_inner();
|
||||
|
||||
println!("DEBUG: Switching to entity context for {}", company_id);
|
||||
// Parse company ID
|
||||
let company_id = match company_id_str.parse::<u32>() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/company"))
|
||||
.finish());
|
||||
}
|
||||
};
|
||||
|
||||
// Get company name based on ID (in a real app, this would come from a database)
|
||||
let company_name = match company_id.as_str() {
|
||||
"company1" => "Zanzibar Digital Solutions",
|
||||
"company2" => "Blockchain Innovations Ltd",
|
||||
"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
|
||||
@ -188,58 +306,366 @@ impl CompanyController {
|
||||
let encoded_message = urlencoding::encode(&success_message);
|
||||
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/company?success={}&entity={}&entity_name={}",
|
||||
encoded_message, company_id, urlencoding::encode(company_name))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!(
|
||||
"/company?success={}&entity={}&entity_name={}",
|
||||
encoded_message,
|
||||
company_id_str,
|
||||
urlencoding::encode(&company_name)
|
||||
),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Process company registration
|
||||
pub async fn register(
|
||||
mut form: actix_multipart::Multipart,
|
||||
) -> Result<HttpResponse> {
|
||||
use actix_web::{http::header};
|
||||
// Deprecated registration method removed - now handled via payment flow
|
||||
|
||||
// Legacy registration method (kept for reference but not used)
|
||||
#[allow(dead_code)]
|
||||
async fn legacy_register(mut form: actix_multipart::Multipart) -> Result<HttpResponse> {
|
||||
use actix_web::http::header;
|
||||
use chrono::Utc;
|
||||
use futures_util::stream::StreamExt as _;
|
||||
use std::collections::HashMap;
|
||||
|
||||
println!("DEBUG: Processing company registration request");
|
||||
|
||||
let mut fields: HashMap<String, String> = HashMap::new();
|
||||
let mut files = Vec::new();
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
// Parse multipart form
|
||||
while let Some(Ok(mut field)) = form.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());
|
||||
|
||||
if field_name.starts_with("contract-") || field_name.ends_with("-doc") {
|
||||
// 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((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);
|
||||
}
|
||||
|
||||
// Get field name from content disposition
|
||||
let cd = field.content_disposition();
|
||||
if let Some(name) = cd.get_name() {
|
||||
if name == "company_docs" {
|
||||
files.push(value); // Just collect files in memory for now
|
||||
} else {
|
||||
fields.insert(name.to_string(), String::from_utf8_lossy(&value).to_string());
|
||||
}
|
||||
fields.insert(field_name, String::from_utf8_lossy(&value).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract company details
|
||||
let company_name = fields.get("company_name").cloned().unwrap_or_default();
|
||||
let company_type = fields.get("company_type").cloned().unwrap_or_default();
|
||||
let shareholders = fields.get("shareholders").cloned().unwrap_or_default();
|
||||
let company_type_str = fields.get("company_type").cloned().unwrap_or_default();
|
||||
let company_purpose = fields.get("company_purpose").cloned().unwrap_or_default();
|
||||
let shareholders_str = fields.get("shareholders").cloned().unwrap_or_default();
|
||||
|
||||
// Log received fields (mock DB insert)
|
||||
println!("[Company Registration] Name: {}, Type: {}, Shareholders: {}, Files: {}",
|
||||
company_name, company_type, shareholders, files.len());
|
||||
// Extract new contact fields
|
||||
let company_email = fields.get("company_email").cloned().unwrap_or_default();
|
||||
let company_phone = fields.get("company_phone").cloned().unwrap_or_default();
|
||||
let company_website = fields.get("company_website").cloned().unwrap_or_default();
|
||||
let company_address = fields.get("company_address").cloned().unwrap_or_default();
|
||||
let company_industry = fields.get("company_industry").cloned().unwrap_or_default();
|
||||
let fiscal_year_end = fields.get("fiscal_year_end").cloned().unwrap_or_default();
|
||||
|
||||
// Create success message
|
||||
let success_message = format!("Successfully registered {} as a {}", company_name, company_type);
|
||||
// Validate required fields
|
||||
if company_name.is_empty() || company_type_str.is_empty() {
|
||||
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
|
||||
);
|
||||
|
||||
// Redirect back to /company with success message
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header((header::LOCATION, format!("/company?success={}", urlencoding::encode(&success_message))))
|
||||
.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,12 +1,15 @@
|
||||
use actix_web::{web, HttpResponse, Result};
|
||||
use actix_web::HttpRequest;
|
||||
use tera::{Context, Tera};
|
||||
use chrono::{Utc, Duration};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use tera::{Context, Tera};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::asset::{Asset, AssetType, AssetStatus};
|
||||
use crate::models::defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB};
|
||||
use crate::models::asset::Asset;
|
||||
use crate::models::defi::{
|
||||
DEFI_DB, DefiPosition, DefiPositionStatus, DefiPositionType, ProvidingPosition,
|
||||
ReceivingPosition,
|
||||
};
|
||||
use crate::utils::render_template;
|
||||
|
||||
// Form structs for DeFi operations
|
||||
@ -26,6 +29,7 @@ pub struct ReceivingForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LiquidityForm {
|
||||
pub first_token: String,
|
||||
pub first_amount: f64,
|
||||
@ -35,6 +39,7 @@ pub struct LiquidityForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct StakingForm {
|
||||
pub asset_id: String,
|
||||
pub amount: f64,
|
||||
@ -49,6 +54,7 @@ pub struct SwapForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CollateralForm {
|
||||
pub asset_id: String,
|
||||
pub amount: f64,
|
||||
@ -116,7 +122,10 @@ impl DefiController {
|
||||
}
|
||||
|
||||
// Process providing request
|
||||
pub async fn create_providing(_tmpl: web::Data<Tera>, form: web::Form<ProvidingForm>) -> Result<HttpResponse> {
|
||||
pub async fn create_providing(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<ProvidingForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing providing request: {:?}", form);
|
||||
|
||||
// Get the asset obligationails (in a real app, this would come from a database)
|
||||
@ -134,7 +143,8 @@ impl DefiController {
|
||||
_ => 4.2, // Default to 30 days rate
|
||||
};
|
||||
|
||||
let return_amount = form.amount + (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
|
||||
let return_amount = form.amount
|
||||
+ (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
|
||||
|
||||
// Create a new providing position
|
||||
let providing_position = ProvidingPosition {
|
||||
@ -164,9 +174,15 @@ impl DefiController {
|
||||
}
|
||||
|
||||
// Redirect with success message
|
||||
let success_message = format!("Successfully provided {} {} for {} days", form.amount, asset.name, form.duration);
|
||||
let success_message = format!(
|
||||
"Successfully provided {} {} for {} days",
|
||||
form.amount, asset.name, form.duration
|
||||
);
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
} else {
|
||||
// Asset not found, redirect with error
|
||||
@ -177,7 +193,10 @@ impl DefiController {
|
||||
}
|
||||
|
||||
// Process receiving request
|
||||
pub async fn create_receiving(_tmpl: web::Data<Tera>, form: web::Form<ReceivingForm>) -> Result<HttpResponse> {
|
||||
pub async fn create_receiving(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<ReceivingForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing receiving request: {:?}", form);
|
||||
|
||||
// Get the asset obligationails (in a real app, this would come from a database)
|
||||
@ -196,11 +215,13 @@ impl DefiController {
|
||||
};
|
||||
|
||||
// Calculate profit share and total to repay
|
||||
let profit_share = form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
|
||||
let profit_share =
|
||||
form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
|
||||
let total_to_repay = form.amount + profit_share;
|
||||
|
||||
// Calculate collateral value and ratio
|
||||
let collateral_value = form.collateral_amount * collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
|
||||
let collateral_value = form.collateral_amount
|
||||
* collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
|
||||
let collateral_ratio = (collateral_value / form.amount) * 100.0;
|
||||
|
||||
// Create a new receiving position
|
||||
@ -238,10 +259,15 @@ impl DefiController {
|
||||
}
|
||||
|
||||
// Redirect with success message
|
||||
let success_message = format!("Successfully borrowed {} ZDFZ using {} {} as collateral",
|
||||
form.amount, form.collateral_amount, collateral_asset.name);
|
||||
let success_message = format!(
|
||||
"Successfully borrowed {} ZDFZ using {} {} as collateral",
|
||||
form.amount, form.collateral_amount, collateral_asset.name
|
||||
);
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
} else {
|
||||
// Asset not found, redirect with error
|
||||
@ -252,22 +278,33 @@ impl DefiController {
|
||||
}
|
||||
|
||||
// Process liquidity provision
|
||||
pub async fn add_liquidity(_tmpl: web::Data<Tera>, form: web::Form<LiquidityForm>) -> Result<HttpResponse> {
|
||||
pub async fn add_liquidity(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<LiquidityForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing liquidity provision: {:?}", form);
|
||||
|
||||
// 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
|
||||
|
||||
let success_message = format!("Successfully added liquidity: {} {} and {} {}",
|
||||
form.first_amount, form.first_token, form.second_amount, form.second_token);
|
||||
let success_message = format!(
|
||||
"Successfully added liquidity: {} {} and {} {}",
|
||||
form.first_amount, form.first_token, form.second_amount, form.second_token
|
||||
);
|
||||
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Process staking request
|
||||
pub async fn create_staking(_tmpl: web::Data<Tera>, form: web::Form<StakingForm>) -> Result<HttpResponse> {
|
||||
pub async fn create_staking(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<StakingForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing staking request: {:?}", form);
|
||||
|
||||
// In a real application, this would create a staking position in the database
|
||||
@ -276,27 +313,41 @@ impl DefiController {
|
||||
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
|
||||
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Process token swap
|
||||
pub async fn swap_tokens(_tmpl: web::Data<Tera>, form: web::Form<SwapForm>) -> Result<HttpResponse> {
|
||||
pub async fn swap_tokens(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<SwapForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing token swap: {:?}", form);
|
||||
|
||||
// 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
|
||||
|
||||
let success_message = format!("Successfully swapped {} {} to {}",
|
||||
form.from_amount, form.from_token, form.to_token);
|
||||
let success_message = format!(
|
||||
"Successfully swapped {} {} to {}",
|
||||
form.from_amount, form.from_token, form.to_token
|
||||
);
|
||||
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Process collateral position creation
|
||||
pub async fn create_collateral(_tmpl: web::Data<Tera>, form: web::Form<CollateralForm>) -> Result<HttpResponse> {
|
||||
pub async fn create_collateral(
|
||||
_tmpl: web::Data<Tera>,
|
||||
form: web::Form<CollateralForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
println!("DEBUG: Processing collateral creation: {:?}", form);
|
||||
|
||||
// In a real application, this would create a collateral position in the database
|
||||
@ -309,11 +360,16 @@ impl DefiController {
|
||||
_ => "collateralization",
|
||||
};
|
||||
|
||||
let success_message = format!("Successfully collateralized {} {} for {}",
|
||||
form.amount, form.asset_id, purpose_str);
|
||||
let success_message = format!(
|
||||
"Successfully collateralized {} {} for {}",
|
||||
form.amount, form.asset_id, purpose_str
|
||||
);
|
||||
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message))))
|
||||
.append_header((
|
||||
"Location",
|
||||
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
@ -322,12 +378,32 @@ impl DefiController {
|
||||
let mut stats = serde_json::Map::new();
|
||||
|
||||
// Handle Option<Number> by unwrapping with expect
|
||||
stats.insert("total_value_locked".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(1250000.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.insert(
|
||||
"total_value_locked".to_string(),
|
||||
serde_json::Value::Number(
|
||||
serde_json::Number::from_f64(1250000.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
|
||||
}
|
||||
@ -336,25 +412,61 @@ impl DefiController {
|
||||
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut map = serde_json::Map::new();
|
||||
|
||||
map.insert("id".to_string(), serde_json::Value::String(asset.id.clone()));
|
||||
map.insert("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()));
|
||||
map.insert(
|
||||
"id".to_string(),
|
||||
serde_json::Value::String(asset.id.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"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
|
||||
if let Some(latest) = asset.latest_valuation() {
|
||||
if let Some(num) = serde_json::Number::from_f64(latest.value) {
|
||||
map.insert("current_valuation".to_string(), serde_json::Value::Number(num));
|
||||
map.insert(
|
||||
"current_valuation".to_string(),
|
||||
serde_json::Value::Number(num),
|
||||
);
|
||||
} else {
|
||||
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0)));
|
||||
map.insert(
|
||||
"current_valuation".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(0)),
|
||||
);
|
||||
}
|
||||
map.insert("valuation_currency".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()));
|
||||
map.insert(
|
||||
"valuation_currency".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 {
|
||||
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0)));
|
||||
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.insert(
|
||||
"current_valuation".to_string(),
|
||||
serde_json::Value::Number(serde_json::Number::from(0)),
|
||||
);
|
||||
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
|
||||
|
382
actix_mvc_app/src/controllers/document.rs
Normal file
382
actix_mvc_app/src/controllers/document.rs
Normal file
@ -0,0 +1,382 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
125
actix_mvc_app/src/controllers/error.rs
Normal file
125
actix_mvc_app/src/controllers/error.rs
Normal file
@ -0,0 +1,125 @@
|
||||
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,6 +609,7 @@ impl FlowController {
|
||||
|
||||
/// Form for creating a new flow
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct FlowForm {
|
||||
/// Flow name
|
||||
pub name: String,
|
||||
@ -620,6 +621,7 @@ pub struct FlowForm {
|
||||
|
||||
/// Form for marking a step as stuck
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct StuckForm {
|
||||
/// Reason for being stuck
|
||||
pub reason: String,
|
||||
@ -627,6 +629,7 @@ pub struct StuckForm {
|
||||
|
||||
/// Form for adding a log to a step
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LogForm {
|
||||
/// Log message
|
||||
pub message: String,
|
||||
|
File diff suppressed because it is too large
Load Diff
418
actix_mvc_app/src/controllers/health.rs
Normal file
418
actix_mvc_app/src/controllers/health.rs
Normal file
@ -0,0 +1,418 @@
|
||||
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,6 +96,7 @@ impl HomeController {
|
||||
|
||||
/// Represents the data submitted in the contact form
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ContactForm {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
|
@ -1,12 +1,11 @@
|
||||
use actix_web::{web, HttpResponse, Result, http};
|
||||
use tera::{Context, Tera};
|
||||
use chrono::{Utc, Duration};
|
||||
use actix_web::{HttpResponse, Result, http, web};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use crate::models::asset::{Asset, AssetType, AssetStatus};
|
||||
use crate::models::marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
|
||||
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;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -22,6 +21,7 @@ pub struct ListingForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct BidForm {
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
@ -43,13 +43,15 @@ impl MarketplaceController {
|
||||
let stats = MarketplaceStatistics::new(&listings);
|
||||
|
||||
// Get featured listings (up to 4)
|
||||
let featured_listings: Vec<&Listing> = listings.iter()
|
||||
let featured_listings: Vec<&Listing> = listings
|
||||
.iter()
|
||||
.filter(|l| l.featured && l.status == ListingStatus::Active)
|
||||
.take(4)
|
||||
.collect();
|
||||
|
||||
// Get recent listings (up to 8)
|
||||
let mut recent_listings: Vec<&Listing> = listings.iter()
|
||||
let mut recent_listings: Vec<&Listing> = listings
|
||||
.iter()
|
||||
.filter(|l| l.status == ListingStatus::Active)
|
||||
.collect();
|
||||
|
||||
@ -58,7 +60,8 @@ impl MarketplaceController {
|
||||
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
|
||||
|
||||
// Get recent sales (up to 5)
|
||||
let mut recent_sales: Vec<&Listing> = listings.iter()
|
||||
let mut recent_sales: Vec<&Listing> = listings
|
||||
.iter()
|
||||
.filter(|l| l.status == ListingStatus::Sold)
|
||||
.collect();
|
||||
|
||||
@ -87,18 +90,24 @@ impl MarketplaceController {
|
||||
let listings = Self::get_mock_listings();
|
||||
|
||||
// Filter active listings
|
||||
let active_listings: Vec<&Listing> = listings.iter()
|
||||
let active_listings: Vec<&Listing> = listings
|
||||
.iter()
|
||||
.filter(|l| l.status == ListingStatus::Active)
|
||||
.collect();
|
||||
|
||||
context.insert("active_page", &"marketplace");
|
||||
context.insert("listings", &active_listings);
|
||||
context.insert("listing_types", &[
|
||||
context.insert(
|
||||
"listing_types",
|
||||
&[
|
||||
ListingType::FixedPrice.as_str(),
|
||||
ListingType::Auction.as_str(),
|
||||
ListingType::Exchange.as_str(),
|
||||
]);
|
||||
context.insert("asset_types", &[
|
||||
],
|
||||
);
|
||||
context.insert(
|
||||
"asset_types",
|
||||
&[
|
||||
AssetType::Token.as_str(),
|
||||
AssetType::Artwork.as_str(),
|
||||
AssetType::RealEstate.as_str(),
|
||||
@ -107,7 +116,8 @@ impl MarketplaceController {
|
||||
AssetType::Share.as_str(),
|
||||
AssetType::Bond.as_str(),
|
||||
AssetType::Other.as_str(),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
render_template(&tmpl, "marketplace/listings.html", &context)
|
||||
}
|
||||
@ -120,9 +130,8 @@ impl MarketplaceController {
|
||||
|
||||
// Filter by current user (mock user ID)
|
||||
let user_id = "user-123";
|
||||
let my_listings: Vec<&Listing> = listings.iter()
|
||||
.filter(|l| l.seller_id == user_id)
|
||||
.collect();
|
||||
let my_listings: Vec<&Listing> =
|
||||
listings.iter().filter(|l| l.seller_id == user_id).collect();
|
||||
|
||||
context.insert("active_page", &"marketplace");
|
||||
context.insert("listings", &my_listings);
|
||||
@ -131,7 +140,10 @@ impl MarketplaceController {
|
||||
}
|
||||
|
||||
// Display listing details
|
||||
pub async fn listing_detail(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> {
|
||||
pub async fn listing_detail(
|
||||
tmpl: web::Data<Tera>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse> {
|
||||
let listing_id = path.into_inner();
|
||||
let mut context = Context::new();
|
||||
|
||||
@ -142,15 +154,19 @@ impl MarketplaceController {
|
||||
|
||||
if let Some(listing) = listing {
|
||||
// Get similar listings (same asset type, active)
|
||||
let similar_listings: Vec<&Listing> = listings.iter()
|
||||
.filter(|l| l.asset_type == listing.asset_type &&
|
||||
l.status == ListingStatus::Active &&
|
||||
l.id != listing.id)
|
||||
let similar_listings: Vec<&Listing> = listings
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.asset_type == listing.asset_type
|
||||
&& l.status == ListingStatus::Active
|
||||
&& l.id != listing.id
|
||||
})
|
||||
.take(4)
|
||||
.collect();
|
||||
|
||||
// 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() {
|
||||
(Some(bid.amount), bid.amount + 1.0)
|
||||
} else {
|
||||
@ -186,17 +202,21 @@ impl MarketplaceController {
|
||||
let assets = AssetController::get_mock_assets();
|
||||
let user_id = "user-123"; // Mock user ID
|
||||
|
||||
let user_assets: Vec<&Asset> = assets.iter()
|
||||
let user_assets: Vec<&Asset> = assets
|
||||
.iter()
|
||||
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
|
||||
.collect();
|
||||
|
||||
context.insert("active_page", &"marketplace");
|
||||
context.insert("assets", &user_assets);
|
||||
context.insert("listing_types", &[
|
||||
context.insert(
|
||||
"listing_types",
|
||||
&[
|
||||
ListingType::FixedPrice.as_str(),
|
||||
ListingType::Auction.as_str(),
|
||||
ListingType::Exchange.as_str(),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
||||
}
|
||||
@ -215,7 +235,8 @@ impl MarketplaceController {
|
||||
if let Some(asset) = asset {
|
||||
// Process tags
|
||||
let tags = match form.tags {
|
||||
Some(tags_str) => tags_str.split(',')
|
||||
Some(tags_str) => tags_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
@ -223,9 +244,9 @@ impl MarketplaceController {
|
||||
};
|
||||
|
||||
// Calculate expiration date if provided
|
||||
let expires_at = form.duration_days.map(|days| {
|
||||
Utc::now() + Duration::days(days as i64)
|
||||
});
|
||||
let expires_at = form
|
||||
.duration_days
|
||||
.map(|days| Utc::now() + Duration::days(days as i64));
|
||||
|
||||
// Parse listing type
|
||||
let listing_type = match form.listing_type.as_str() {
|
||||
@ -273,13 +294,14 @@ impl MarketplaceController {
|
||||
}
|
||||
|
||||
// Submit a bid on an auction listing
|
||||
#[allow(dead_code)]
|
||||
pub async fn submit_bid(
|
||||
tmpl: web::Data<Tera>,
|
||||
_tmpl: web::Data<Tera>,
|
||||
path: web::Path<String>,
|
||||
form: web::Form<BidForm>,
|
||||
_form: web::Form<BidForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
let listing_id = path.into_inner();
|
||||
let form = form.into_inner();
|
||||
let _form = _form.into_inner();
|
||||
|
||||
// In a real application, we would:
|
||||
// 1. Find the listing in the database
|
||||
@ -289,13 +311,16 @@ impl MarketplaceController {
|
||||
|
||||
// For now, we'll just redirect back to the listing
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
||||
.insert_header((
|
||||
http::header::LOCATION,
|
||||
format!("/marketplace/{}", listing_id),
|
||||
))
|
||||
.finish())
|
||||
}
|
||||
|
||||
// Purchase a fixed-price listing
|
||||
pub async fn purchase_listing(
|
||||
tmpl: web::Data<Tera>,
|
||||
_tmpl: web::Data<Tera>,
|
||||
path: web::Path<String>,
|
||||
form: web::Form<PurchaseForm>,
|
||||
) -> Result<HttpResponse> {
|
||||
@ -305,7 +330,10 @@ impl MarketplaceController {
|
||||
if !form.agree_to_terms {
|
||||
// User must agree to terms
|
||||
return Ok(HttpResponse::SeeOther()
|
||||
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
||||
.insert_header((
|
||||
http::header::LOCATION,
|
||||
format!("/marketplace/{}", listing_id),
|
||||
))
|
||||
.finish());
|
||||
}
|
||||
|
||||
@ -324,7 +352,7 @@ impl MarketplaceController {
|
||||
|
||||
// Cancel a listing
|
||||
pub async fn cancel_listing(
|
||||
tmpl: web::Data<Tera>,
|
||||
_tmpl: web::Data<Tera>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _listing_id = path.into_inner();
|
||||
@ -368,7 +396,10 @@ impl MarketplaceController {
|
||||
|
||||
let mut listing = Listing::new(
|
||||
format!("{} for Sale", asset.name),
|
||||
format!("This is a great opportunity to own {}. {}", asset.name, asset.description),
|
||||
format!(
|
||||
"This is a great opportunity to own {}. {}",
|
||||
asset.name, asset.description
|
||||
),
|
||||
asset.id.clone(),
|
||||
asset.name.clone(),
|
||||
asset.asset_type.clone(),
|
||||
@ -427,7 +458,8 @@ impl MarketplaceController {
|
||||
let num_bids = 2 + (i % 3);
|
||||
for j in 0..num_bids {
|
||||
let bidder_index = (j + 1) % user_ids.len();
|
||||
if bidder_index != user_index { // Ensure seller isn't bidding
|
||||
if bidder_index != user_index {
|
||||
// Ensure seller isn't bidding
|
||||
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
|
||||
let _ = listing.add_bid(
|
||||
user_ids[bidder_index].to_string(),
|
||||
@ -465,7 +497,10 @@ impl MarketplaceController {
|
||||
|
||||
let listing = Listing::new(
|
||||
format!("Trade: {}", asset.name),
|
||||
format!("Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.", asset.name),
|
||||
format!(
|
||||
"Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.",
|
||||
asset.name
|
||||
),
|
||||
asset.id.clone(),
|
||||
asset.name.clone(),
|
||||
asset.asset_type.clone(),
|
||||
|
@ -1,14 +1,18 @@
|
||||
// Export controllers
|
||||
pub mod home;
|
||||
pub mod auth;
|
||||
pub mod ticket;
|
||||
pub mod calendar;
|
||||
pub mod governance;
|
||||
pub mod flow;
|
||||
pub mod contract;
|
||||
pub mod asset;
|
||||
pub mod defi;
|
||||
pub mod marketplace;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod company;
|
||||
pub mod contract;
|
||||
pub mod defi;
|
||||
pub mod document;
|
||||
pub mod error;
|
||||
pub mod flow;
|
||||
pub mod governance;
|
||||
pub mod health;
|
||||
pub mod home;
|
||||
pub mod marketplace;
|
||||
pub mod payment;
|
||||
pub mod ticket;
|
||||
|
||||
// Re-export controllers for easier imports
|
||||
|
1152
actix_mvc_app/src/controllers/payment.rs
Normal file
1152
actix_mvc_app/src/controllers/payment.rs
Normal file
File diff suppressed because it is too large
Load Diff
362
actix_mvc_app/src/db/calendar.rs
Normal file
362
actix_mvc_app/src/db/calendar.rs
Normal file
@ -0,0 +1,362 @@
|
||||
#![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)
|
||||
}
|
500
actix_mvc_app/src/db/company.rs
Normal file
500
actix_mvc_app/src/db/company.rs
Normal file
@ -0,0 +1,500 @@
|
||||
#![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(())
|
||||
}
|
460
actix_mvc_app/src/db/contracts.rs
Normal file
460
actix_mvc_app/src/db/contracts.rs
Normal file
@ -0,0 +1,460 @@
|
||||
#![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,
|
||||
}
|
17
actix_mvc_app/src/db/db.rs
Normal file
17
actix_mvc_app/src/db/db.rs
Normal file
@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
199
actix_mvc_app/src/db/document.rs
Normal file
199
actix_mvc_app/src/db/document.rs
Normal file
@ -0,0 +1,199 @@
|
||||
#![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)
|
||||
}
|
257
actix_mvc_app/src/db/governance.rs
Normal file
257
actix_mvc_app/src/db/governance.rs
Normal file
@ -0,0 +1,257 @@
|
||||
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)
|
||||
}
|
8
actix_mvc_app/src/db/mod.rs
Normal file
8
actix_mvc_app/src/db/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
pub mod calendar;
|
||||
pub mod company;
|
||||
pub mod contracts;
|
||||
pub mod db;
|
||||
pub mod document;
|
||||
pub mod governance;
|
||||
pub mod payment;
|
||||
pub mod registration;
|
355
actix_mvc_app/src/db/payment.rs
Normal file
355
actix_mvc_app/src/db/payment.rs
Normal file
@ -0,0 +1,355 @@
|
||||
#![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))
|
||||
}
|
||||
}
|
||||
}
|
272
actix_mvc_app/src/db/registration.rs
Normal file
272
actix_mvc_app/src/db/registration.rs
Normal file
@ -0,0 +1,272 @@
|
||||
#![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");
|
||||
}
|
||||
}
|
37
actix_mvc_app/src/lib.rs
Normal file
37
actix_mvc_app/src/lib.rs
Normal file
@ -0,0 +1,37 @@
|
||||
// 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,22 +1,24 @@
|
||||
use actix_files as fs;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use actix_web::middleware::Logger;
|
||||
use tera::Tera;
|
||||
use std::io;
|
||||
use std::env;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use lazy_static::lazy_static;
|
||||
use std::env;
|
||||
use std::io;
|
||||
use tera::Tera;
|
||||
|
||||
mod config;
|
||||
mod controllers;
|
||||
mod db;
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod utils;
|
||||
mod validators;
|
||||
|
||||
// Import middleware components
|
||||
use middleware::{RequestTimer, SecurityHeaders, JwtAuth};
|
||||
use utils::redis_service;
|
||||
use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
|
||||
use models::initialize_mock_data;
|
||||
use utils::redis_service;
|
||||
|
||||
// Initialize lazy_static for in-memory storage
|
||||
extern crate lazy_static;
|
||||
@ -49,10 +51,18 @@ async fn main() -> io::Result<()> {
|
||||
// Load configuration
|
||||
let config = config::get_config();
|
||||
|
||||
// Check for port override from command line arguments
|
||||
// Check for port override from environment variable or command line arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let mut port = config.server.port;
|
||||
|
||||
// First check environment variable
|
||||
if let Ok(env_port) = env::var("PORT") {
|
||||
if let Ok(p) = env_port.parse::<u16>() {
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check command line arguments (takes precedence over env var)
|
||||
for i in 1..args.len() {
|
||||
if args[i] == "--port" && i + 1 < args.len() {
|
||||
if let Ok(p) = args[i + 1].parse::<u16>() {
|
||||
@ -65,7 +75,8 @@ async fn main() -> io::Result<()> {
|
||||
let bind_address = format!("{}:{}", config.server.host, port);
|
||||
|
||||
// Initialize Redis client
|
||||
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
|
||||
let redis_url =
|
||||
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) {
|
||||
log::error!("Failed to initialize Redis client: {}", e);
|
||||
log::warn!("Calendar functionality will not work properly without Redis");
|
||||
@ -77,6 +88,9 @@ async fn main() -> io::Result<()> {
|
||||
initialize_mock_data();
|
||||
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);
|
||||
|
||||
// Create and configure the HTTP server
|
||||
@ -106,6 +120,8 @@ async fn main() -> io::Result<()> {
|
||||
.app_data(web::Data::new(tera))
|
||||
// Configure routes
|
||||
.configure(routes::configure_routes)
|
||||
// Add default handler for 404 errors
|
||||
.default_service(web::route().to(controllers::error::render_generic_not_found))
|
||||
})
|
||||
.bind(bind_address)?
|
||||
.workers(num_cpus::get())
|
||||
|
@ -112,6 +112,7 @@ pub struct Asset {
|
||||
pub external_url: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Asset {
|
||||
/// Creates a new asset
|
||||
pub fn new(
|
||||
|
@ -1,61 +1,4 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
// No imports needed for this module currently
|
||||
|
||||
/// Represents a view mode for the calendar
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
@ -1,7 +1,99 @@
|
||||
#![allow(dead_code)] // Model utility functions may not all be used yet
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Contract activity types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContractActivityType {
|
||||
Created,
|
||||
SignerAdded,
|
||||
SignerRemoved,
|
||||
SentForSignatures,
|
||||
Signed,
|
||||
Rejected,
|
||||
StatusChanged,
|
||||
Revised,
|
||||
}
|
||||
|
||||
impl ContractActivityType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ContractActivityType::Created => "Contract Created",
|
||||
ContractActivityType::SignerAdded => "Signer Added",
|
||||
ContractActivityType::SignerRemoved => "Signer Removed",
|
||||
ContractActivityType::SentForSignatures => "Sent for Signatures",
|
||||
ContractActivityType::Signed => "Contract Signed",
|
||||
ContractActivityType::Rejected => "Contract Rejected",
|
||||
ContractActivityType::StatusChanged => "Status Changed",
|
||||
ContractActivityType::Revised => "Contract Revised",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract activity model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContractActivity {
|
||||
pub id: String,
|
||||
pub contract_id: u32,
|
||||
pub activity_type: ContractActivityType,
|
||||
pub description: String,
|
||||
pub user_name: String,
|
||||
pub created_at: DateTime<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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContractStatus {
|
||||
@ -10,7 +102,7 @@ pub enum ContractStatus {
|
||||
Signed,
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ContractStatus {
|
||||
@ -37,7 +129,7 @@ pub enum ContractType {
|
||||
Distribution,
|
||||
License,
|
||||
Membership,
|
||||
Other
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ContractType {
|
||||
@ -61,7 +153,7 @@ impl ContractType {
|
||||
pub enum SignerStatus {
|
||||
Pending,
|
||||
Signed,
|
||||
Rejected
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl SignerStatus {
|
||||
@ -85,6 +177,7 @@ pub struct ContractSigner {
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ContractSigner {
|
||||
/// Creates a new contract signer
|
||||
pub fn new(name: String, email: String) -> Self {
|
||||
@ -123,9 +216,15 @@ pub struct ContractRevision {
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ContractRevision {
|
||||
/// Creates a new contract revision
|
||||
pub fn new(version: u32, content: String, created_by: String, comments: Option<String>) -> Self {
|
||||
pub fn new(
|
||||
version: u32,
|
||||
content: String,
|
||||
created_by: String,
|
||||
comments: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
version,
|
||||
content,
|
||||
@ -166,9 +265,16 @@ pub struct Contract {
|
||||
pub toc: Option<Vec<TocItem>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Contract {
|
||||
/// Creates a new contract
|
||||
pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option<String>) -> Self {
|
||||
pub fn new(
|
||||
title: String,
|
||||
description: String,
|
||||
contract_type: ContractType,
|
||||
created_by: String,
|
||||
organization_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
title,
|
||||
@ -226,7 +332,9 @@ impl Contract {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.signers.iter().all(|signer| signer.status == SignerStatus::Signed)
|
||||
self.signers
|
||||
.iter()
|
||||
.all(|signer| signer.status == SignerStatus::Signed)
|
||||
}
|
||||
|
||||
/// Marks the contract as signed if all signers have signed
|
||||
@ -258,17 +366,26 @@ impl Contract {
|
||||
|
||||
/// Gets the number of pending signers
|
||||
pub fn pending_signers_count(&self) -> usize {
|
||||
self.signers.iter().filter(|s| s.status == SignerStatus::Pending).count()
|
||||
self.signers
|
||||
.iter()
|
||||
.filter(|s| s.status == SignerStatus::Pending)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Gets the number of signed signers
|
||||
pub fn signed_signers_count(&self) -> usize {
|
||||
self.signers.iter().filter(|s| s.status == SignerStatus::Signed).count()
|
||||
self.signers
|
||||
.iter()
|
||||
.filter(|s| s.status == SignerStatus::Signed)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Gets the number of rejected signers
|
||||
pub fn rejected_signers_count(&self) -> usize {
|
||||
self.signers.iter().filter(|s| s.status == SignerStatus::Rejected).count()
|
||||
self.signers
|
||||
.iter()
|
||||
.filter(|s| s.status == SignerStatus::Rejected)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,11 +413,26 @@ impl ContractStatistics {
|
||||
/// Creates new contract statistics from a list of contracts
|
||||
pub fn new(contracts: &[Contract]) -> Self {
|
||||
let total_contracts = contracts.len();
|
||||
let draft_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Draft).count();
|
||||
let pending_signature_contracts = contracts.iter().filter(|c| c.status == ContractStatus::PendingSignatures).count();
|
||||
let signed_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Signed).count();
|
||||
let expired_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Expired).count();
|
||||
let cancelled_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Cancelled).count();
|
||||
let draft_contracts = contracts
|
||||
.iter()
|
||||
.filter(|c| c.status == ContractStatus::Draft)
|
||||
.count();
|
||||
let pending_signature_contracts = contracts
|
||||
.iter()
|
||||
.filter(|c| c.status == ContractStatus::PendingSignatures)
|
||||
.count();
|
||||
let signed_contracts = contracts
|
||||
.iter()
|
||||
.filter(|c| c.status == ContractStatus::Signed)
|
||||
.count();
|
||||
let expired_contracts = contracts
|
||||
.iter()
|
||||
.filter(|c| c.status == ContractStatus::Expired)
|
||||
.count();
|
||||
let cancelled_contracts = contracts
|
||||
.iter()
|
||||
.filter(|c| c.status == ContractStatus::Cancelled)
|
||||
.count();
|
||||
|
||||
Self {
|
||||
total_contracts,
|
||||
|
@ -14,6 +14,7 @@ pub enum DefiPositionStatus {
|
||||
Cancelled
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DefiPositionStatus {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@ -35,6 +36,7 @@ pub enum DefiPositionType {
|
||||
Collateral,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DefiPositionType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@ -95,6 +97,7 @@ pub struct DefiDatabase {
|
||||
receiving_positions: HashMap<String, ReceivingPosition>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DefiDatabase {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
254
actix_mvc_app/src/models/document.rs
Normal file
254
actix_mvc_app/src/models/document.rs
Normal file
@ -0,0 +1,254 @@
|
||||
#![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,6 +110,7 @@ pub struct FlowStep {
|
||||
pub logs: Vec<FlowLog>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FlowStep {
|
||||
/// Creates a new flow step
|
||||
pub fn new(name: String, description: String, order: u32) -> Self {
|
||||
@ -189,6 +190,7 @@ pub struct FlowLog {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FlowLog {
|
||||
/// Creates a new flow log
|
||||
pub fn new(message: String) -> Self {
|
||||
@ -231,6 +233,7 @@ pub struct Flow {
|
||||
pub current_step: Option<FlowStep>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Flow {
|
||||
/// Creates a new flow
|
||||
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {
|
||||
|
@ -1,248 +0,0 @@
|
||||
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 serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use crate::models::asset::{Asset, AssetType};
|
||||
|
||||
/// Status of a marketplace listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@ -12,6 +12,7 @@ pub enum ListingStatus {
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ListingStatus {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@ -63,6 +64,7 @@ pub enum BidStatus {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl BidStatus {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@ -103,6 +105,7 @@ pub struct Listing {
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Listing {
|
||||
/// Creates a new listing
|
||||
pub fn new(
|
||||
@ -150,7 +153,13 @@ impl Listing {
|
||||
}
|
||||
|
||||
/// Adds a bid to the listing
|
||||
pub fn add_bid(&mut self, bidder_id: String, bidder_name: String, amount: f64, currency: String) -> Result<(), String> {
|
||||
pub fn add_bid(
|
||||
&mut self,
|
||||
bidder_id: String,
|
||||
bidder_name: String,
|
||||
amount: f64,
|
||||
currency: String,
|
||||
) -> Result<(), String> {
|
||||
if self.status != ListingStatus::Active {
|
||||
return Err("Listing is not active".to_string());
|
||||
}
|
||||
@ -160,7 +169,10 @@ impl Listing {
|
||||
}
|
||||
|
||||
if currency != self.currency {
|
||||
return Err(format!("Currency mismatch: expected {}, got {}", self.currency, currency));
|
||||
return Err(format!(
|
||||
"Currency mismatch: expected {}, got {}",
|
||||
self.currency, currency
|
||||
));
|
||||
}
|
||||
|
||||
// Check if bid amount is higher than current highest bid or starting price
|
||||
@ -193,13 +205,19 @@ impl Listing {
|
||||
|
||||
/// Gets the highest bid on the listing
|
||||
pub fn highest_bid(&self) -> Option<&Bid> {
|
||||
self.bids.iter()
|
||||
self.bids
|
||||
.iter()
|
||||
.filter(|b| b.status == BidStatus::Active)
|
||||
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
|
||||
}
|
||||
|
||||
/// Marks the listing as sold
|
||||
pub fn mark_as_sold(&mut self, buyer_id: String, buyer_name: String, sale_price: f64) -> Result<(), String> {
|
||||
pub fn mark_as_sold(
|
||||
&mut self,
|
||||
buyer_id: String,
|
||||
buyer_name: String,
|
||||
sale_price: f64,
|
||||
) -> Result<(), String> {
|
||||
if self.status != ListingStatus::Active {
|
||||
return Err("Listing is not active".to_string());
|
||||
}
|
||||
@ -257,11 +275,13 @@ impl MarketplaceStatistics {
|
||||
let mut listings_by_type = std::collections::HashMap::new();
|
||||
let mut sales_by_asset_type = std::collections::HashMap::new();
|
||||
|
||||
let active_listings = listings.iter()
|
||||
let active_listings = listings
|
||||
.iter()
|
||||
.filter(|l| l.status == ListingStatus::Active)
|
||||
.count();
|
||||
|
||||
let sold_listings = listings.iter()
|
||||
let sold_listings = listings
|
||||
.iter()
|
||||
.filter(|l| l.status == ListingStatus::Sold)
|
||||
.count();
|
||||
|
||||
|
81
actix_mvc_app/src/models/mock_user.rs
Normal file
81
actix_mvc_app/src/models/mock_user.rs
Normal file
@ -0,0 +1,81 @@
|
||||
#![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,17 +1,18 @@
|
||||
// Export models
|
||||
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 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;
|
||||
|
||||
// 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 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,6 +76,7 @@ pub struct Ticket {
|
||||
pub assigned_to: Option<i32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Ticket {
|
||||
/// Creates a new ticket
|
||||
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
||||
|
@ -4,6 +4,7 @@ use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
/// Represents a user in the system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct User {
|
||||
/// Unique identifier for the user
|
||||
pub id: Option<i32>,
|
||||
@ -31,6 +32,7 @@ pub enum UserRole {
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl User {
|
||||
/// Creates a new user with default values
|
||||
pub fn new(name: String, email: String) -> Self {
|
||||
@ -125,6 +127,7 @@ impl User {
|
||||
|
||||
/// Represents user login credentials
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LoginCredentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
@ -132,6 +135,7 @@ pub struct LoginCredentials {
|
||||
|
||||
/// Represents user registration data
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RegistrationData {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
|
@ -1,26 +1,29 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
/// Configures all application routes
|
||||
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
|
||||
let session_middleware = SessionMiddleware::builder(
|
||||
CookieSessionStore::default(),
|
||||
SESSION_KEY.clone()
|
||||
)
|
||||
let session_middleware =
|
||||
SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
|
||||
.cookie_secure(false) // Set to true in production with HTTPS
|
||||
.build();
|
||||
|
||||
@ -33,67 +36,187 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
.route("/about", web::get().to(HomeController::about))
|
||||
.route("/contact", web::get().to(HomeController::contact))
|
||||
.route("/contact", web::post().to(HomeController::submit_contact))
|
||||
|
||||
// Auth routes
|
||||
.route("/login", web::get().to(AuthController::login_page))
|
||||
.route("/login", web::post().to(AuthController::login))
|
||||
.route("/register", web::get().to(AuthController::register_page))
|
||||
.route("/register", web::post().to(AuthController::register))
|
||||
.route("/logout", web::get().to(AuthController::logout))
|
||||
|
||||
// Protected routes that require authentication
|
||||
// These routes will be protected by the JwtAuth middleware in the main.rs file
|
||||
.route("/editor", web::get().to(HomeController::editor))
|
||||
|
||||
// Ticket routes
|
||||
.route("/tickets", web::get().to(TicketController::list_tickets))
|
||||
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
||||
.route("/tickets", web::post().to(TicketController::create_ticket))
|
||||
.route("/tickets/{id}", web::get().to(TicketController::show_ticket))
|
||||
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment))
|
||||
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status))
|
||||
.route(
|
||||
"/tickets/{id}",
|
||||
web::get().to(TicketController::show_ticket),
|
||||
)
|
||||
.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))
|
||||
|
||||
// Calendar routes
|
||||
.route("/calendar", web::get().to(CalendarController::calendar))
|
||||
.route("/calendar/events/new", web::get().to(CalendarController::new_event))
|
||||
.route("/calendar/events", web::post().to(CalendarController::create_event))
|
||||
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event))
|
||||
|
||||
.route(
|
||||
"/calendar/events/new",
|
||||
web::get().to(CalendarController::new_event),
|
||||
)
|
||||
.route(
|
||||
"/calendar/events",
|
||||
web::post().to(CalendarController::create_event),
|
||||
)
|
||||
.route(
|
||||
"/calendar/events/{id}/delete",
|
||||
web::post().to(CalendarController::delete_event),
|
||||
)
|
||||
// Governance routes
|
||||
.route("/governance", web::get().to(GovernanceController::index))
|
||||
.route("/governance/proposals", web::get().to(GovernanceController::proposals))
|
||||
.route("/governance/proposals/{id}", 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/proposals",
|
||||
web::get().to(GovernanceController::proposals),
|
||||
)
|
||||
.route(
|
||||
"/governance/proposals/{id}",
|
||||
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
|
||||
.service(
|
||||
web::scope("/flows")
|
||||
.route("", web::get().to(FlowController::index))
|
||||
.route("/list", web::get().to(FlowController::list_flows))
|
||||
.route("/{id}", web::get().to(FlowController::flow_detail))
|
||||
.route("/{id}/advance", web::post().to(FlowController::advance_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(
|
||||
"/{id}/advance",
|
||||
web::post().to(FlowController::advance_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::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
|
||||
.service(
|
||||
web::scope("/contracts")
|
||||
.route("", web::get().to(ContractController::index))
|
||||
.route("/", web::get().to(ContractController::index)) // Handle trailing slash
|
||||
.route("/list", web::get().to(ContractController::list))
|
||||
.route("/my", web::get().to(ContractController::my_contracts))
|
||||
.route("/{id}", web::get().to(ContractController::detail))
|
||||
.route("/create", web::get().to(ContractController::create_form))
|
||||
.route("/create", web::post().to(ContractController::create))
|
||||
.route("/list/", web::get().to(ContractController::list)) // Handle trailing slash
|
||||
.route(
|
||||
"/my-contracts",
|
||||
web::get().to(ContractController::my_contracts),
|
||||
)
|
||||
.route(
|
||||
"/my-contracts/",
|
||||
web::get().to(ContractController::my_contracts),
|
||||
) // Handle trailing slash
|
||||
.route("/create", web::get().to(ContractController::create_form))
|
||||
.route("/create/", web::get().to(ContractController::create_form)) // Handle trailing slash
|
||||
.route("/create", web::post().to(ContractController::create))
|
||||
.route("/create/", web::post().to(ContractController::create)) // Handle trailing slash
|
||||
.route("/statistics", web::get().to(ContractController::statistics))
|
||||
.route(
|
||||
"/activities",
|
||||
web::get().to(ContractController::all_activities),
|
||||
)
|
||||
.route("/{id}/edit", web::get().to(ContractController::edit_form))
|
||||
.route("/{id}/edit", web::post().to(ContractController::update))
|
||||
.route(
|
||||
"/filter/{status}",
|
||||
web::get().to(ContractController::filter_by_status),
|
||||
)
|
||||
.route("/{id}", web::get().to(ContractController::detail))
|
||||
.route(
|
||||
"/{id}/status/{status}",
|
||||
web::post().to(ContractController::update_status),
|
||||
)
|
||||
.route("/{id}/delete", web::post().to(ContractController::delete))
|
||||
.route(
|
||||
"/{id}/add-signer",
|
||||
web::get().to(ContractController::add_signer_form),
|
||||
)
|
||||
.route(
|
||||
"/{id}/add-signer",
|
||||
web::post().to(ContractController::add_signer),
|
||||
)
|
||||
.route(
|
||||
"/{id}/remind",
|
||||
web::post().to(ContractController::remind_to_sign),
|
||||
)
|
||||
.route(
|
||||
"/{id}/send",
|
||||
web::post().to(ContractController::send_for_signatures),
|
||||
)
|
||||
.route(
|
||||
"/{id}/reminder-status",
|
||||
web::get().to(ContractController::get_reminder_status),
|
||||
)
|
||||
.route(
|
||||
"/{id}/add-revision",
|
||||
web::post().to(ContractController::add_revision),
|
||||
)
|
||||
.route(
|
||||
"/{id}/signer/{signer_id}/status/{status}",
|
||||
web::post().to(ContractController::update_signer_status),
|
||||
)
|
||||
.route(
|
||||
"/{id}/sign/{signer_id}",
|
||||
web::post().to(ContractController::sign_contract),
|
||||
)
|
||||
.route(
|
||||
"/{id}/reject/{signer_id}",
|
||||
web::post().to(ContractController::reject_contract),
|
||||
)
|
||||
.route(
|
||||
"/{id}/cancel",
|
||||
web::post().to(ContractController::cancel_contract),
|
||||
)
|
||||
.route(
|
||||
"/{id}/clone",
|
||||
web::post().to(ContractController::clone_contract),
|
||||
)
|
||||
.route(
|
||||
"/{id}/signed/{signer_id}",
|
||||
web::get().to(ContractController::view_signed_document),
|
||||
)
|
||||
.route(
|
||||
"/{id}/share",
|
||||
web::post().to(ContractController::share_contract),
|
||||
),
|
||||
)
|
||||
|
||||
// Asset routes
|
||||
.service(
|
||||
web::scope("/assets")
|
||||
@ -104,49 +227,113 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
.route("/create", web::post().to(AssetController::create))
|
||||
.route("/test", web::get().to(AssetController::test))
|
||||
.route("/{id}", web::get().to(AssetController::detail))
|
||||
.route("/{id}/valuation", web::post().to(AssetController::add_valuation))
|
||||
.route("/{id}/transaction", web::post().to(AssetController::add_transaction))
|
||||
.route("/{id}/status/{status}", web::post().to(AssetController::update_status))
|
||||
.route(
|
||||
"/{id}/valuation",
|
||||
web::post().to(AssetController::add_valuation),
|
||||
)
|
||||
.route(
|
||||
"/{id}/transaction",
|
||||
web::post().to(AssetController::add_transaction),
|
||||
)
|
||||
.route(
|
||||
"/{id}/status/{status}",
|
||||
web::post().to(AssetController::update_status),
|
||||
),
|
||||
)
|
||||
|
||||
// Marketplace routes
|
||||
.service(
|
||||
web::scope("/marketplace")
|
||||
.route("", web::get().to(MarketplaceController::index))
|
||||
.route("/listings", web::get().to(MarketplaceController::list_listings))
|
||||
.route("/my", web::get().to(MarketplaceController::my_listings))
|
||||
.route("/create", web::get().to(MarketplaceController::create_listing_form))
|
||||
.route("/create", 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))
|
||||
.route(
|
||||
"/listings",
|
||||
web::get().to(MarketplaceController::list_listings),
|
||||
)
|
||||
.route("/my", web::get().to(MarketplaceController::my_listings))
|
||||
.route(
|
||||
"/create",
|
||||
web::get().to(MarketplaceController::create_listing_form),
|
||||
)
|
||||
.route(
|
||||
"/create",
|
||||
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
|
||||
.service(
|
||||
web::scope("/defi")
|
||||
.route("", web::get().to(DefiController::index))
|
||||
.route("/providing", web::post().to(DefiController::create_providing))
|
||||
.route("/receiving", web::post().to(DefiController::create_receiving))
|
||||
.route(
|
||||
"/providing",
|
||||
web::post().to(DefiController::create_providing),
|
||||
)
|
||||
.route(
|
||||
"/receiving",
|
||||
web::post().to(DefiController::create_receiving),
|
||||
)
|
||||
.route("/liquidity", web::post().to(DefiController::add_liquidity))
|
||||
.route("/staking", web::post().to(DefiController::create_staking))
|
||||
.route("/swap", web::post().to(DefiController::swap_tokens))
|
||||
.route("/collateral", web::post().to(DefiController::create_collateral))
|
||||
.route(
|
||||
"/collateral",
|
||||
web::post().to(DefiController::create_collateral),
|
||||
),
|
||||
)
|
||||
// Company routes
|
||||
.service(
|
||||
web::scope("/company")
|
||||
.route("", web::get().to(CompanyController::index))
|
||||
.route("/register", web::post().to(CompanyController::register))
|
||||
// OLD REGISTRATION ROUTE REMOVED - Now only payment flow creates companies
|
||||
.route("/view/{id}", web::get().to(CompanyController::view_company))
|
||||
.route("/switch/{id}", web::get().to(CompanyController::switch_entity))
|
||||
.route("/edit/{id}", web::get().to(CompanyController::edit_form))
|
||||
.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
|
||||
cfg.service(
|
||||
web::scope("/protected")
|
||||
.wrap(JwtAuth) // Apply JWT authentication middleware
|
||||
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
|
||||
);
|
||||
}
|
@ -1,16 +1,20 @@
|
||||
use actix_web::{error, Error, HttpResponse};
|
||||
use actix_web::{Error, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use tera::{self, Context, Function, Tera, Value};
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
use std::error::Error as StdError;
|
||||
use tera::{self, Context, Function, Tera, Value};
|
||||
|
||||
// Export modules
|
||||
pub mod redis_service;
|
||||
pub mod secure_logging;
|
||||
pub mod stripe_security;
|
||||
|
||||
// Re-export for easier imports
|
||||
pub use redis_service::RedisCalendarService;
|
||||
// pub use redis_service::RedisCalendarService; // Currently unused
|
||||
|
||||
/// Error type for template rendering
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TemplateError {
|
||||
pub message: String,
|
||||
pub details: String,
|
||||
@ -25,10 +29,16 @@ impl std::fmt::Display for TemplateError {
|
||||
|
||||
impl std::error::Error for TemplateError {}
|
||||
|
||||
/// Registers custom Tera functions
|
||||
/// Registers custom Tera functions and filters
|
||||
pub fn register_tera_functions(tera: &mut tera::Tera) {
|
||||
tera.register_function("now", NowFunction);
|
||||
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
|
||||
@ -68,14 +78,10 @@ impl Function for FormatDateFunction {
|
||||
None => {
|
||||
return Err(tera::Error::msg(
|
||||
"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") {
|
||||
@ -89,23 +95,130 @@ impl Function for FormatDateFunction {
|
||||
// Convert timestamp to DateTime using the non-deprecated method
|
||||
let datetime = match DateTime::from_timestamp(timestamp, 0) {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[allow(dead_code)]
|
||||
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
|
||||
date.format(format).to_string()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if s.len() <= max_length {
|
||||
s.to_string()
|
||||
@ -114,6 +227,26 @@ pub fn truncate_string(s: &str, max_length: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses markdown content and returns HTML
|
||||
pub fn parse_markdown(markdown_content: &str) -> String {
|
||||
// Set up markdown parser options
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
|
||||
// Create parser
|
||||
let parser = Parser::new_ext(markdown_content, options);
|
||||
|
||||
// Render to HTML
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
html_output
|
||||
}
|
||||
|
||||
/// Renders a template with error handling
|
||||
///
|
||||
/// This function attempts to render a template and handles any errors by rendering
|
||||
@ -136,10 +269,13 @@ pub fn render_template(
|
||||
Ok(content) => {
|
||||
println!("DEBUG: Successfully rendered template: {}", template_name);
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(content))
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error with more details
|
||||
println!("DEBUG: Template rendering error for {}: {}", template_name, e);
|
||||
println!(
|
||||
"DEBUG: Template rendering error for {}: {}",
|
||||
template_name, e
|
||||
);
|
||||
println!("DEBUG: Error details: {:?}", e);
|
||||
|
||||
// Print the error cause chain for better debugging
|
||||
|
@ -1,7 +1,9 @@
|
||||
#![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 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
|
||||
lazy_static! {
|
||||
@ -59,11 +61,11 @@ impl RedisCalendarService {
|
||||
})?;
|
||||
|
||||
// Save the event
|
||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.id);
|
||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.base_data.id);
|
||||
let _: () = conn.set(event_key, json)?;
|
||||
|
||||
// Add the event ID to the set of all events
|
||||
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.id)?;
|
||||
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.base_data.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
315
actix_mvc_app/src/utils/secure_logging.rs
Normal file
315
actix_mvc_app/src/utils/secure_logging.rs
Normal file
@ -0,0 +1,315 @@
|
||||
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"));
|
||||
}
|
||||
}
|
257
actix_mvc_app/src/utils/stripe_security.rs
Normal file
257
actix_mvc_app/src/utils/stripe_security.rs
Normal file
@ -0,0 +1,257 @@
|
||||
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());
|
||||
}
|
||||
}
|
403
actix_mvc_app/src/validators/company.rs
Normal file
403
actix_mvc_app/src/validators/company.rs
Normal file
@ -0,0 +1,403 @@
|
||||
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"));
|
||||
}
|
||||
}
|
4
actix_mvc_app/src/validators/mod.rs
Normal file
4
actix_mvc_app/src/validators/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod company;
|
||||
|
||||
// Re-export for easier imports
|
||||
pub use company::{CompanyRegistrationValidator, ValidationError, ValidationResult};
|
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/calendar/new" method="post">
|
||||
<form action="/calendar/events" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Event Title</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required>
|
||||
@ -39,6 +39,13 @@
|
||||
</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">
|
||||
<label for="color" class="form-label">Event Color</label>
|
||||
<select class="form-control" id="color" name="color">
|
||||
@ -60,6 +67,69 @@
|
||||
|
||||
<script>
|
||||
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
|
||||
document.querySelector('form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
@ -67,6 +137,12 @@
|
||||
const startTime = document.getElementById('start_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
|
||||
const startRFC = new Date(startTime).toISOString();
|
||||
const endRFC = new Date(endTime).toISOString();
|
||||
|
417
actix_mvc_app/src/views/company/documents.html
Normal file
417
actix_mvc_app/src/views/company/documents.html
Normal file
@ -0,0 +1,417 @@
|
||||
{% 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 %}
|
249
actix_mvc_app/src/views/company/edit.html
Normal file
249
actix_mvc_app/src/views/company/edit.html
Normal file
@ -0,0 +1,249 @@
|
||||
{% 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,55 +15,71 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Example rows -->
|
||||
{% if companies and companies|length > 0 %}
|
||||
{% for company in companies %}
|
||||
<tr>
|
||||
<td>Zanzibar Digital Solutions</td>
|
||||
<td>Startup FZC</td>
|
||||
<td><span class="badge bg-success">Active</span></td>
|
||||
<td>2025-04-01</td>
|
||||
<td>{{ company.name }}</td>
|
||||
<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>
|
||||
<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>
|
||||
<div class="btn-group">
|
||||
<a href="/company/view/company1" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||
<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 href="/company/view/{{ company.base_data.id }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>Blockchain Innovations Ltd</td>
|
||||
<td>Growth FZC</td>
|
||||
<td><span class="badge bg-success">Active</span></td>
|
||||
<td>2025-03-15</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="/company/view/company2" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||
<a href="/company/switch/company2" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a>
|
||||
<td colspan="5" class="text-center py-4">
|
||||
<div class="text-muted">
|
||||
<i class="bi bi-building display-4 mb-3"></i>
|
||||
<h5>No Companies Found</h5>
|
||||
<p>You haven't registered any companies yet. Get started by registering your first company.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="document.querySelector('#register-tab').click()">
|
||||
<i class="bi bi-plus-circle me-1"></i> Register Your First Company
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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 -->
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Details Modal -->
|
||||
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details</h5>
|
||||
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -186,7 +202,8 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</button>
|
||||
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i
|
||||
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
78
actix_mvc_app/src/views/company/payment_error.html
Normal file
78
actix_mvc_app/src/views/company/payment_error.html
Normal file
@ -0,0 +1,78 @@
|
||||
{% 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 %}
|
89
actix_mvc_app/src/views/company/payment_success.html
Normal file
89
actix_mvc_app/src/views/company/payment_success.html
Normal file
@ -0,0 +1,89 @@
|
||||
{% 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,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ company_name }} - Company Details{% endblock %}
|
||||
{% block title %}{{ company.name }} - Company Details{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
@ -9,6 +9,7 @@
|
||||
background-color: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
@ -19,13 +20,61 @@
|
||||
{% 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-building me-2"></i>{{ company_name }}</h2>
|
||||
<h2><i class="bi bi-building me-2"></i>{{ company.name }}</h2>
|
||||
<div>
|
||||
<a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to 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" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to
|
||||
Companies</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>
|
||||
|
||||
<!-- 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="col-md-6">
|
||||
<div class="card h-100">
|
||||
@ -36,29 +85,49 @@
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th style="width: 30%">Company Name:</th>
|
||||
<td>{{ company_name }}</td>
|
||||
<td>{{ company.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Type:</th>
|
||||
<td>{{ company_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Registration Date:</th>
|
||||
<td>{{ registration_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status:</th>
|
||||
<td>
|
||||
{% if status == "Active" %}
|
||||
<span class="badge bg-success">{{ status }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">{{ status }}</span>
|
||||
{% 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>Purpose:</th>
|
||||
<td>{{ purpose }}</td>
|
||||
<th>Registration Number:</th>
|
||||
<td>{{ company.registration_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Registration Date:</th>
|
||||
<td>{{ incorporation_date_formatted }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status:</th>
|
||||
<td>
|
||||
{% if company.status == "Active" %}
|
||||
<span class="badge bg-success">{{ company.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 %}
|
||||
<span class="badge bg-secondary">{{ company.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Industry:</th>
|
||||
<td>{{ company.industry | default(value="Not specified") }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ company.description | default(value="No description provided") }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@ -67,21 +136,79 @@
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing Information</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Additional Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th style="width: 30%">Plan:</th>
|
||||
<td>{{ plan }}</td>
|
||||
<th style="width: 30%">Email:</th>
|
||||
<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>
|
||||
<th>Next Billing:</th>
|
||||
<td>{{ next_billing }}</td>
|
||||
<th>Phone:</th>
|
||||
<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>
|
||||
<th>Payment Method:</th>
|
||||
<td>{{ payment_method }}</td>
|
||||
<th>Website:</th>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@ -104,12 +231,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if shareholders and shareholders|length > 0 %}
|
||||
{% for shareholder in shareholders %}
|
||||
<tr>
|
||||
<td>{{ shareholder.0 }}</td>
|
||||
<td>{{ shareholder.1 }}</td>
|
||||
<td>{{ shareholder.name }}</td>
|
||||
<td>{{ shareholder.percentage }}%</td>
|
||||
</tr>
|
||||
{% 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>
|
||||
</table>
|
||||
</div>
|
||||
@ -118,35 +254,74 @@
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i>Contracts</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing & Payment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
{% if payment_info %}
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>Contract</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contract in contracts %}
|
||||
<tr>
|
||||
<td>{{ contract.0 }}</td>
|
||||
<th style="width: 40%">Payment Status:</th>
|
||||
<td>
|
||||
{% if contract.1 == "Signed" %}
|
||||
<span class="badge bg-success">{{ contract.1 }}</span>
|
||||
{% if payment_info.status == "Succeeded" %}
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Paid
|
||||
</span>
|
||||
{% elif payment_info.status == "Pending" %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="bi bi-clock me-1"></i>Pending
|
||||
</span>
|
||||
{% elif payment_info.status == "Failed" %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>Failed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">{{ contract.1 }}</span>
|
||||
<span class="badge bg-secondary">{{ payment_info.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Payment Plan:</th>
|
||||
<td>{{ payment_plan_display }}</td>
|
||||
</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>
|
||||
<a href="/contracts/view/{{ contract.0 | lower | replace(from=' ', to='-') }}" class="btn btn-sm btn-outline-primary">View</a>
|
||||
<code class="small">{{ payment_info.payment_intent_id }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endif %}
|
||||
</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>
|
||||
@ -158,9 +333,12 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/company/edit/{{ company_id }}" class="btn btn-outline-primary"><i 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/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/edit/{{ company.base_data.id }}" class="btn btn-outline-primary"><i
|
||||
class="bi bi-pencil me-1"></i>Edit Company</a>
|
||||
<a href="/company/documents/{{ company.base_data.id }}" class="btn btn-outline-secondary"><i
|
||||
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>
|
||||
|
200
actix_mvc_app/src/views/contracts/add_signer.html
Normal file
200
actix_mvc_app/src/views/contracts/add_signer.html
Normal file
@ -0,0 +1,200 @@
|
||||
{% 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 %}
|
128
actix_mvc_app/src/views/contracts/all_activities.html
Normal file
128
actix_mvc_app/src/views/contracts/all_activities.html
Normal file
@ -0,0 +1,128 @@
|
||||
{% 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,27 +36,41 @@
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="PendingSignatures">Pending Signatures</option>
|
||||
<option value="Signed">Signed</option>
|
||||
<option value="Expired">Expired</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>Draft
|
||||
</option>
|
||||
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
||||
%}selected{% endif %}>Pending Signatures</option>
|
||||
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
||||
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>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">Contract Type</label>
|
||||
<select class="form-select" id="type" name="type">
|
||||
<option value="">All Types</option>
|
||||
<option value="Service">Service Agreement</option>
|
||||
<option value="Employment">Employment Contract</option>
|
||||
<option value="NDA">Non-Disclosure Agreement</option>
|
||||
<option value="SLA">Service Level Agreement</option>
|
||||
<option value="Other">Other</option>
|
||||
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
||||
%}selected{% endif %}>Service Agreement</option>
|
||||
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
||||
%}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="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">
|
||||
<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>
|
||||
@ -98,7 +112,8 @@
|
||||
</td>
|
||||
<td>{{ contract.contract_type }}</td>
|
||||
<td>
|
||||
<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 %}">
|
||||
<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 %}">
|
||||
{{ contract.status }}
|
||||
</span>
|
||||
</td>
|
||||
@ -112,9 +127,14 @@
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if contract.status == 'Draft' %}
|
||||
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||
<a href="/contracts/{{ contract.id }}/edit"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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 %}
|
||||
</div>
|
||||
</td>
|
||||
@ -137,4 +157,70 @@
|
||||
</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('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,12 +25,14 @@
|
||||
<div class="card-body">
|
||||
<form action="/contracts/create" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Contract Title <span class="text-danger">*</span></label>
|
||||
<label for="title" class="form-label">Contract Title <span
|
||||
class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="title" name="title" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="contract_type" class="form-label">Contract Type <span class="text-danger">*</span></label>
|
||||
<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>
|
||||
<option value="" selected disabled>Select a contract type</option>
|
||||
{% for type in contract_types %}
|
||||
@ -40,14 +42,45 @@
|
||||
</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></textarea>
|
||||
<label for="description" class="form-label">Description <span
|
||||
class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
required></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"></textarea>
|
||||
<div class="form-text">You can leave this blank and add content later.</div>
|
||||
<label for="content" class="form-label">Contract Content (Markdown)</label>
|
||||
<textarea class="form-control" id="content" name="content" rows="10" placeholder="# Contract Title
|
||||
|
||||
## 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 class="mb-3">
|
||||
@ -75,7 +108,8 @@
|
||||
<h5 class="mb-0">Tips</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Creating a new contract is just the first step. After creating the contract, you'll be able to:</p>
|
||||
<p>Creating a new contract is just the first step. After creating the contract, you'll be able to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Add signers who need to approve the contract</li>
|
||||
<li>Edit the contract content</li>
|
||||
@ -93,16 +127,20 @@
|
||||
<div class="card-body">
|
||||
<p>You can use one of our pre-defined templates to get started quickly:</p>
|
||||
<div class="list-group">
|
||||
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('nda')">
|
||||
<button type="button" class="list-group-item list-group-item-action"
|
||||
onclick="loadTemplate('nda')">
|
||||
Non-Disclosure Agreement
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('service')">
|
||||
<button type="button" class="list-group-item list-group-item-action"
|
||||
onclick="loadTemplate('service')">
|
||||
Service Agreement
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('employment')">
|
||||
<button type="button" class="list-group-item list-group-item-action"
|
||||
onclick="loadTemplate('employment')">
|
||||
Employment Contract
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('sla')">
|
||||
<button type="button" class="list-group-item list-group-item-action"
|
||||
onclick="loadTemplate('sla')">
|
||||
Service Level Agreement
|
||||
</button>
|
||||
</div>
|
||||
@ -127,13 +165,95 @@
|
||||
title = 'Non-Disclosure Agreement';
|
||||
description = 'Standard NDA for protecting confidential information';
|
||||
contractType = '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...';
|
||||
content = `# Non-Disclosure Agreement
|
||||
|
||||
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;
|
||||
case 'service':
|
||||
title = 'Service Agreement';
|
||||
description = 'Agreement for providing professional services';
|
||||
contractType = '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...';
|
||||
content = `# Service Agreement
|
||||
|
||||
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;
|
||||
case 'employment':
|
||||
title = 'Employment Contract';
|
||||
|
215
actix_mvc_app/src/views/contracts/edit_contract.html
Normal file
215
actix_mvc_app/src/views/contracts/edit_contract.html
Normal file
@ -0,0 +1,215 @@
|
||||
{% 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,58 +11,108 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.total_contracts > 0 %}
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-primary h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Total</h5>
|
||||
<p class="display-4">{{ stats.total_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Total</h5>
|
||||
<h3 class="mb-0">{{ stats.total_contracts }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-secondary h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Draft</h5>
|
||||
<p class="display-4">{{ stats.draft_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Draft</h5>
|
||||
<h3 class="mb-0">{{ stats.draft_contracts }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-warning h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Pending</h5>
|
||||
<p class="display-4">{{ stats.pending_signature_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Pending</h5>
|
||||
<h3 class="mb-0">{{ stats.pending_signature_contracts }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-success h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Signed</h5>
|
||||
<p class="display-4">{{ stats.signed_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Signed</h5>
|
||||
<h3 class="mb-0">{{ stats.signed_contracts }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-danger h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Expired</h5>
|
||||
<p class="display-4">{{ stats.expired_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Expired</h5>
|
||||
<h3 class="mb-0">{{ stats.expired_contracts }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card text-white bg-dark h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Cancelled</h5>
|
||||
<p class="display-4">{{ stats.cancelled_contracts }}</p>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title mb-1">Cancelled</h5>
|
||||
<h3 class="mb-0">{{ stats.cancelled_contracts }}</h3>
|
||||
</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 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
@ -86,6 +136,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pending Signature Contracts -->
|
||||
{% if pending_signature_contracts and pending_signature_contracts | length > 0 %}
|
||||
@ -168,7 +219,8 @@
|
||||
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||
<a href="/contracts/{{ contract.id }}/edit"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
@ -183,5 +235,115 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function showHelpModal() {
|
||||
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
|
||||
helpModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -13,7 +13,10 @@
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<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">
|
||||
<a href="/contracts/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||
@ -23,38 +26,132 @@
|
||||
</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 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Filters</h5>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<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 class="collapse show" id="filtersCollapse">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="PendingSignatures">Pending Signatures</option>
|
||||
<option value="Signed">Signed</option>
|
||||
<option value="Expired">Expired</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>
|
||||
Draft</option>
|
||||
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
||||
%}selected{% endif %}>Pending Signatures</option>
|
||||
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
||||
Signed</option>
|
||||
<option value="Active" {% if current_status_filter=="Active" %}selected{% endif %}>
|
||||
Active</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>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">Contract Type</label>
|
||||
<select class="form-select" id="type" name="type">
|
||||
<option value="">All Types</option>
|
||||
<option value="Service">Service Agreement</option>
|
||||
<option value="Employment">Employment Contract</option>
|
||||
<option value="NDA">Non-Disclosure Agreement</option>
|
||||
<option value="SLA">Service Level Agreement</option>
|
||||
<option value="Other">Other</option>
|
||||
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
||||
%}selected{% endif %}>Service Agreement</option>
|
||||
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
||||
%}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-4 d-flex align-items-end">
|
||||
<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>
|
||||
@ -62,53 +159,128 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contract List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">My Contracts</h5>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<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 class="card-body">
|
||||
{% if contracts and contracts | length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Contract Title</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Signers</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
<th scope="col">
|
||||
<div class="d-flex align-items-center">
|
||||
Contract Title
|
||||
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
|
||||
onclick="sortTable(0)"></i>
|
||||
</div>
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>
|
||||
<a href="/contracts/{{ contract.id }}">{{ contract.title }}</a>
|
||||
<div>
|
||||
<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>{{ contract.contract_type }}</td>
|
||||
<td>
|
||||
<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 %}">
|
||||
<span class="badge bg-light text-dark">{{ contract.contract_type }}</span>
|
||||
</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 }}
|
||||
</span>
|
||||
</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>
|
||||
{% 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">
|
||||
<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>
|
||||
</a>
|
||||
{% if contract.status == 'Draft' %}
|
||||
<a href="/contracts/{{ contract.id }}/edit" class="btn btn-sm btn-outline-secondary">
|
||||
<a href="/contracts/{{ contract.id }}/edit"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit Contract">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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 %}
|
||||
</div>
|
||||
</td>
|
||||
@ -119,11 +291,20 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-text fs-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">You don't have any contracts yet</p>
|
||||
<a href="/contracts/create" class="btn btn-primary mt-2">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
|
||||
</div>
|
||||
<h4 class="text-muted mb-3">No Contracts Found</h4>
|
||||
<p class="text-muted mb-4">You haven't created any contracts yet. Get started by creating your
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -131,4 +312,166 @@
|
||||
</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 %}
|
||||
|
||||
{% 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 %}
|
370
actix_mvc_app/src/views/contracts/signed_document.html
Normal file
370
actix_mvc_app/src/views/contracts/signed_document.html
Normal file
@ -0,0 +1,370 @@
|
||||
{% 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 %}
|
125
actix_mvc_app/src/views/errors/404.html
Normal file
125
actix_mvc_app/src/views/errors/404.html
Normal file
@ -0,0 +1,125 @@
|
||||
<!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>
|
18
actix_mvc_app/src/views/governance/_header.html
Normal file
18
actix_mvc_app/src/views/governance/_header.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!-- 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>
|
32
actix_mvc_app/src/views/governance/_tabs.html
Normal file
32
actix_mvc_app/src/views/governance/_tabs.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!-- 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>
|
118
actix_mvc_app/src/views/governance/all_activities.html
Normal file
118
actix_mvc_app/src/views/governance/all_activities.html
Normal file
@ -0,0 +1,118 @@
|
||||
{% 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,53 +4,54 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="display-5 mb-4">Create Governance Proposal</h1>
|
||||
<p class="lead">Submit a new proposal for the community to vote on.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
{% include "governance/_header.html" %}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="row mb-4">
|
||||
{% include "governance/_tabs.html" %}
|
||||
|
||||
<!-- Info Alert -->
|
||||
<div class="row">
|
||||
<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" 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 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 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>
|
||||
|
||||
<!-- Proposal Form -->
|
||||
<!-- Proposal Form and Guidelines in Flex Layout -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<!-- Proposal Form Column -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">New Proposal</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/governance/create" method="post">
|
||||
<form action="/governance/create" method="post" id="proposalForm" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required
|
||||
placeholder="Enter a clear, concise title for your proposal">
|
||||
<input type="text" class="form-control" id="title" name="title" required minlength="5"
|
||||
maxlength="100" 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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="6" required
|
||||
<textarea class="form-control" id="description" name="description" rows="8" required
|
||||
minlength="50" maxlength="5000"
|
||||
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>
|
||||
|
||||
@ -58,11 +59,15 @@
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<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>
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
@ -84,12 +89,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidelines Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card bg-light">
|
||||
<!-- Guidelines Column -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card bg-light h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Proposal Guidelines</h5>
|
||||
</div>
|
||||
@ -116,4 +119,111 @@
|
||||
</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 %}
|
@ -3,25 +3,11 @@
|
||||
{% block title %}Governance Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
{% include "governance/_header.html" %}
|
||||
|
||||
<!-- 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="/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" href="/governance/create">Create Proposal</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "governance/_tabs.html" %}
|
||||
|
||||
<!-- Info Alert -->
|
||||
<div class="row mb-2">
|
||||
@ -29,9 +15,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 Governance</h5>
|
||||
<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>
|
||||
<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>
|
||||
<div class="mt-2">
|
||||
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a>
|
||||
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i>
|
||||
Read Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -46,8 +35,10 @@
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Urgent: Voting Closes Soon</h5>
|
||||
<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>
|
||||
<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">
|
||||
@ -58,26 +49,54 @@
|
||||
<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: 65%" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">65% Yes</div>
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: 35%" aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">35% No</div>
|
||||
<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 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>26 votes cast</span>
|
||||
<span>Quorum: 75% reached</span>
|
||||
<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>
|
||||
<form action="/governance/proposals/{{ nearest_proposal.base_data.id }}/vote" method="post">
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" placeholder="Optional comment on your vote" aria-label="Vote comment">
|
||||
<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" value="yes" class="btn btn-success">Vote Yes</button>
|
||||
<button type="submit" name="vote" value="no" class="btn btn-danger">Vote No</button>
|
||||
<button type="submit" name="vote" value="abstain" class="btn btn-secondary">Abstain</button>
|
||||
<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>
|
||||
@ -112,9 +131,11 @@
|
||||
<div>
|
||||
<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>
|
||||
<small class="text-muted">{{ activity.created_at | date(format="%H:%M") }}</small>
|
||||
</div>
|
||||
<p class="mb-1">{{ activity.action }} on <a href="/governance/proposals/{{ activity.proposal_id }}">{{ activity.proposal_title }}</a></p>
|
||||
<p class="mb-1">{{ activity.action }} on <a
|
||||
href="/governance/proposals/{{ activity.proposal_id }}">{{
|
||||
activity.proposal_title }}</a></p>
|
||||
{% if activity.type == "comment" and activity.comment is defined %}
|
||||
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
|
||||
{% endif %}
|
||||
@ -125,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a href="/governance/proposals" class="btn btn-sm btn-outline-info">View All Activity</a>
|
||||
<a href="/governance/activities" class="btn btn-sm btn-outline-info">View All Activities</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -142,22 +163,23 @@
|
||||
<div class="row">
|
||||
{% set count = 0 %}
|
||||
{% for proposal in proposals %}
|
||||
{% if count < 3 %}
|
||||
<div class="col-md-4 mb-3">
|
||||
{% 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 %}">
|
||||
<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>
|
||||
<a href="/governance/proposals/{{ proposal.base_data.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>
|
||||
<span>Voting ends: {{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,23 +3,60 @@
|
||||
{% block title %}My Votes - Governance Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
{% include "governance/_header.html" %}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="row mb-4">
|
||||
{% include "governance/_tabs.html" %}
|
||||
|
||||
<!-- Info Alert -->
|
||||
<div class="row">
|
||||
<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" href="/governance/proposals">All Proposals</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" 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 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 Votes</h5>
|
||||
<p>Voting is a fundamental right of all token holders in our governance system. Each vote carries weight
|
||||
proportional to your token holdings, ensuring fair representation. The voting statistics below show the
|
||||
community's collective decision-making across all proposals.</p>
|
||||
<div class="mt-2">
|
||||
<a href="/governance/voting-guide" class="btn btn-sm btn-outline-primary"><i
|
||||
class="bi bi-check2-square"></i> Voting Guide</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voting Stats -->
|
||||
<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">
|
||||
{{ 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>
|
||||
|
||||
@ -48,18 +85,21 @@
|
||||
<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 %}">
|
||||
<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 %}">
|
||||
<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>
|
||||
<a href="/governance/proposals/{{ proposal.base_data.id }}"
|
||||
class="btn btn-sm btn-primary">View Proposal</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -79,57 +119,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voting Stats -->
|
||||
{% 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,8 +2,45 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
{% include "governance/_header.html" %}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
{% include "governance/_tabs.html" %}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
@ -30,42 +67,62 @@
|
||||
|
||||
<!-- Proposal Details -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h4 class="mb-0">{{ proposal.title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<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">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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">
|
||||
<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 }}
|
||||
</span>
|
||||
<small class="text-muted">Created by {{ proposal.creator_name }} on {{ proposal.created_at | date(format="%Y-%m-%d") }}</small>
|
||||
<span class="text-muted"><i class="bi bi-person me-1"></i>Created by {{ proposal.creator_name
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<h5>Description</h5>
|
||||
<p class="mb-4">{{ proposal.description }}</p>
|
||||
<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>
|
||||
|
||||
<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") }}
|
||||
<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 %}
|
||||
Not set
|
||||
<div class="text-center w-100">Not set</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Voting Results</h5>
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4 shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-bar-chart-fill me-2"></i>Voting Dashboard</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<!-- Voting Results Section -->
|
||||
<div class="mb-4">
|
||||
<h6 class="border-bottom pb-2 mb-3">Results</h6>
|
||||
|
||||
{% set yes_percent = 0 %}
|
||||
{% set no_percent = 0 %}
|
||||
{% set abstain_percent = 0 %}
|
||||
@ -76,114 +133,483 @@
|
||||
{% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
|
||||
{% endif %}
|
||||
|
||||
<p class="mb-1">Yes: {{ results.yes_count }} ({{ yes_percent }}%)</p>
|
||||
<div class="progress mb-3">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"></div>
|
||||
<!-- Yes votes -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-bold text-success"><i class="bi bi-check-circle-fill me-1"></i> Yes</span>
|
||||
<span class="badge bg-success rounded-pill">{{ results.yes_count }}</span>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: 12px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
|
||||
aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="{{ yes_percent }}% of votes"></div>
|
||||
</div>
|
||||
|
||||
<p class="mb-1">No: {{ results.no_count }} ({{ no_percent }}%)</p>
|
||||
<div class="progress mb-3">
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"></div>
|
||||
<!-- No votes -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-bold text-danger"><i class="bi bi-x-circle-fill me-1"></i> No</span>
|
||||
<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>
|
||||
|
||||
<p class="mb-1">Abstain: {{ results.abstain_count }} ({{ abstain_percent }}%)</p>
|
||||
<div class="progress mb-3">
|
||||
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"></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>
|
||||
<p class="text-center"><strong>Total Votes:</strong> {{ results.total_votes }}</p>
|
||||
<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>
|
||||
|
||||
<!-- 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 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>
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
|
||||
<!-- Votes List -->
|
||||
<div class="row mb-4">
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Votes ({{ votes | length }})</h5>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-list-check me-2"></i>Votes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if votes | length > 0 %}
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Voter</th>
|
||||
<th class="ps-3">Voter</th>
|
||||
<th>Vote</th>
|
||||
<th>Comment</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end pe-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vote in votes %}
|
||||
<tbody id="votesTableBody">
|
||||
{% if votes | length == 0 %}
|
||||
<tr>
|
||||
<td>{{ vote.voter_name }}</td>
|
||||
<td colspan="4" class="text-center py-4">
|
||||
<div class="py-3">
|
||||
<i class="bi bi-inbox fs-1 text-muted"></i>
|
||||
<p class="mt-2 mb-0">No votes have been cast yet</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for vote in votes %}
|
||||
<tr class="vote-row" data-vote-type="{{ vote.vote_type | lower }}">
|
||||
<td class="ps-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-circle me-2 bg-primary text-white">
|
||||
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 %}">
|
||||
<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 %}{{ vote.comment }}{% else %}No comment{% endif %}</td>
|
||||
<td>{{ vote.created_at | date(format="%Y-%m-%d %H:%M") }}</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>
|
||||
{% else %}
|
||||
<p class="text-center">No votes have been cast yet.</p>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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,6 +3,12 @@
|
||||
{% block title %}Proposals - Governance Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
{% include "governance/_header.html" %}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
{% include "governance/_tabs.html" %}
|
||||
|
||||
<!-- Success message if present -->
|
||||
{% if success %}
|
||||
<div class="row mb-4">
|
||||
@ -15,33 +21,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@ -55,17 +44,23 @@
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Approved">Approved</option>
|
||||
<option value="Rejected">Rejected</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
<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">
|
||||
<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>
|
||||
@ -85,6 +80,7 @@
|
||||
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if proposals and proposals|length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
@ -103,25 +99,41 @@
|
||||
<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 %}">
|
||||
<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") }}
|
||||
{% 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.id }}" class="btn btn-sm btn-primary">View</a>
|
||||
<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>
|
||||
|
362
actix_mvc_app/tests/payment_tests.rs
Normal file
362
actix_mvc_app/tests/payment_tests.rs
Normal file
@ -0,0 +1,362 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user