Compare commits

...

28 Commits

Author SHA1 Message Date
Mahmoud-Emad
d3a66d4fc8 feat: Add initial production deployment support
- Add .env.example file for environment variable setup
- Add .gitignore to manage sensitive files and directories
- Add Dockerfile.prod for production-ready Docker image
- Add PRODUCTION_CHECKLIST.md for pre/post deployment steps
- Add PRODUCTION_DEPLOYMENT.md for deployment instructions
- Add STRIPE_SETUP.md for Stripe payment configuration
- Add config/default.toml for default configuration settings
- Add config/local.toml.example for local configuration template
2025-06-25 18:32:20 +03:00
Mahmoud-Emad
464e253739 feat: Enhance contract management with new features
- Implement comprehensive contract listing with filtering by
  status and type, and search functionality.
- Add contract cloning, sharing, and cancellation features.
- Improve contract details view with enhanced UI and activity
  timeline.
- Implement signer management with add/update/delete and status
  updates, including signature data handling and rejection.
- Introduce contract creation and editing functionalities with
  markdown support.
- Add error handling for contract not found scenarios.
- Implement reminder system for pending signatures with rate
  limiting and status tracking.
- Add API endpoint for retrieving contract statistics.
- Improve logging with more descriptive messages.
- Refactor code for better structure and maintainability.
2025-06-12 13:53:33 +03:00
Mahmoud-Emad
7e95391a9c feat: Refactor governance models and views
- Moved governance models (`Vote`, `VoteType`, `VotingResults`) from
  `models/governance.rs` to `controllers/governance.rs` for better
  organization and to avoid circular dependencies.  This improves
  maintainability and reduces complexity.
- Updated governance views to use the new model locations.
- Added a limit to the number of recent activities displayed on the
  dashboard for performance optimization.
2025-06-03 15:31:50 +03:00
Mahmoud-Emad
9802d51acc feat: Migrate to hero-models for calendar event data
- Replaced custom `CalendarEvent` model with `heromodels`' `Event` model.
- Updated database interactions and controller logic to use the new model.
- Removed unnecessary `CalendarEvent` model and related code.
- Updated views to reflect changes in event data structure.
2025-06-03 15:12:53 +03:00
Mahmoud-Emad
2299b61e79 feat: Enhance calendar display of all-day events
- Improve display of all-day events by adding a message
  indicating when there are no all-day events scheduled.
- Add visual improvements to all-day event display using
  bootstrap classes.
- Clarify messaging when there are no events scheduled for a
  given day.
2025-05-29 14:17:48 +03:00
Mahmoud-Emad
b8928379de feat: Fix timezone issues in event creation
- Correctly handle timezones when creating events, ensuring that
  start and end times are accurately represented regardless of the
  user's timezone.
- Add 1-day compensation to event times to handle timezone shifts
  during conversion to UTC.
- Improve default time setting for date-specific events.
2025-05-29 14:07:03 +03:00
Mahmoud-Emad
45c4f4985e feat: Enhance calendar display and event management
- Improve event display: Show only the first two events for each day
  in the calendar, with a "+X more" link to show the rest.
- Add event details modal:  Allows viewing and deleting events.
- Enhance event creation modal: Improve user experience and add color
  selection for events.
- Improve year view: Show the number of events for each month.
- Improve day view: Display all day events separately.
- Improve styling and layout: Enhance the visual appeal and
  responsiveness of the calendar.
2025-05-28 16:59:24 +03:00
Mahmoud-Emad
58d1cde1ce feat: Migrate calendar functionality to a database
- Replaced Redis-based calendar with a database-backed solution
- Implemented database models for calendars and events
- Improved error handling and logging for database interactions
- Added new database functions for calendar management
- Updated calendar views to reflect the database changes
- Enhanced event creation and deletion processes
- Refined date/time handling for better consistency
2025-05-28 15:48:54 +03:00
Mahmoud-Emad
d815d9d365 feat: Add custom Tera filters for date/time formatting
- Add three new Tera filters: `format_hour`, `extract_hour`, and
  `format_time` for flexible date/time formatting in templates.
- Improve template flexibility and maintainability by allowing
  customizable date/time display.
- Enhance the user experience with more precise date/time rendering.
2025-05-28 10:43:02 +03:00
Mahmoud-Emad
2827cfebc9 refactor: Rename proposals module to governance
The `proposals` module has been renamed to `governance` to better
reflect its purpose and content.  This improves code clarity and
consistency.

- Renamed the `proposals` module to `governance` throughout the
  project to reflect the broader scope of governance features.
- Updated all related imports and function calls to use the new
  module name.
2025-05-28 09:29:19 +03:00
Mahmoud-Emad
7b15606da5 refactor: Remove unnecessary debug print statements
- Removed several `println!` statements from the `governance`
  controller and `proposals` database module to improve code
  cleanliness and reduce unnecessary console output.
- Updated the `all_activities.html` template to use the
  `created_at` field instead of `timestamp` for activity dates.
- Updated the `index.html` template to use the `created_at`
  field instead of `timestamp` for activity timestamps.
- Added `#[allow(unused_assignments)]` attribute to the
  `create_activity` function in `proposals.rs` to suppress a
  potentially unnecessary warning.
2025-05-28 09:24:56 +03:00
Mahmoud-Emad
11d7ae37b6 feat: Enhance governance module with activity tracking and DB refactor
- Refactor database interaction for proposals and activities.
- Add activity tracking for proposal creation and voting.
- Improve logging for better debugging and monitoring.
- Update governance views to display recent activities.
- Add strum and strum_macros crates for enum handling.
- Update Cargo.lock file with new dependencies.
2025-05-27 20:45:30 +03:00
Mahmoud-Emad
70ca9f1605 feat: Enhance governance dashboard with activity tracking
- Add governance activity tracker to record user actions.
- Display recent activities on the governance dashboard.
- Add a dedicated page to view all governance activities.
- Improve header information and styling across governance pages.
- Track proposal creation and voting activities.
2025-05-25 16:02:34 +03:00
Mahmoud-Emad
d12a082ca1 feat: Enhance Governance Controller and Proposal Handling
- Improve proposal search to include description field: This
  allows for more comprehensive search results.
- Fix redirect after voting: The redirect now correctly handles
  the success message.
- Handle potential invalid timestamps in ballots: The code now
  gracefully handles ballots with invalid timestamps, preventing
  crashes and using the current time as a fallback.
- Add local time formatting function:  This provides a way to
  display dates and times in the user's local timezone.
- Update database path: This simplifies the database setup.
- Improve proposal vote handling: Addresses issues with vote
  submission and timestamping.
- Add client-side pagination and filtering to proposal details:
  Improves user experience for viewing large vote lists.
2025-05-25 10:48:02 +03:00
Mahmoud-Emad
97e7a04827 feat: Add pagination and filtering improvements to proposal votes
- Added pagination to the proposal votes table to improve usability
  with large datasets.
- Implemented client-side filtering of votes by type and search
  terms, enhancing the user experience.
- Improved the responsiveness and efficiency of the vote filtering
  and pagination.
2025-05-22 17:13:52 +03:00
Mahmoud-Emad
3d8aca19cc feat: Improve user experience after voting on proposals
- Redirect users to the proposal detail page with a success
  message after a successful vote, improving feedback.
- Automatically remove the success message from the URL after a
  short time to avoid URL clutter and maintain a clean browsing
  experience.
- Add a success alert message on the proposal detail page to
  provide immediate visual confirmation of a successful vote.
- Improve the visual presentation of the votes list on the
  proposal detail page by adding top margin for better spacing.
2025-05-22 17:05:26 +03:00
Mahmoud-Emad
52fbc77e3e feat: Enhance proposal creation and display
- Improve proposal creation form with input validation and
  default date settings for a better user experience.
- Add context variables to the proposals template for
  consistent display across governance pages.
- Enhance proposal detail page with visual improvements,
  voting results display, and user voting functionality.
- Add styles for better visual presentation of proposal details
  and voting information.
2025-05-22 16:31:11 +03:00
Mahmoud-Emad
fad288f67d feat: Add total vote counts to governance views
- Add functionality to calculate total yes, no, and abstain votes
  across all proposals. This provides a summary of community
  voting patterns on the governance page.
- Improve the user experience by displaying total vote counts
  prominently on the "My Votes" page. This gives users a quick
  overview of the overall voting results.
- Enhance the "Create Proposal" page with informative guidelines
  and a helpful alert to guide users through the proposal creation
  process.  This improves clarity and ensures proposals are well-
  structured.
2025-05-22 16:08:12 +03:00
Mahmoud-Emad
4659697ae2 feat: Add filtering and searching to governance proposals page
- Added filtering of proposals by status (Draft, Active, Approved, Rejected, Cancelled).
- Added searching of proposals by title and description.
- Improved UI to persist filter and search values.
- Added a "No proposals found" message for better UX.
2025-05-22 15:47:11 +03:00
Mahmoud-Emad
67b80f237d feat: Enahnced the dashboard 2025-05-21 18:01:22 +03:00
Mahmoud-Emad
b606923102 feat: Finish the proposal dashboard 2025-05-21 15:56:47 +03:00
Mahmoud-Emad
8f1438dc01 feat: Remove mock proposals 2025-05-21 15:43:17 +03:00
Mahmoud-Emad
916f435dbc feat: Load voting 2025-05-21 15:04:45 +03:00
Mahmoud-Emad
5d9eaac1f8 feat: Implemented submit vote 2025-05-21 13:49:20 +03:00
Mahmoud-Emad
9c71c63ec5 feat: Working on the propsal page:
- Integerated the view proposal detail db call
- Use real data instead of mock data
2025-05-21 12:21:38 +03:00
Mahmoud-Emad
4a2f1c7282 feat: Implement Proposals page:
- Added the create new proposal functionality
- Added the list all proposals functionnality
2025-05-21 11:44:06 +03:00
Mahmoud Emad
60198dc2d4 fix: Remove warnings 2025-05-18 09:48:28 +03:00
Mahmoud Emad
e4e403e231 feat: Integerated the DB:
- Added an initialization with the db
- Implemented 'add_new_proposal' function to be used in the form
2025-05-18 09:07:59 +03:00
87 changed files with 24004 additions and 4326 deletions

View File

@ -0,0 +1,2 @@
[net]
git-fetch-with-cli = true

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

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,14 @@ name = "actix_mvc_app"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
name = "actix_mvc_app"
path = "src/lib.rs"
[[bin]]
name = "actix_mvc_app"
path = "src/main.rs"
[dependencies] [dependencies]
actix-multipart = "0.6.1" actix-multipart = "0.6.1"
futures-util = "0.3.30" futures-util = "0.3.30"
@ -15,6 +23,8 @@ env_logger = "0.11.2"
log = "0.4.21" log = "0.4.21"
dotenv = "0.15.0" dotenv = "0.15.0"
chrono = { version = "0.4.35", features = ["serde"] } chrono = { version = "0.4.35", features = ["serde"] }
heromodels = { path = "../../db/heromodels" }
heromodels_core = { path = "../../db/heromodels_core" }
config = "0.14.0" config = "0.14.0"
num_cpus = "1.16.0" num_cpus = "1.16.0"
futures = "0.3.30" futures = "0.3.30"
@ -27,3 +37,24 @@ redis = { version = "0.23.0", features = ["tokio-comp"] }
jsonwebtoken = "8.3.0" jsonwebtoken = "8.3.0"
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
urlencoding = "2.1.3" urlencoding = "2.1.3"
tokio = { version = "1.0", features = ["full"] }
async-stripe = { version = "0.41", features = ["runtime-tokio-hyper"] }
reqwest = { version = "0.12.20", features = ["json"] }
# Security dependencies for webhook verification
hmac = "0.12.1"
sha2 = "0.10.8"
hex = "0.4.3"
# Validation dependencies
regex = "1.10.2"
[dev-dependencies]
# Testing dependencies
tokio-test = "0.4.3"
[patch."https://git.ourworld.tf/herocode/db.git"]
rhai_autobind_macros = { path = "../../rhaj/rhai_autobind_macros" }
rhai_wrapper = { path = "../../rhaj/rhai_wrapper" }

View 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"]

View 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! 🎉

View 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.

View 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

View 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

View 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"

View 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

View File

@ -1,6 +1,6 @@
use std::env;
use config::{Config, ConfigError, File}; use config::{Config, ConfigError, File};
use serde::Deserialize; use serde::Deserialize;
use std::env;
/// Application configuration /// Application configuration
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -9,10 +9,13 @@ pub struct AppConfig {
pub server: ServerConfig, pub server: ServerConfig,
/// Template configuration /// Template configuration
pub templates: TemplateConfig, pub templates: TemplateConfig,
/// Stripe configuration
pub stripe: StripeConfig,
} }
/// Server configuration /// Server configuration
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ServerConfig { pub struct ServerConfig {
/// Host address to bind to /// Host address to bind to
pub host: String, pub host: String,
@ -29,6 +32,17 @@ pub struct TemplateConfig {
pub dir: String, pub dir: String,
} }
/// Stripe configuration
#[derive(Debug, Deserialize, Clone)]
pub struct StripeConfig {
/// Stripe publishable key
pub publishable_key: String,
/// Stripe secret key
pub secret_key: String,
/// Webhook endpoint secret
pub webhook_secret: Option<String>,
}
impl AppConfig { impl AppConfig {
/// Loads configuration from files and environment variables /// Loads configuration from files and environment variables
pub fn new() -> Result<Self, ConfigError> { pub fn new() -> Result<Self, ConfigError> {
@ -37,7 +51,10 @@ impl AppConfig {
.set_default("server.host", "127.0.0.1")? .set_default("server.host", "127.0.0.1")?
.set_default("server.port", 9999)? .set_default("server.port", 9999)?
.set_default("server.workers", None::<u32>)? .set_default("server.workers", None::<u32>)?
.set_default("templates.dir", "./src/views")?; .set_default("templates.dir", "./src/views")?
.set_default("stripe.publishable_key", "")?
.set_default("stripe.secret_key", "")?
.set_default("stripe.webhook_secret", None::<String>)?;
// Load from config file if it exists // Load from config file if it exists
if let Ok(config_path) = env::var("APP_CONFIG") { if let Ok(config_path) = env::var("APP_CONFIG") {
@ -50,7 +67,8 @@ impl AppConfig {
} }
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT) // Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
config_builder = config_builder.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 // Build and deserialize the config
let config = config_builder.build()?; let config = config_builder.build()?;
@ -61,4 +79,4 @@ impl AppConfig {
/// Returns the application configuration /// Returns the application configuration
pub fn get_config() -> AppConfig { pub fn get_config() -> AppConfig {
AppConfig::new().expect("Failed to load configuration") AppConfig::new().expect("Failed to load configuration")
} }

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ lazy_static! {
/// Controller for handling authentication-related routes /// Controller for handling authentication-related routes
pub struct AuthController; pub struct AuthController;
#[allow(dead_code)]
impl AuthController { impl AuthController {
/// Generate a JWT token for a user /// Generate a JWT token for a user
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> { fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {

View File

@ -1,12 +1,17 @@
use actix_web::{web, HttpResponse, Responder, Result};
use actix_session::Session; use actix_session::Session;
use actix_web::{HttpResponse, Responder, Result, web};
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tera::Tera;
use serde_json::Value; use serde_json::Value;
use tera::Tera;
use crate::models::{CalendarEvent, CalendarViewMode}; use crate::db::calendar::{
use crate::utils::{RedisCalendarService, render_template}; 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 /// Controller for handling calendar-related routes
pub struct CalendarController; pub struct CalendarController;
@ -14,9 +19,11 @@ pub struct CalendarController;
impl CalendarController { impl CalendarController {
/// Helper function to get user from session /// Helper function to get user from session
fn get_user_from_session(session: &Session) -> Option<Value> { fn get_user_from_session(session: &Session) -> Option<Value> {
session.get::<String>("user").ok().flatten().and_then(|user_json| { session
serde_json::from_str(&user_json).ok() .get::<String>("user")
}) .ok()
.flatten()
.and_then(|user_json| serde_json::from_str(&user_json).ok())
} }
/// Handles the calendar page route /// Handles the calendar page route
@ -27,113 +34,176 @@ impl CalendarController {
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
// Parse the view mode from the query parameters // Parse the view mode from the query parameters
let view_mode = 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()); ctx.insert("view_mode", &view_mode.to_str());
// Parse the date from the query parameters or use the current date // Parse the date from the query parameters or use the current date
let date = if let Some(date_str) = &query.date { let date = if let Some(date_str) = &query.date {
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(naive_date) => Utc.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(), Err(_) => Utc::now(),
} }
} else { } else {
Utc::now() Utc::now()
}; };
ctx.insert("current_date", &date.format("%Y-%m-%d").to_string()); ctx.insert("current_date", &date.format("%Y-%m-%d").to_string());
ctx.insert("current_year", &date.year()); ctx.insert("current_year", &date.year());
ctx.insert("current_month", &date.month()); ctx.insert("current_month", &date.month());
ctx.insert("current_day", &date.day()); ctx.insert("current_day", &date.day());
// Add user to context if available // Add user to context if available and ensure user has a calendar
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = Self::get_user_from_session(&_session) {
ctx.insert("user", &user); ctx.insert("user", &user);
// Get or create user calendar
if let (Some(user_id), Some(user_name)) = (
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
user.get("full_name").and_then(|v| v.as_str()),
) {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => {
log::info!(
"User calendar ready: ID {}, Name: '{}'",
calendar.get_id(),
calendar.name
);
ctx.insert("user_calendar", &calendar);
}
Err(e) => {
log::error!("Failed to get or create user calendar: {}", e);
// Continue without calendar - the app should still work
}
}
}
} }
// Get events for the current view // Get events for the current view
let (start_date, end_date) = match view_mode { let (start_date, end_date) = match view_mode {
CalendarViewMode::Year => { CalendarViewMode::Year => {
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap(); let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
let end = Utc.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) (start, end)
}, }
CalendarViewMode::Month => { 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 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) (start, end)
}, }
CalendarViewMode::Week => { CalendarViewMode::Week => {
// Calculate the start of the week (Sunday) // Calculate the start of the week (Sunday)
let _weekday = date.weekday().num_days_from_sunday(); let _weekday = date.weekday().num_days_from_sunday();
let start_date = date.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 start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
let end = start + chrono::Duration::days(7); let end = start + chrono::Duration::days(7);
(start, end) (start, end)
}, }
CalendarViewMode::Day => { CalendarViewMode::Day => {
let start = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0).unwrap(); let start = Utc
let end = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59).unwrap(); .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) (start, end)
}, }
}; };
// Get events from Redis // Get events from database
let events = match RedisCalendarService::get_events_in_range(start_date, end_date) { let events = match get_events() {
Ok(events) => 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) => { Err(e) => {
log::error!("Failed to get events from Redis: {}", e); log::error!("Failed to get events from database: {}", e);
vec![] vec![]
} }
}; };
ctx.insert("events", &events); ctx.insert("events", &events);
// Generate calendar data based on the view mode // Generate calendar data based on the view mode
match view_mode { match view_mode {
CalendarViewMode::Year => { CalendarViewMode::Year => {
let months = (1..=12).map(|month| { let months = (1..=12)
let month_name = match month { .map(|month| {
1 => "January", let month_name = match month {
2 => "February", 1 => "January",
3 => "March", 2 => "February",
4 => "April", 3 => "March",
5 => "May", 4 => "April",
6 => "June", 5 => "May",
7 => "July", 6 => "June",
8 => "August", 7 => "July",
9 => "September", 8 => "August",
10 => "October", 9 => "September",
11 => "November", 10 => "October",
12 => "December", 11 => "November",
_ => "", 12 => "December",
}; _ => "",
};
let month_events = events.iter()
.filter(|event| { let month_events = events
event.start_time.month() == month || event.end_time.month() == month .iter()
}) .filter(|event| {
.cloned() event.start_time.month() == month || event.end_time.month() == month
.collect::<Vec<_>>(); })
.cloned()
CalendarMonth { .collect::<Vec<_>>();
month,
name: month_name.to_string(), CalendarMonth {
events: month_events, month,
} name: month_name.to_string(),
}).collect::<Vec<_>>(); events: month_events,
}
})
.collect::<Vec<_>>();
ctx.insert("months", &months); ctx.insert("months", &months);
}, }
CalendarViewMode::Month => { CalendarViewMode::Month => {
let days_in_month = Self::last_day_of_month(date.year(), date.month()); let days_in_month = Self::last_day_of_month(date.year(), date.month());
let first_day = Utc.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 first_weekday = first_day.weekday().num_days_from_sunday();
let mut calendar_days = Vec::new(); let mut calendar_days = Vec::new();
// Add empty days for the start of the month // Add empty days for the start of the month
for _ in 0..first_weekday { for _ in 0..first_weekday {
calendar_days.push(CalendarDay { calendar_days.push(CalendarDay {
@ -142,27 +212,34 @@ impl CalendarController {
is_current_month: false, is_current_month: false,
}); });
} }
// Add days for the current month // Add days for the current month
for day in 1..=days_in_month { for day in 1..=days_in_month {
let day_events = events.iter() let day_events = events
.iter()
.filter(|event| { .filter(|event| {
let day_start = Utc.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0).unwrap(); let day_start = Utc
let day_end = Utc.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59).unwrap(); .with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0)
.unwrap();
(event.start_time <= day_end && event.end_time >= day_start) || let day_end = Utc
(event.all_day && event.start_time.day() <= day && event.end_time.day() >= day) .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)
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
calendar_days.push(CalendarDay { calendar_days.push(CalendarDay {
day, day,
events: day_events, events: day_events,
is_current_month: true, is_current_month: true,
}); });
} }
// Fill out the rest of the calendar grid (6 rows of 7 days) // Fill out the rest of the calendar grid (6 rows of 7 days)
let remaining_days = 42 - calendar_days.len(); let remaining_days = 42 - calendar_days.len();
for day in 1..=remaining_days { for day in 1..=remaining_days {
@ -172,149 +249,250 @@ impl CalendarController {
is_current_month: false, is_current_month: false,
}); });
} }
ctx.insert("calendar_days", &calendar_days); ctx.insert("calendar_days", &calendar_days);
ctx.insert("month_name", &Self::month_name(date.month())); ctx.insert("month_name", &Self::month_name(date.month()));
}, }
CalendarViewMode::Week => { CalendarViewMode::Week => {
// Calculate the start of the week (Sunday) // Calculate the start of the week (Sunday)
let weekday = date.weekday().num_days_from_sunday(); let weekday = date.weekday().num_days_from_sunday();
let week_start = date - chrono::Duration::days(weekday as i64); let week_start = date - chrono::Duration::days(weekday as i64);
let mut week_days = Vec::new(); let mut week_days = Vec::new();
for i in 0..7 { for i in 0..7 {
let day_date = week_start + chrono::Duration::days(i); let day_date = week_start + chrono::Duration::days(i);
let day_events = events.iter() let day_events = events
.iter()
.filter(|event| { .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_start = Utc
let day_end = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 23, 59, 59).unwrap(); .with_ymd_and_hms(
day_date.year(),
(event.start_time <= day_end && event.end_time >= day_start) || day_date.month(),
(event.all_day && event.start_time.day() <= day_date.day() && event.end_time.day() >= day_date.day()) day_date.day(),
0,
0,
0,
)
.unwrap();
let day_end = Utc
.with_ymd_and_hms(
day_date.year(),
day_date.month(),
day_date.day(),
23,
59,
59,
)
.unwrap();
(event.start_time <= day_end && event.end_time >= day_start)
|| (event.all_day
&& event.start_time.day() <= day_date.day()
&& event.end_time.day() >= day_date.day())
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
week_days.push(CalendarDay { week_days.push(CalendarDay {
day: day_date.day(), day: day_date.day(),
events: day_events, events: day_events,
is_current_month: day_date.month() == date.month(), is_current_month: day_date.month() == date.month(),
}); });
} }
ctx.insert("week_days", &week_days); ctx.insert("week_days", &week_days);
}, }
CalendarViewMode::Day => { CalendarViewMode::Day => {
log::info!("Day view selected"); log::info!("Day view selected");
ctx.insert("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 // Add debug info
log::info!("Events count: {}", events.len()); log::info!("Events count: {}", events.len());
log::info!("Current date: {}", date.format("%Y-%m-%d")); log::info!("Current date: {}", date.format("%Y-%m-%d"));
log::info!("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) render_template(&tmpl, "calendar/index.html", &ctx)
} }
/// Handles the new event page route /// Handles the new event page route
pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> { pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
// Add user to context if available // Add user to context if available and ensure user has a calendar
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = Self::get_user_from_session(&_session) {
ctx.insert("user", &user); ctx.insert("user", &user);
// Get or create user calendar
if let (Some(user_id), Some(user_name)) = (
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
user.get("full_name").and_then(|v| v.as_str()),
) {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => {
ctx.insert("user_calendar", &calendar);
}
Err(e) => {
log::error!("Failed to get or create user calendar: {}", e);
}
}
}
} }
render_template(&tmpl, "calendar/new_event.html", &ctx) render_template(&tmpl, "calendar/new_event.html", &ctx)
} }
/// Handles the create event route /// Handles the create event route
pub async fn create_event( pub async fn create_event(
form: web::Form<EventForm>, form: web::Form<EventForm>,
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
_session: Session, _session: Session,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
// Log the form data for debugging
log::info!(
"Creating event with form data: title='{}', start_time='{}', end_time='{}', all_day={}",
form.title,
form.start_time,
form.end_time,
form.all_day
);
// Parse the start and end times // Parse the start and end times
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) { let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
Ok(dt) => dt.with_timezone(&Utc), Ok(dt) => dt.with_timezone(&Utc),
Err(e) => { Err(e) => {
log::error!("Failed to parse start time: {}", e); log::error!("Failed to parse start time '{}': {}", form.start_time, e);
return Ok(HttpResponse::BadRequest().body("Invalid start time")); return Ok(HttpResponse::BadRequest().body("Invalid start time format"));
} }
}; };
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) { let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
Ok(dt) => dt.with_timezone(&Utc), Ok(dt) => dt.with_timezone(&Utc),
Err(e) => { Err(e) => {
log::error!("Failed to parse end time: {}", e); log::error!("Failed to parse end time '{}': {}", form.end_time, e);
return Ok(HttpResponse::BadRequest().body("Invalid end time")); return Ok(HttpResponse::BadRequest().body("Invalid end time format"));
} }
}; };
// Create the event // Get user information from session
let event = CalendarEvent::new( let user_info = Self::get_user_from_session(&_session);
form.title.clone(), let (user_id, user_name) = if let Some(user) = &user_info {
form.description.clone(), 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, start_time,
end_time, end_time,
Some(form.color.clone()), None, // location
Some(&form.color),
form.all_day, form.all_day,
None, // User ID would come from session in a real app user_id,
); None, // category
None, // reminder_minutes
// Save the event to Redis ) {
match RedisCalendarService::save_event(&event) { Ok((event_id, _saved_event)) => {
Ok(_) => { log::info!("Created event with ID: {}", event_id);
// If user is logged in, add the event to their calendar
if let Some(user_id) = user_id {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => match add_event_to_calendar(calendar.get_id(), event_id) {
Ok(_) => {
log::info!(
"Added event {} to calendar {}",
event_id,
calendar.get_id()
);
}
Err(e) => {
log::error!("Failed to add event to calendar: {}", e);
}
},
Err(e) => {
log::error!("Failed to get user calendar: {}", e);
}
}
}
// Redirect to the calendar page // Redirect to the calendar page
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", "/calendar")) .append_header(("Location", "/calendar"))
.finish()) .finish())
}, }
Err(e) => { Err(e) => {
log::error!("Failed to save event to Redis: {}", e); log::error!("Failed to save event to database: {}", e);
// Show an error message // Show an error message
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
ctx.insert("error", "Failed to save event"); ctx.insert("error", "Failed to save event");
// Add user to context if available // Add user to context if available
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = user_info {
ctx.insert("user", &user); ctx.insert("user", &user);
} }
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?; let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
Ok(HttpResponse::InternalServerError().content_type("text/html").body(result.into_body())) Ok(HttpResponse::InternalServerError()
.content_type("text/html")
.body(result.into_body()))
} }
} }
} }
/// Handles the delete event route /// Handles the delete event route
pub async fn delete_event( pub async fn delete_event(
path: web::Path<String>, path: web::Path<String>,
_session: Session, _session: Session,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
// Delete the event from Redis // Parse the event ID
match RedisCalendarService::delete_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(_) => { Ok(_) => {
log::info!("Deleted event with ID: {}", event_id);
// Redirect to the calendar page // Redirect to the calendar page
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", "/calendar")) .append_header(("Location", "/calendar"))
.finish()) .finish())
}, }
Err(e) => { Err(e) => {
log::error!("Failed to delete event from Redis: {}", e); log::error!("Failed to delete event from database: {}", e);
Ok(HttpResponse::InternalServerError().body("Failed to delete event")) Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
} }
} }
} }
/// Returns the last day of the month /// Returns the last day of the month
fn last_day_of_month(year: i32, month: u32) -> u32 { fn last_day_of_month(year: i32, month: u32) -> u32 {
match month { match month {
@ -326,11 +504,11 @@ impl CalendarController {
} else { } else {
28 28
} }
}, }
_ => 30, // Default to 30 days _ => 30, // Default to 30 days
} }
} }
/// Returns the name of the month /// Returns the name of the month
fn month_name(month: u32) -> &'static str { fn month_name(month: u32) -> &'static str {
match month { match month {
@ -349,7 +527,7 @@ impl CalendarController {
_ => "", _ => "",
} }
} }
/// Returns the name of the day /// Returns the name of the day
fn day_name(day: u32) -> &'static str { fn day_name(day: u32) -> &'static str {
match day { match day {
@ -387,7 +565,7 @@ pub struct EventForm {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct CalendarDay { struct CalendarDay {
day: u32, day: u32,
events: Vec<CalendarEvent>, events: Vec<Event>,
is_current_month: bool, is_current_month: bool,
} }
@ -396,5 +574,5 @@ struct CalendarDay {
struct CalendarMonth { struct CalendarMonth {
month: u32, month: u32,
name: String, name: String,
events: Vec<CalendarEvent>, events: Vec<Event>,
} }

View File

@ -1,12 +1,20 @@
use actix_web::{web, HttpResponse, Responder, Result}; use crate::config::get_config;
use actix_web::HttpRequest; use crate::controllers::error::render_company_not_found;
use tera::{Context, Tera}; use crate::db::company::*;
use serde::Deserialize; use crate::db::document::*;
use chrono::Utc; use crate::models::document::DocumentType;
use crate::utils::render_template; 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 // Form structs for company operations
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct CompanyRegistrationForm { pub struct CompanyRegistrationForm {
pub company_name: String, pub company_name: String,
pub company_type: String, pub company_type: String,
@ -14,232 +22,650 @@ pub struct CompanyRegistrationForm {
pub company_purpose: Option<String>, pub company_purpose: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct CompanyEditForm {
pub company_name: String,
pub company_type: String,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
pub address: Option<String>,
pub industry: Option<String>,
pub description: Option<String>,
pub fiscal_year_end: Option<String>,
pub status: String,
}
pub struct CompanyController; pub struct CompanyController;
impl CompanyController { impl CompanyController {
// Display the company management dashboard // Display the company management dashboard
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let config = get_config();
println!("DEBUG: Starting Company dashboard rendering");
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"company"); context.insert("active_page", &"company");
// Add Stripe configuration for payment processing
context.insert("stripe_publishable_key", &config.stripe.publishable_key);
// Load companies from database
let companies = match get_companies() {
Ok(companies) => companies,
Err(e) => {
log::error!("Failed to get companies from database: {}", e);
vec![]
}
};
context.insert("companies", &companies);
// Parse query parameters // Parse query parameters
let query_string = req.query_string(); let query_string = req.query_string();
// Check for success message // Check for success message
if let Some(pos) = query_string.find("success=") { if let Some(pos) = query_string.find("success=") {
let start = pos + 8; // length of "success=" let start = pos + 8; // length of "success="
let end = query_string[start..].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 success = &query_string[start..end];
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into()); let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("success", &decoded); context.insert("success", &decoded);
} }
// Check for entity context // Check for entity context
if let Some(pos) = query_string.find("entity=") { if let Some(pos) = query_string.find("entity=") {
let start = pos + 7; // length of "entity=" let start = pos + 7; // length of "entity="
let end = query_string[start..].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]; let entity = &query_string[start..end];
context.insert("entity", &entity); context.insert("entity", &entity);
// Also get entity name if present // Also get entity name if present
if let Some(pos) = query_string.find("entity_name=") { if let Some(pos) = query_string.find("entity_name=") {
let start = pos + 12; // length of "entity_name=" let start = pos + 12; // length of "entity_name="
let end = query_string[start..].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 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); context.insert("entity_name", &decoded_name);
println!("DEBUG: Entity context set to {} ({})", entity, decoded_name);
} }
} }
println!("DEBUG: Rendering Company dashboard template"); render_template(&tmpl, "company/index.html", &context)
let response = render_template(&tmpl, "company/index.html", &context);
println!("DEBUG: Finished rendering Company dashboard template");
response
} }
// View company details // Display company edit form
pub async fn view_company(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> { pub async fn edit_form(
let company_id = path.into_inner(); tmpl: web::Data<Tera>,
path: web::Path<String>,
req: HttpRequest,
) -> Result<HttpResponse> {
let company_id_str = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
println!("DEBUG: Viewing company details for {}", company_id);
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"company"); context.insert("active_page", &"company");
context.insert("company_id", &company_id);
// Parse query parameters for success/error messages
// In a real application, we would fetch company data from a database let query_string = req.query_string();
// For now, we'll use mock data based on the company_id
match company_id.as_str() { // Check for success message
"company1" => { if let Some(pos) = query_string.find("success=") {
context.insert("company_name", &"Zanzibar Digital Solutions"); let start = pos + 8; // length of "success="
context.insert("company_type", &"Startup FZC"); let end = query_string[start..]
context.insert("status", &"Active"); .find('&')
context.insert("registration_date", &"2025-04-01"); .map_or(query_string.len(), |e| e + start);
context.insert("purpose", &"Digital solutions and blockchain development"); let success = &query_string[start..end];
context.insert("plan", &"Startup FZC - $50/month"); let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("next_billing", &"2025-06-01"); context.insert("success", &decoded);
context.insert("payment_method", &"Credit Card (****4582)"); }
// Shareholders data // Check for error message
let shareholders = vec![ if let Some(pos) = query_string.find("error=") {
("John Smith", "60%"), let start = pos + 6; // length of "error="
("Sarah Johnson", "40%"), let end = query_string[start..]
]; .find('&')
context.insert("shareholders", &shareholders); .map_or(query_string.len(), |e| e + start);
let error = &query_string[start..end];
// Contracts data let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
let contracts = vec![ context.insert("error", &decoded);
("Articles of Incorporation", "Signed"), }
("Terms & Conditions", "Signed"),
("Digital Asset Issuance", "Signed"), // Parse company ID
]; let company_id = match company_id_str.parse::<u32>() {
context.insert("contracts", &contracts); Ok(id) => id,
}, Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
"company2" => { };
context.insert("company_name", &"Blockchain Innovations Ltd");
context.insert("company_type", &"Growth FZC"); // Fetch company from database
context.insert("status", &"Active"); if let Ok(Some(company)) = get_company_by_id(company_id) {
context.insert("registration_date", &"2025-03-15"); context.insert("company", &company);
context.insert("purpose", &"Blockchain technology research and development");
context.insert("plan", &"Growth FZC - $100/month"); // Format timestamps for display
context.insert("next_billing", &"2025-06-15"); let incorporation_date =
context.insert("payment_method", &"Bank Transfer"); chrono::DateTime::from_timestamp(company.incorporation_date, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
// Shareholders data .unwrap_or_else(|| "Unknown".to_string());
let shareholders = vec![ context.insert("incorporation_date_formatted", &incorporation_date);
("Michael Chen", "35%"),
("Aisha Patel", "35%"), render_template(&tmpl, "company/edit.html", &context)
("David Okonkwo", "30%"), } else {
]; render_company_not_found(&tmpl, Some(&company_id_str)).await
context.insert("shareholders", &shareholders); }
}
// Contracts data
let contracts = vec![ // View company details
("Articles of Incorporation", "Signed"), pub async fn view_company(
("Terms & Conditions", "Signed"), tmpl: web::Data<Tera>,
("Digital Asset Issuance", "Signed"), path: web::Path<String>,
("Physical Asset Holding", "Signed"), req: HttpRequest,
]; ) -> Result<HttpResponse> {
context.insert("contracts", &contracts); let company_id_str = path.into_inner();
}, let mut context = Context::new();
"company3" => {
context.insert("company_name", &"Sustainable Energy Cooperative"); // Add active_page for navigation highlighting
context.insert("company_type", &"Cooperative FZC"); context.insert("active_page", &"company");
context.insert("status", &"Pending"); context.insert("company_id", &company_id_str);
context.insert("registration_date", &"2025-05-01");
context.insert("purpose", &"Renewable energy production and distribution"); // Parse query parameters for success/error messages
context.insert("plan", &"Cooperative FZC - $200/month"); let query_string = req.query_string();
context.insert("next_billing", &"Pending Activation");
context.insert("payment_method", &"Pending"); // Check for success message
if let Some(pos) = query_string.find("success=") {
// Shareholders data let start = pos + 8; // length of "success="
let shareholders = vec![ let end = query_string[start..]
("Community Energy Group", "40%"), .find('&')
("Green Future Initiative", "30%"), .map_or(query_string.len(), |e| e + start);
("Sustainable Living Collective", "30%"), let success = &query_string[start..end];
]; let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("shareholders", &shareholders); context.insert("success", &decoded);
}
// Contracts data
let contracts = vec![ // Check for error message
("Articles of Incorporation", "Signed"), if let Some(pos) = query_string.find("error=") {
("Terms & Conditions", "Signed"), let start = pos + 6; // length of "error="
("Cooperative Governance", "Pending"), let end = query_string[start..]
]; .find('&')
context.insert("contracts", &contracts); .map_or(query_string.len(), |e| e + start);
}, let error = &query_string[start..end];
_ => { let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
// If company_id is not recognized, redirect to company index 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_str = path.into_inner();
// Parse company ID
let company_id = match company_id_str.parse::<u32>() {
Ok(id) => id,
Err(_) => {
return Ok(HttpResponse::Found() return Ok(HttpResponse::Found()
.append_header(("Location", "/company")) .append_header(("Location", "/company"))
.finish()); .finish());
} }
}
println!("DEBUG: Rendering company view template");
let response = render_template(&tmpl, "company/view.html", &context);
println!("DEBUG: Finished rendering company view template");
response
}
// Switch to entity context
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
let company_id = path.into_inner();
println!("DEBUG: Switching to entity context for {}", company_id);
// 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 // In a real application, we would set a session/cookie for the current entity
// Here we'll redirect back to the company page with a success message and entity parameter // Here we'll redirect back to the company page with a success message and entity parameter
let success_message = format!("Switched to {} entity context", company_name); let success_message = format!("Switched to {} entity context", company_name);
let encoded_message = urlencoding::encode(&success_message); let encoded_message = urlencoding::encode(&success_message);
Ok(HttpResponse::Found() Ok(HttpResponse::Found()
.append_header(("Location", format!("/company?success={}&entity={}&entity_name={}", .append_header((
encoded_message, company_id, urlencoding::encode(company_name)))) "Location",
format!(
"/company?success={}&entity={}&entity_name={}",
encoded_message,
company_id_str,
urlencoding::encode(&company_name)
),
))
.finish()) .finish())
} }
// Process company registration // Deprecated registration method removed - now handled via payment flow
pub async fn register(
mut form: actix_multipart::Multipart, // Legacy registration method (kept for reference but not used)
) -> Result<HttpResponse> { #[allow(dead_code)]
use actix_web::{http::header}; 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 futures_util::stream::StreamExt as _;
use std::collections::HashMap; use std::collections::HashMap;
println!("DEBUG: Processing company registration request");
let mut fields: HashMap<String, String> = HashMap::new(); let mut fields: HashMap<String, String> = HashMap::new();
let mut files = Vec::new(); let mut uploaded_files = Vec::new();
// Parse multipart form // Parse multipart form
while let Some(Ok(mut field)) = form.next().await { while let Some(Ok(mut field)) = form.next().await {
let mut value = Vec::new(); let content_disposition = field.content_disposition();
while let Some(chunk) = field.next().await { let field_name = content_disposition
let data = chunk.unwrap(); .get_name()
value.extend_from_slice(&data); .unwrap_or("unknown")
} .to_string();
let filename = content_disposition.get_filename().map(|f| f.to_string());
// Get field name from content disposition
let cd = field.content_disposition(); if field_name.starts_with("contract-") || field_name.ends_with("-doc") {
if let Some(name) = cd.get_name() { // Handle file upload
if name == "company_docs" { if let Some(filename) = filename {
files.push(value); // Just collect files in memory for now let mut file_data = Vec::new();
} else { while let Some(chunk) = field.next().await {
fields.insert(name.to_string(), String::from_utf8_lossy(&value).to_string()); 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);
}
fields.insert(field_name, String::from_utf8_lossy(&value).to_string());
} }
} }
// Extract company details // Extract company details
let company_name = fields.get("company_name").cloned().unwrap_or_default(); let company_name = fields.get("company_name").cloned().unwrap_or_default();
let company_type = fields.get("company_type").cloned().unwrap_or_default(); let company_type_str = fields.get("company_type").cloned().unwrap_or_default();
let shareholders = fields.get("shareholders").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: {}", // Extract new contact fields
company_name, company_type, shareholders, files.len()); let company_email = fields.get("company_email").cloned().unwrap_or_default();
let company_phone = fields.get("company_phone").cloned().unwrap_or_default();
// Create success message let company_website = fields.get("company_website").cloned().unwrap_or_default();
let success_message = format!("Successfully registered {} as a {}", company_name, company_type); let company_address = fields.get("company_address").cloned().unwrap_or_default();
let company_industry = fields.get("company_industry").cloned().unwrap_or_default();
// Redirect back to /company with success message let fiscal_year_end = fields.get("fiscal_year_end").cloned().unwrap_or_default();
Ok(HttpResponse::SeeOther()
.append_header((header::LOCATION, format!("/company?success={}", urlencoding::encode(&success_message)))) // Validate required fields
.finish()) 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
);
Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!("/company?success={}", urlencoding::encode(&success_message)),
))
.finish())
}
Err(e) => {
log::error!("Failed to create company: {}", e);
Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
"/company?error=Failed to register company",
))
.finish())
}
}
}
// Process company edit form
pub async fn edit(
tmpl: web::Data<Tera>,
path: web::Path<String>,
form: web::Form<CompanyEditForm>,
) -> Result<HttpResponse> {
use actix_web::http::header;
let company_id_str = path.into_inner();
// Parse company ID
let company_id = match company_id_str.parse::<u32>() {
Ok(id) => id,
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
};
// Validate required fields
if form.company_name.trim().is_empty() {
return Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/company/edit/{}?error=Company name is required",
company_id
),
))
.finish());
}
// Parse business type
let business_type = match form.company_type.as_str() {
"Startup FZC" => BusinessType::Starter,
"Growth FZC" => BusinessType::Global,
"Cooperative FZC" => BusinessType::Coop,
"Single FZC" => BusinessType::Single,
"Twin FZC" => BusinessType::Twin,
_ => BusinessType::Single, // Default
};
// Parse status
let status = match form.status.as_str() {
"Active" => CompanyStatus::Active,
"Inactive" => CompanyStatus::Inactive,
"Suspended" => CompanyStatus::Suspended,
_ => CompanyStatus::Active, // Default
};
// Update company in database
match update_company(
company_id,
Some(form.company_name.clone()),
form.email.clone(),
form.phone.clone(),
form.website.clone(),
form.address.clone(),
form.industry.clone(),
form.description.clone(),
form.fiscal_year_end.clone(),
Some(status),
Some(business_type),
) {
Ok(_) => {
let success_message = format!("Successfully updated {}", form.company_name);
Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/company/view/{}?success={}",
company_id,
urlencoding::encode(&success_message)
),
))
.finish())
}
Err(e) => {
log::error!("Failed to update company {}: {}", company_id, e);
Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/company/edit/{}?error=Failed to update company",
company_id
),
))
.finish())
}
}
}
/// Debug endpoint to clean up corrupted database (emergency use only)
pub async fn cleanup_database() -> Result<HttpResponse> {
match crate::db::company::cleanup_corrupted_database() {
Ok(message) => {
log::info!("Database cleanup successful: {}", message);
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"message": message
})))
}
Err(error) => {
log::error!("Database cleanup failed: {}", error);
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": error
})))
}
}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,15 @@
use actix_web::{web, HttpResponse, Result};
use actix_web::HttpRequest; use actix_web::HttpRequest;
use tera::{Context, Tera}; use actix_web::{HttpResponse, Result, web};
use chrono::{Utc, Duration}; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use tera::{Context, Tera};
use uuid::Uuid; use uuid::Uuid;
use crate::models::asset::{Asset, AssetType, AssetStatus}; use crate::models::asset::Asset;
use crate::models::defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB}; use crate::models::defi::{
DEFI_DB, DefiPosition, DefiPositionStatus, DefiPositionType, ProvidingPosition,
ReceivingPosition,
};
use crate::utils::render_template; use crate::utils::render_template;
// Form structs for DeFi operations // Form structs for DeFi operations
@ -26,6 +29,7 @@ pub struct ReceivingForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LiquidityForm { pub struct LiquidityForm {
pub first_token: String, pub first_token: String,
pub first_amount: f64, pub first_amount: f64,
@ -35,6 +39,7 @@ pub struct LiquidityForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct StakingForm { pub struct StakingForm {
pub asset_id: String, pub asset_id: String,
pub amount: f64, pub amount: f64,
@ -49,6 +54,7 @@ pub struct SwapForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct CollateralForm { pub struct CollateralForm {
pub asset_id: String, pub asset_id: String,
pub amount: f64, pub amount: f64,
@ -63,29 +69,29 @@ impl DefiController {
// Display the DeFi dashboard // Display the DeFi dashboard
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
println!("DEBUG: Starting DeFi dashboard rendering"); println!("DEBUG: Starting DeFi dashboard rendering");
// Get mock assets for the dropdown selectors // Get mock assets for the dropdown selectors
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
println!("DEBUG: Generated {} mock assets", assets.len()); println!("DEBUG: Generated {} mock assets", assets.len());
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"defi"); context.insert("active_page", &"defi");
// Add DeFi stats // Add DeFi stats
let defi_stats = Self::get_defi_stats(); let defi_stats = Self::get_defi_stats();
context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap()); context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap());
// Add recent assets for selection in forms // Add recent assets for selection in forms
let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets
.iter() .iter()
.take(5) .take(5)
.map(|a| Self::asset_to_json(a)) .map(|a| Self::asset_to_json(a))
.collect(); .collect();
context.insert("recent_assets", &recent_assets); context.insert("recent_assets", &recent_assets);
// Get user's providing positions // Get user's providing positions
let db = DEFI_DB.lock().unwrap(); let db = DEFI_DB.lock().unwrap();
let providing_positions = db.get_user_providing_positions("user123"); let providing_positions = db.get_user_providing_positions("user123");
@ -94,7 +100,7 @@ impl DefiController {
.map(|p| serde_json::to_value(p).unwrap()) .map(|p| serde_json::to_value(p).unwrap())
.collect(); .collect();
context.insert("providing_positions", &providing_positions_json); context.insert("providing_positions", &providing_positions_json);
// Get user's receiving positions // Get user's receiving positions
let receiving_positions = db.get_user_receiving_positions("user123"); let receiving_positions = db.get_user_receiving_positions("user123");
let receiving_positions_json: Vec<serde_json::Value> = receiving_positions let receiving_positions_json: Vec<serde_json::Value> = receiving_positions
@ -102,27 +108,30 @@ impl DefiController {
.map(|p| serde_json::to_value(p).unwrap()) .map(|p| serde_json::to_value(p).unwrap())
.collect(); .collect();
context.insert("receiving_positions", &receiving_positions_json); context.insert("receiving_positions", &receiving_positions_json);
// Add success message if present in query params // Add success message if present in query params
if let Some(success) = req.query_string().strip_prefix("success=") { if let Some(success) = req.query_string().strip_prefix("success=") {
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into()); let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("success_message", &decoded); context.insert("success_message", &decoded);
} }
println!("DEBUG: Rendering DeFi dashboard template"); println!("DEBUG: Rendering DeFi dashboard template");
let response = render_template(&tmpl, "defi/index.html", &context); let response = render_template(&tmpl, "defi/index.html", &context);
println!("DEBUG: Finished rendering DeFi dashboard template"); println!("DEBUG: Finished rendering DeFi dashboard template");
response response
} }
// Process providing request // Process providing request
pub async fn create_providing(_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); println!("DEBUG: Processing providing request: {:?}", form);
// Get the asset obligationails (in a real app, this would come from a database) // Get the asset obligationails (in a real app, this would come from a database)
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
let asset = assets.iter().find(|a| a.id == form.asset_id); let asset = assets.iter().find(|a| a.id == form.asset_id);
if let Some(asset) = asset { if let Some(asset) = asset {
// Calculate profit share and return amount // Calculate profit share and return amount
let profit_share = match form.duration { let profit_share = match form.duration {
@ -133,9 +142,10 @@ impl DefiController {
365 => 12.0, 365 => 12.0,
_ => 4.2, // Default to 30 days rate _ => 4.2, // Default to 30 days rate
}; };
let return_amount = form.amount + (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 // Create a new providing position
let providing_position = ProvidingPosition { let providing_position = ProvidingPosition {
base: DefiPosition { base: DefiPosition {
@ -156,17 +166,23 @@ impl DefiController {
profit_share_earned: profit_share, profit_share_earned: profit_share,
return_amount, return_amount,
}; };
// Add the position to the database // Add the position to the database
{ {
let mut db = DEFI_DB.lock().unwrap(); let mut db = DEFI_DB.lock().unwrap();
db.add_providing_position(providing_position); db.add_providing_position(providing_position);
} }
// Redirect with success message // Redirect with success message
let success_message = format!("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() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} else { } else {
// Asset not found, redirect with error // Asset not found, redirect with error
@ -175,15 +191,18 @@ impl DefiController {
.finish()) .finish())
} }
} }
// Process receiving request // 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); println!("DEBUG: Processing receiving request: {:?}", form);
// Get the asset obligationails (in a real app, this would come from a database) // Get the asset obligationails (in a real app, this would come from a database)
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id); let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id);
if let Some(collateral_asset) = collateral_asset { if let Some(collateral_asset) = collateral_asset {
// Calculate profit share rate based on duration // Calculate profit share rate based on duration
let profit_share_rate = match form.duration { let profit_share_rate = match form.duration {
@ -194,15 +213,17 @@ impl DefiController {
365 => 10.0, 365 => 10.0,
_ => 5.0, // Default to 30 days rate _ => 5.0, // Default to 30 days rate
}; };
// Calculate profit share and total to repay // Calculate profit share and total to repay
let profit_share = 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; let total_to_repay = form.amount + profit_share;
// Calculate collateral value and ratio // 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; let collateral_ratio = (collateral_value / form.amount) * 100.0;
// Create a new receiving position // Create a new receiving position
let receiving_position = ReceivingPosition { let receiving_position = ReceivingPosition {
base: DefiPosition { base: DefiPosition {
@ -230,18 +251,23 @@ impl DefiController {
total_to_repay, total_to_repay,
collateral_ratio, collateral_ratio,
}; };
// Add the position to the database // Add the position to the database
{ {
let mut db = DEFI_DB.lock().unwrap(); let mut db = DEFI_DB.lock().unwrap();
db.add_receiving_position(receiving_position); db.add_receiving_position(receiving_position);
} }
// Redirect with success message // Redirect with success message
let success_message = format!("Successfully borrowed {} ZDFZ using {} {} as collateral", let success_message = format!(
form.amount, form.collateral_amount, collateral_asset.name); "Successfully borrowed {} ZDFZ using {} {} as collateral",
form.amount, form.collateral_amount, collateral_asset.name
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} else { } else {
// Asset not found, redirect with error // Asset not found, redirect with error
@ -250,116 +276,202 @@ impl DefiController {
.finish()) .finish())
} }
} }
// Process liquidity provision // 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); println!("DEBUG: Processing liquidity provision: {:?}", form);
// In a real application, this would add liquidity to a pool in the database // In a real application, this would add liquidity to a pool in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully added liquidity: {} {} and {} {}", let success_message = format!(
form.first_amount, form.first_token, form.second_amount, form.second_token); "Successfully added liquidity: {} {} and {} {}",
form.first_amount, form.first_token, form.second_amount, form.second_token
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process staking request // 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); println!("DEBUG: Processing staking request: {:?}", form);
// In a real application, this would create a staking position in the database // In a real application, this would create a staking position in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id); let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process token swap // 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); println!("DEBUG: Processing token swap: {:?}", form);
// In a real application, this would perform a token swap in the database // In a real application, this would perform a token swap in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully swapped {} {} to {}", let success_message = format!(
form.from_amount, form.from_token, form.to_token); "Successfully swapped {} {} to {}",
form.from_amount, form.from_token, form.to_token
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process collateral position creation // 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); println!("DEBUG: Processing collateral creation: {:?}", form);
// In a real application, this would create a collateral position in the database // In a real application, this would create a collateral position in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let purpose_str = match form.purpose.as_str() { let purpose_str = match form.purpose.as_str() {
"funds" => "secure a funds", "funds" => "secure a funds",
"synthetic" => "generate synthetic assets", "synthetic" => "generate synthetic assets",
"leverage" => "leverage trading", "leverage" => "leverage trading",
_ => "collateralization", _ => "collateralization",
}; };
let success_message = format!("Successfully collateralized {} {} for {}", let success_message = format!(
form.amount, form.asset_id, purpose_str); "Successfully collateralized {} {} for {}",
form.amount, form.asset_id, purpose_str
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Helper method to get DeFi statistics // Helper method to get DeFi statistics
fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> { fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> {
let mut stats = serde_json::Map::new(); let mut stats = serde_json::Map::new();
// Handle Option<Number> by unwrapping with expect // Handle Option<Number> by unwrapping with expect
stats.insert("total_value_locked".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(1250000.0).expect("Valid float"))); stats.insert(
stats.insert("providing_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float"))); "total_value_locked".to_string(),
stats.insert("receiving_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float"))); serde_json::Value::Number(
stats.insert("liquidity_pools_count".to_string(), serde_json::Value::Number(serde_json::Number::from(12))); serde_json::Number::from_f64(1250000.0).expect("Valid float"),
stats.insert("active_stakers".to_string(), serde_json::Value::Number(serde_json::Number::from(156))); ),
stats.insert("total_swap_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float"))); );
stats.insert(
"providing_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float")),
);
stats.insert(
"receiving_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float")),
);
stats.insert(
"liquidity_pools_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(12)),
);
stats.insert(
"active_stakers".to_string(),
serde_json::Value::Number(serde_json::Number::from(156)),
);
stats.insert(
"total_swap_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float")),
);
stats stats
} }
// Helper method to convert Asset to a JSON object for templates // Helper method to convert Asset to a JSON object for templates
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> { fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
map.insert("id".to_string(), serde_json::Value::String(asset.id.clone())); map.insert(
map.insert("name".to_string(), serde_json::Value::String(asset.name.clone())); "id".to_string(),
map.insert("description".to_string(), serde_json::Value::String(asset.description.clone())); serde_json::Value::String(asset.id.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(
"name".to_string(),
serde_json::Value::String(asset.name.clone()),
);
map.insert(
"description".to_string(),
serde_json::Value::String(asset.description.clone()),
);
map.insert(
"asset_type".to_string(),
serde_json::Value::String(asset.asset_type.as_str().to_string()),
);
map.insert(
"status".to_string(),
serde_json::Value::String(asset.status.as_str().to_string()),
);
// Add current valuation // Add current valuation
if let Some(latest) = asset.latest_valuation() { if let Some(latest) = asset.latest_valuation() {
if let Some(num) = serde_json::Number::from_f64(latest.value) { if let Some(num) = serde_json::Number::from_f64(latest.value) {
map.insert("current_valuation".to_string(), serde_json::Value::Number(num)); map.insert(
"current_valuation".to_string(),
serde_json::Value::Number(num),
);
} else { } 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(
map.insert("valuation_date".to_string(), serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string())); "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 { } else {
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0))); map.insert(
map.insert("valuation_currency".to_string(), serde_json::Value::String("USD".to_string())); "current_valuation".to_string(),
map.insert("valuation_date".to_string(), serde_json::Value::String("N/A".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 map
} }
// Generate mock assets for testing // Generate mock assets for testing
fn get_mock_assets() -> Vec<Asset> { fn get_mock_assets() -> Vec<Asset> {
// Reuse the asset controller's mock data function // Reuse the asset controller's mock data function

View 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())
}
}
}
}

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

View File

@ -609,6 +609,7 @@ impl FlowController {
/// Form for creating a new flow /// Form for creating a new flow
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct FlowForm { pub struct FlowForm {
/// Flow name /// Flow name
pub name: String, pub name: String,
@ -620,6 +621,7 @@ pub struct FlowForm {
/// Form for marking a step as stuck /// Form for marking a step as stuck
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct StuckForm { pub struct StuckForm {
/// Reason for being stuck /// Reason for being stuck
pub reason: String, pub reason: String,
@ -627,6 +629,7 @@ pub struct StuckForm {
/// Form for adding a log to a step /// Form for adding a log to a step
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LogForm { pub struct LogForm {
/// Log message /// Log message
pub message: String, pub message: String,

File diff suppressed because it is too large Load Diff

View 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)),
);
}

View File

@ -96,6 +96,7 @@ impl HomeController {
/// Represents the data submitted in the contact form /// Represents the data submitted in the contact form
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
pub struct ContactForm { pub struct ContactForm {
pub name: String, pub name: String,
pub email: String, pub email: String,

View File

@ -1,12 +1,11 @@
use actix_web::{web, HttpResponse, Result, http}; use actix_web::{HttpResponse, Result, http, web};
use tera::{Context, Tera}; use chrono::{Duration, Utc};
use chrono::{Utc, Duration};
use serde::Deserialize; 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::controllers::asset::AssetController;
use crate::models::asset::{Asset, AssetStatus, AssetType};
use crate::models::marketplace::{Listing, ListingStatus, ListingType, MarketplaceStatistics};
use crate::utils::render_template; use crate::utils::render_template;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -22,6 +21,7 @@ pub struct ListingForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct BidForm { pub struct BidForm {
pub amount: f64, pub amount: f64,
pub currency: String, pub currency: String,
@ -38,30 +38,33 @@ impl MarketplaceController {
// Display the marketplace dashboard // Display the marketplace dashboard
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
let stats = MarketplaceStatistics::new(&listings); let stats = MarketplaceStatistics::new(&listings);
// Get featured listings (up to 4) // Get featured listings (up to 4)
let featured_listings: Vec<&Listing> = listings.iter() let featured_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.featured && l.status == ListingStatus::Active) .filter(|l| l.featured && l.status == ListingStatus::Active)
.take(4) .take(4)
.collect(); .collect();
// Get recent listings (up to 8) // Get recent listings (up to 8)
let mut recent_listings: Vec<&Listing> = listings.iter() let mut recent_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.collect(); .collect();
// Sort by created_at (newest first) // Sort by created_at (newest first)
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at)); recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>(); let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
// Get recent sales (up to 5) // Get recent sales (up to 5)
let mut recent_sales: Vec<&Listing> = listings.iter() let mut recent_sales: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Sold) .filter(|l| l.status == ListingStatus::Sold)
.collect(); .collect();
// Sort by sold_at (newest first) // Sort by sold_at (newest first)
recent_sales.sort_by(|a, b| { recent_sales.sort_by(|a, b| {
let a_sold = a.sold_at.unwrap_or(a.created_at); let a_sold = a.sold_at.unwrap_or(a.created_at);
@ -69,88 +72,101 @@ impl MarketplaceController {
b_sold.cmp(&a_sold) b_sold.cmp(&a_sold)
}); });
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>(); let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
// Add data to context // Add data to context
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("stats", &stats); context.insert("stats", &stats);
context.insert("featured_listings", &featured_listings); context.insert("featured_listings", &featured_listings);
context.insert("recent_listings", &recent_listings); context.insert("recent_listings", &recent_listings);
context.insert("recent_sales", &recent_sales); context.insert("recent_sales", &recent_sales);
render_template(&tmpl, "marketplace/index.html", &context) render_template(&tmpl, "marketplace/index.html", &context)
} }
// Display all marketplace listings // Display all marketplace listings
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Filter active listings // Filter active listings
let active_listings: Vec<&Listing> = listings.iter() let active_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.collect(); .collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listings", &active_listings); context.insert("listings", &active_listings);
context.insert("listing_types", &[ context.insert(
ListingType::FixedPrice.as_str(), "listing_types",
ListingType::Auction.as_str(), &[
ListingType::Exchange.as_str(), ListingType::FixedPrice.as_str(),
]); ListingType::Auction.as_str(),
context.insert("asset_types", &[ ListingType::Exchange.as_str(),
AssetType::Token.as_str(), ],
AssetType::Artwork.as_str(), );
AssetType::RealEstate.as_str(), context.insert(
AssetType::IntellectualProperty.as_str(), "asset_types",
AssetType::Commodity.as_str(), &[
AssetType::Share.as_str(), AssetType::Token.as_str(),
AssetType::Bond.as_str(), AssetType::Artwork.as_str(),
AssetType::Other.as_str(), AssetType::RealEstate.as_str(),
]); AssetType::IntellectualProperty.as_str(),
AssetType::Commodity.as_str(),
AssetType::Share.as_str(),
AssetType::Bond.as_str(),
AssetType::Other.as_str(),
],
);
render_template(&tmpl, "marketplace/listings.html", &context) render_template(&tmpl, "marketplace/listings.html", &context)
} }
// Display my listings // Display my listings
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Filter by current user (mock user ID) // Filter by current user (mock user ID)
let user_id = "user-123"; let user_id = "user-123";
let my_listings: Vec<&Listing> = listings.iter() let my_listings: Vec<&Listing> =
.filter(|l| l.seller_id == user_id) listings.iter().filter(|l| l.seller_id == user_id).collect();
.collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listings", &my_listings); context.insert("listings", &my_listings);
render_template(&tmpl, "marketplace/my_listings.html", &context) render_template(&tmpl, "marketplace/my_listings.html", &context)
} }
// Display listing details // Display listing details
pub async fn listing_detail(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 listing_id = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Find the listing // Find the listing
let listing = listings.iter().find(|l| l.id == listing_id); let listing = listings.iter().find(|l| l.id == listing_id);
if let Some(listing) = listing { if let Some(listing) = listing {
// Get similar listings (same asset type, active) // Get similar listings (same asset type, active)
let similar_listings: Vec<&Listing> = listings.iter() let similar_listings: Vec<&Listing> = listings
.filter(|l| l.asset_type == listing.asset_type && .iter()
l.status == ListingStatus::Active && .filter(|l| {
l.id != listing.id) l.asset_type == listing.asset_type
&& l.status == ListingStatus::Active
&& l.id != listing.id
})
.take(4) .take(4)
.collect(); .collect();
// Get highest bid amount and minimum bid for auction listings // Get highest bid amount and minimum bid for auction listings
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction { let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction
{
if let Some(bid) = listing.highest_bid() { if let Some(bid) = listing.highest_bid() {
(Some(bid.amount), bid.amount + 1.0) (Some(bid.amount), bid.amount + 1.0)
} else { } else {
@ -159,74 +175,79 @@ impl MarketplaceController {
} else { } else {
(None, 0.0) (None, 0.0)
}; };
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listing", listing); context.insert("listing", listing);
context.insert("similar_listings", &similar_listings); context.insert("similar_listings", &similar_listings);
context.insert("highest_bid_amount", &highest_bid_amount); context.insert("highest_bid_amount", &highest_bid_amount);
context.insert("minimum_bid", &minimum_bid); context.insert("minimum_bid", &minimum_bid);
// Add current user info for bid/purchase forms // Add current user info for bid/purchase forms
let user_id = "user-123"; let user_id = "user-123";
let user_name = "Alice Hostly"; let user_name = "Alice Hostly";
context.insert("user_id", &user_id); context.insert("user_id", &user_id);
context.insert("user_name", &user_name); context.insert("user_name", &user_name);
render_template(&tmpl, "marketplace/listing_detail.html", &context) render_template(&tmpl, "marketplace/listing_detail.html", &context)
} else { } else {
Ok(HttpResponse::NotFound().finish()) Ok(HttpResponse::NotFound().finish())
} }
} }
// Display create listing form // Display create listing form
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
// Get user's assets for selection // Get user's assets for selection
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let user_id = "user-123"; // Mock user ID let user_id = "user-123"; // Mock user ID
let user_assets: Vec<&Asset> = assets.iter() let user_assets: Vec<&Asset> = assets
.iter()
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active) .filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
.collect(); .collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("assets", &user_assets); context.insert("assets", &user_assets);
context.insert("listing_types", &[ context.insert(
ListingType::FixedPrice.as_str(), "listing_types",
ListingType::Auction.as_str(), &[
ListingType::Exchange.as_str(), ListingType::FixedPrice.as_str(),
]); ListingType::Auction.as_str(),
ListingType::Exchange.as_str(),
],
);
render_template(&tmpl, "marketplace/create_listing.html", &context) render_template(&tmpl, "marketplace/create_listing.html", &context)
} }
// Create a new listing // Create a new listing
pub async fn create_listing( pub async fn create_listing(
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
form: web::Form<ListingForm>, form: web::Form<ListingForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let form = form.into_inner(); let form = form.into_inner();
// Get the asset details // Get the asset details
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let asset = assets.iter().find(|a| a.id == form.asset_id); let asset = assets.iter().find(|a| a.id == form.asset_id);
if let Some(asset) = asset { if let Some(asset) = asset {
// Process tags // Process tags
let tags = match form.tags { let tags = match form.tags {
Some(tags_str) => tags_str.split(',') Some(tags_str) => tags_str
.split(',')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(), .collect(),
None => Vec::new(), None => Vec::new(),
}; };
// Calculate expiration date if provided // Calculate expiration date if provided
let expires_at = form.duration_days.map(|days| { let expires_at = form
Utc::now() + Duration::days(days as i64) .duration_days
}); .map(|days| Utc::now() + Duration::days(days as i64));
// Parse listing type // Parse listing type
let listing_type = match form.listing_type.as_str() { let listing_type = match form.listing_type.as_str() {
"Fixed Price" => ListingType::FixedPrice, "Fixed Price" => ListingType::FixedPrice,
@ -234,11 +255,11 @@ impl MarketplaceController {
"Exchange" => ListingType::Exchange, "Exchange" => ListingType::Exchange,
_ => ListingType::FixedPrice, _ => ListingType::FixedPrice,
}; };
// Mock user data // Mock user data
let user_id = "user-123"; let user_id = "user-123";
let user_name = "Alice Hostly"; let user_name = "Alice Hostly";
// Create the listing // Create the listing
let _listing = Listing::new( let _listing = Listing::new(
form.title, form.title,
@ -255,9 +276,9 @@ impl MarketplaceController {
tags, tags,
asset.image_url.clone(), asset.image_url.clone(),
); );
// In a real application, we would save the listing to a database here // In a real application, we would save the listing to a database here
// Redirect to the marketplace // Redirect to the marketplace
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace")) .insert_header((http::header::LOCATION, "/marketplace"))
@ -267,94 +288,101 @@ impl MarketplaceController {
let mut context = Context::new(); let mut context = Context::new();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("error", &"Asset not found"); context.insert("error", &"Asset not found");
render_template(&tmpl, "marketplace/create_listing.html", &context) render_template(&tmpl, "marketplace/create_listing.html", &context)
} }
} }
// Submit a bid on an auction listing // Submit a bid on an auction listing
#[allow(dead_code)]
pub async fn submit_bid( pub async fn submit_bid(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
form: web::Form<BidForm>, _form: web::Form<BidForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let listing_id = path.into_inner(); let listing_id = path.into_inner();
let form = form.into_inner(); let _form = _form.into_inner();
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate the bid // 2. Validate the bid
// 3. Create the bid // 3. Create the bid
// 4. Save it to the database // 4. Save it to the database
// For now, we'll just redirect back to the listing // For now, we'll just redirect back to the listing
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id))) .insert_header((
http::header::LOCATION,
format!("/marketplace/{}", listing_id),
))
.finish()) .finish())
} }
// Purchase a fixed-price listing // Purchase a fixed-price listing
pub async fn purchase_listing( pub async fn purchase_listing(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
form: web::Form<PurchaseForm>, form: web::Form<PurchaseForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let listing_id = path.into_inner(); let listing_id = path.into_inner();
let form = form.into_inner(); let form = form.into_inner();
if !form.agree_to_terms { if !form.agree_to_terms {
// User must agree to terms // User must agree to terms
return Ok(HttpResponse::SeeOther() return Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id))) .insert_header((
http::header::LOCATION,
format!("/marketplace/{}", listing_id),
))
.finish()); .finish());
} }
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate the purchase // 2. Validate the purchase
// 3. Process the transaction // 3. Process the transaction
// 4. Update the listing status // 4. Update the listing status
// 5. Transfer the asset // 5. Transfer the asset
// For now, we'll just redirect to the marketplace // For now, we'll just redirect to the marketplace
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace")) .insert_header((http::header::LOCATION, "/marketplace"))
.finish()) .finish())
} }
// Cancel a listing // Cancel a listing
pub async fn cancel_listing( pub async fn cancel_listing(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let _listing_id = path.into_inner(); let _listing_id = path.into_inner();
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate that the current user is the seller // 2. Validate that the current user is the seller
// 3. Update the listing status // 3. Update the listing status
// For now, we'll just redirect to my listings // For now, we'll just redirect to my listings
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace/my")) .insert_header((http::header::LOCATION, "/marketplace/my"))
.finish()) .finish())
} }
// Generate mock listings for development // Generate mock listings for development
pub fn get_mock_listings() -> Vec<Listing> { pub fn get_mock_listings() -> Vec<Listing> {
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let mut listings = Vec::new(); let mut listings = Vec::new();
// Mock user data // Mock user data
let user_ids = vec!["user-123", "user-456", "user-789"]; let user_ids = vec!["user-123", "user-456", "user-789"];
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"]; let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
// Create some fixed price listings // Create some fixed price listings
for i in 0..6 { for i in 0..6 {
let asset_index = i % assets.len(); let asset_index = i % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 50.0 + (i as f64 * 10.0), AssetType::Token => 50.0 + (i as f64 * 10.0),
AssetType::Artwork => 500.0 + (i as f64 * 100.0), AssetType::Artwork => 500.0 + (i as f64 * 100.0),
@ -365,10 +393,13 @@ impl MarketplaceController {
AssetType::Bond => 1500.0 + (i as f64 * 300.0), AssetType::Bond => 1500.0 + (i as f64 * 300.0),
AssetType::Other => 800.0 + (i as f64 * 150.0), AssetType::Other => 800.0 + (i as f64 * 150.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} for Sale", asset.name), format!("{} for Sale", asset.name),
format!("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.id.clone(),
asset.name.clone(), asset.name.clone(),
asset.asset_type.clone(), asset.asset_type.clone(),
@ -381,21 +412,21 @@ impl MarketplaceController {
vec!["digital".to_string(), "asset".to_string()], vec!["digital".to_string(), "asset".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Make some listings featured // Make some listings featured
if i % 5 == 0 { if i % 5 == 0 {
listing.set_featured(true); listing.set_featured(true);
} }
listings.push(listing); listings.push(listing);
} }
// Create some auction listings // Create some auction listings
for i in 0..4 { for i in 0..4 {
let asset_index = (i + 6) % assets.len(); let asset_index = (i + 6) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let starting_price = match asset.asset_type { let starting_price = match asset.asset_type {
AssetType::Token => 40.0 + (i as f64 * 5.0), AssetType::Token => 40.0 + (i as f64 * 5.0),
AssetType::Artwork => 400.0 + (i as f64 * 50.0), AssetType::Artwork => 400.0 + (i as f64 * 50.0),
@ -406,7 +437,7 @@ impl MarketplaceController {
AssetType::Bond => 1200.0 + (i as f64 * 250.0), AssetType::Bond => 1200.0 + (i as f64 * 250.0),
AssetType::Other => 600.0 + (i as f64 * 120.0), AssetType::Other => 600.0 + (i as f64 * 120.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("Auction: {}", asset.name), format!("Auction: {}", asset.name),
format!("Bid on this amazing {}. {}", asset.name, asset.description), format!("Bid on this amazing {}. {}", asset.name, asset.description),
@ -422,12 +453,13 @@ impl MarketplaceController {
vec!["auction".to_string(), "bidding".to_string()], vec!["auction".to_string(), "bidding".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Add some bids to the auctions // Add some bids to the auctions
let num_bids = 2 + (i % 3); let num_bids = 2 + (i % 3);
for j in 0..num_bids { for j in 0..num_bids {
let bidder_index = (j + 1) % user_ids.len(); let bidder_index = (j + 1) % user_ids.len();
if bidder_index != user_index { // 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 bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
let _ = listing.add_bid( let _ = listing.add_bid(
user_ids[bidder_index].to_string(), user_ids[bidder_index].to_string(),
@ -437,21 +469,21 @@ impl MarketplaceController {
); );
} }
} }
// Make some listings featured // Make some listings featured
if i % 3 == 0 { if i % 3 == 0 {
listing.set_featured(true); listing.set_featured(true);
} }
listings.push(listing); listings.push(listing);
} }
// Create some exchange listings // Create some exchange listings
for i in 0..3 { for i in 0..3 {
let asset_index = (i + 10) % assets.len(); let asset_index = (i + 10) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let value = match asset.asset_type { let value = match asset.asset_type {
AssetType::Token => 60.0 + (i as f64 * 15.0), AssetType::Token => 60.0 + (i as f64 * 15.0),
AssetType::Artwork => 600.0 + (i as f64 * 150.0), AssetType::Artwork => 600.0 + (i as f64 * 150.0),
@ -462,33 +494,36 @@ impl MarketplaceController {
AssetType::Bond => 1800.0 + (i as f64 * 350.0), AssetType::Bond => 1800.0 + (i as f64 * 350.0),
AssetType::Other => 1000.0 + (i as f64 * 200.0), AssetType::Other => 1000.0 + (i as f64 * 200.0),
}; };
let listing = Listing::new( let listing = Listing::new(
format!("Trade: {}", asset.name), format!("Trade: {}", asset.name),
format!("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.id.clone(),
asset.name.clone(), asset.name.clone(),
asset.asset_type.clone(), asset.asset_type.clone(),
user_ids[user_index].to_string(), user_ids[user_index].to_string(),
user_names[user_index].to_string(), user_names[user_index].to_string(),
value, // Estimated value for exchange value, // Estimated value for exchange
"USD".to_string(), "USD".to_string(),
ListingType::Exchange, ListingType::Exchange,
Some(Utc::now() + Duration::days(60)), Some(Utc::now() + Duration::days(60)),
vec!["exchange".to_string(), "trade".to_string()], vec!["exchange".to_string(), "trade".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
listings.push(listing); listings.push(listing);
} }
// Create some sold listings // Create some sold listings
for i in 0..5 { for i in 0..5 {
let asset_index = (i + 13) % assets.len(); let asset_index = (i + 13) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let seller_index = i % user_ids.len(); let seller_index = i % user_ids.len();
let buyer_index = (i + 1) % user_ids.len(); let buyer_index = (i + 1) % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 55.0 + (i as f64 * 12.0), AssetType::Token => 55.0 + (i as f64 * 12.0),
AssetType::Artwork => 550.0 + (i as f64 * 120.0), AssetType::Artwork => 550.0 + (i as f64 * 120.0),
@ -499,9 +534,9 @@ impl MarketplaceController {
AssetType::Bond => 1650.0 + (i as f64 * 330.0), AssetType::Bond => 1650.0 + (i as f64 * 330.0),
AssetType::Other => 900.0 + (i as f64 * 180.0), AssetType::Other => 900.0 + (i as f64 * 180.0),
}; };
let sale_price = price * 0.95; // Slight discount on sale let sale_price = price * 0.95; // Slight discount on sale
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} - SOLD", asset.name), format!("{} - SOLD", asset.name),
format!("This {} was sold recently.", asset.name), format!("This {} was sold recently.", asset.name),
@ -517,27 +552,27 @@ impl MarketplaceController {
vec!["sold".to_string()], vec!["sold".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Mark as sold // Mark as sold
let _ = listing.mark_as_sold( let _ = listing.mark_as_sold(
user_ids[buyer_index].to_string(), user_ids[buyer_index].to_string(),
user_names[buyer_index].to_string(), user_names[buyer_index].to_string(),
sale_price, sale_price,
); );
// Set sold date to be sometime in the past // Set sold date to be sometime in the past
let days_ago = i as i64 + 1; let days_ago = i as i64 + 1;
listing.sold_at = Some(Utc::now() - Duration::days(days_ago)); listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
listings.push(listing); listings.push(listing);
} }
// Create a few cancelled listings // Create a few cancelled listings
for i in 0..2 { for i in 0..2 {
let asset_index = (i + 18) % assets.len(); let asset_index = (i + 18) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 45.0 + (i as f64 * 8.0), AssetType::Token => 45.0 + (i as f64 * 8.0),
AssetType::Artwork => 450.0 + (i as f64 * 80.0), AssetType::Artwork => 450.0 + (i as f64 * 80.0),
@ -548,7 +583,7 @@ impl MarketplaceController {
AssetType::Bond => 1350.0 + (i as f64 * 270.0), AssetType::Bond => 1350.0 + (i as f64 * 270.0),
AssetType::Other => 750.0 + (i as f64 * 150.0), AssetType::Other => 750.0 + (i as f64 * 150.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} - Cancelled", asset.name), format!("{} - Cancelled", asset.name),
format!("This listing for {} was cancelled.", asset.name), format!("This listing for {} was cancelled.", asset.name),
@ -564,13 +599,13 @@ impl MarketplaceController {
vec!["cancelled".to_string()], vec!["cancelled".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Cancel the listing // Cancel the listing
let _ = listing.cancel(); let _ = listing.cancel();
listings.push(listing); listings.push(listing);
} }
listings listings
} }
} }

View File

@ -1,14 +1,18 @@
// Export controllers // 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 asset;
pub mod defi; pub mod auth;
pub mod marketplace; pub mod calendar;
pub mod company; 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 // Re-export controllers for easier imports

File diff suppressed because it is too large Load Diff

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

View 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(())
}

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

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

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

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

View 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;

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

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

View File

@ -1,22 +1,24 @@
use actix_files as fs; use actix_files as fs;
use actix_web::{App, HttpServer, web};
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use tera::Tera; use actix_web::{App, HttpServer, web};
use std::io;
use std::env;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::env;
use std::io;
use tera::Tera;
mod config; mod config;
mod controllers; mod controllers;
mod db;
mod middleware; mod middleware;
mod models; mod models;
mod routes; mod routes;
mod utils; mod utils;
mod validators;
// Import middleware components // Import middleware components
use middleware::{RequestTimer, SecurityHeaders, JwtAuth}; use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
use utils::redis_service;
use models::initialize_mock_data; use models::initialize_mock_data;
use utils::redis_service;
// Initialize lazy_static for in-memory storage // Initialize lazy_static for in-memory storage
extern crate lazy_static; extern crate lazy_static;
@ -29,13 +31,13 @@ lazy_static! {
// Create a key that's at least 64 bytes long // Create a key that's at least 64 bytes long
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string() "my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
}); });
// Ensure the key is at least 64 bytes // Ensure the key is at least 64 bytes
let mut key_bytes = secret.as_bytes().to_vec(); let mut key_bytes = secret.as_bytes().to_vec();
while key_bytes.len() < 64 { while key_bytes.len() < 64 {
key_bytes.extend_from_slice(b"0123456789abcdef"); key_bytes.extend_from_slice(b"0123456789abcdef");
} }
actix_web::cookie::Key::from(&key_bytes[0..64]) actix_web::cookie::Key::from(&key_bytes[0..64])
}; };
} }
@ -45,14 +47,22 @@ async fn main() -> io::Result<()> {
// Initialize environment // Initialize environment
dotenv::dotenv().ok(); dotenv::dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Load configuration // Load configuration
let config = config::get_config(); let config = config::get_config();
// Check for port override from command line arguments // Check for port override from environment variable or command line arguments
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let mut port = config.server.port; let mut port = config.server.port;
// First check environment variable
if let Ok(env_port) = env::var("PORT") {
if let Ok(p) = env_port.parse::<u16>() {
port = p;
}
}
// Then check command line arguments (takes precedence over env var)
for i in 1..args.len() { for i in 1..args.len() {
if args[i] == "--port" && i + 1 < args.len() { if args[i] == "--port" && i + 1 < args.len() {
if let Ok(p) = args[i + 1].parse::<u16>() { if let Ok(p) = args[i + 1].parse::<u16>() {
@ -61,24 +71,28 @@ async fn main() -> io::Result<()> {
} }
} }
} }
let bind_address = format!("{}:{}", config.server.host, port); let bind_address = format!("{}:{}", config.server.host, port);
// Initialize Redis client // Initialize Redis client
let redis_url = 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) { if let Err(e) = redis_service::init_redis_client(&redis_url) {
log::error!("Failed to initialize Redis client: {}", e); log::error!("Failed to initialize Redis client: {}", e);
log::warn!("Calendar functionality will not work properly without Redis"); log::warn!("Calendar functionality will not work properly without Redis");
} else { } else {
log::info!("Redis client initialized successfully"); log::info!("Redis client initialized successfully");
} }
// Initialize mock data for DeFi operations // Initialize mock data for DeFi operations
initialize_mock_data(); initialize_mock_data();
log::info!("DeFi mock data initialized successfully"); log::info!("DeFi mock data initialized successfully");
// Governance activity tracker is now ready to record real user activities
log::info!("Governance activity tracker initialized and ready");
log::info!("Starting server at http://{}", bind_address); log::info!("Starting server at http://{}", bind_address);
// Create and configure the HTTP server // Create and configure the HTTP server
HttpServer::new(move || { HttpServer::new(move || {
// Initialize Tera templates // Initialize Tera templates
@ -89,10 +103,10 @@ async fn main() -> io::Result<()> {
::std::process::exit(1); ::std::process::exit(1);
} }
}; };
// Register custom Tera functions // Register custom Tera functions
utils::register_tera_functions(&mut tera); utils::register_tera_functions(&mut tera);
App::new() App::new()
// Enable logger middleware // Enable logger middleware
.wrap(Logger::default()) .wrap(Logger::default())
@ -106,6 +120,8 @@ async fn main() -> io::Result<()> {
.app_data(web::Data::new(tera)) .app_data(web::Data::new(tera))
// Configure routes // Configure routes
.configure(routes::configure_routes) .configure(routes::configure_routes)
// Add default handler for 404 errors
.default_service(web::route().to(controllers::error::render_generic_not_found))
}) })
.bind(bind_address)? .bind(bind_address)?
.workers(num_cpus::get()) .workers(num_cpus::get())

View File

@ -112,6 +112,7 @@ pub struct Asset {
pub external_url: Option<String>, pub external_url: Option<String>,
} }
#[allow(dead_code)]
impl Asset { impl Asset {
/// Creates a new asset /// Creates a new asset
pub fn new( pub fn new(

View File

@ -1,61 +1,4 @@
use chrono::{DateTime, Utc}; // No imports needed for this module currently
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Represents a calendar event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
/// Unique identifier for the event
pub id: String,
/// Title of the event
pub title: String,
/// Description of the event
pub description: String,
/// Start time of the event
pub start_time: DateTime<Utc>,
/// End time of the event
pub end_time: DateTime<Utc>,
/// Color of the event (hex code)
pub color: String,
/// Whether the event is an all-day event
pub all_day: bool,
/// User ID of the event creator
pub user_id: Option<String>,
}
impl CalendarEvent {
/// Creates a new calendar event
pub fn new(
title: String,
description: String,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
color: Option<String>,
all_day: bool,
user_id: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
title,
description,
start_time,
end_time,
color: color.unwrap_or_else(|| "#4285F4".to_string()), // Google Calendar blue
all_day,
user_id,
}
}
/// Converts the event to a JSON string
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
/// Creates an event from a JSON string
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
/// Represents a view mode for the calendar /// Represents a view mode for the calendar
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -91,4 +34,4 @@ impl CalendarViewMode {
Self::Day => "day", Self::Day => "day",
} }
} }
} }

View File

@ -1,7 +1,99 @@
#![allow(dead_code)] // Model utility functions may not all be used yet
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Contract activity types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContractActivityType {
Created,
SignerAdded,
SignerRemoved,
SentForSignatures,
Signed,
Rejected,
StatusChanged,
Revised,
}
impl ContractActivityType {
pub fn as_str(&self) -> &str {
match self {
ContractActivityType::Created => "Contract Created",
ContractActivityType::SignerAdded => "Signer Added",
ContractActivityType::SignerRemoved => "Signer Removed",
ContractActivityType::SentForSignatures => "Sent for Signatures",
ContractActivityType::Signed => "Contract Signed",
ContractActivityType::Rejected => "Contract Rejected",
ContractActivityType::StatusChanged => "Status Changed",
ContractActivityType::Revised => "Contract Revised",
}
}
}
/// Contract activity model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractActivity {
pub id: String,
pub contract_id: u32,
pub activity_type: ContractActivityType,
pub description: String,
pub user_name: String,
pub created_at: DateTime<Utc>,
pub metadata: Option<serde_json::Value>,
}
impl ContractActivity {
/// Creates a new contract activity
pub fn new(
contract_id: u32,
activity_type: ContractActivityType,
description: String,
user_name: String,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
contract_id,
activity_type,
description,
user_name,
created_at: Utc::now(),
metadata: None,
}
}
/// Creates a contract creation activity
pub fn contract_created(contract_id: u32, contract_title: &str, user_name: &str) -> Self {
Self::new(
contract_id,
ContractActivityType::Created,
format!("Created contract '{}'", contract_title),
user_name.to_string(),
)
}
/// Creates a signer added activity
pub fn signer_added(contract_id: u32, signer_name: &str, user_name: &str) -> Self {
Self::new(
contract_id,
ContractActivityType::SignerAdded,
format!("Added signer: {}", signer_name),
user_name.to_string(),
)
}
/// Creates a sent for signatures activity
pub fn sent_for_signatures(contract_id: u32, signer_count: usize, user_name: &str) -> Self {
Self::new(
contract_id,
ContractActivityType::SentForSignatures,
format!("Sent contract for signatures to {} signer(s)", signer_count),
user_name.to_string(),
)
}
}
/// Contract status enum /// Contract status enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContractStatus { pub enum ContractStatus {
@ -10,7 +102,7 @@ pub enum ContractStatus {
Signed, Signed,
Active, Active,
Expired, Expired,
Cancelled Cancelled,
} }
impl ContractStatus { impl ContractStatus {
@ -37,7 +129,7 @@ pub enum ContractType {
Distribution, Distribution,
License, License,
Membership, Membership,
Other Other,
} }
impl ContractType { impl ContractType {
@ -61,7 +153,7 @@ impl ContractType {
pub enum SignerStatus { pub enum SignerStatus {
Pending, Pending,
Signed, Signed,
Rejected Rejected,
} }
impl SignerStatus { impl SignerStatus {
@ -85,6 +177,7 @@ pub struct ContractSigner {
pub comments: Option<String>, pub comments: Option<String>,
} }
#[allow(dead_code)]
impl ContractSigner { impl ContractSigner {
/// Creates a new contract signer /// Creates a new contract signer
pub fn new(name: String, email: String) -> Self { pub fn new(name: String, email: String) -> Self {
@ -123,9 +216,15 @@ pub struct ContractRevision {
pub comments: Option<String>, pub comments: Option<String>,
} }
#[allow(dead_code)]
impl ContractRevision { impl ContractRevision {
/// Creates a new contract revision /// Creates a new contract revision
pub fn new(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 { Self {
version, version,
content, content,
@ -166,9 +265,16 @@ pub struct Contract {
pub toc: Option<Vec<TocItem>>, pub toc: Option<Vec<TocItem>>,
} }
#[allow(dead_code)]
impl Contract { impl Contract {
/// Creates a new contract /// Creates a new contract
pub fn new(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 { Self {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
title, title,
@ -226,7 +332,9 @@ impl Contract {
return false; 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 /// Marks the contract as signed if all signers have signed
@ -258,17 +366,26 @@ impl Contract {
/// Gets the number of pending signers /// Gets the number of pending signers
pub fn pending_signers_count(&self) -> usize { pub fn pending_signers_count(&self) -> usize {
self.signers.iter().filter(|s| s.status == SignerStatus::Pending).count() self.signers
.iter()
.filter(|s| s.status == SignerStatus::Pending)
.count()
} }
/// Gets the number of signed signers /// Gets the number of signed signers
pub fn signed_signers_count(&self) -> usize { pub fn signed_signers_count(&self) -> usize {
self.signers.iter().filter(|s| s.status == SignerStatus::Signed).count() self.signers
.iter()
.filter(|s| s.status == SignerStatus::Signed)
.count()
} }
/// Gets the number of rejected signers /// Gets the number of rejected signers
pub fn rejected_signers_count(&self) -> usize { pub fn rejected_signers_count(&self) -> usize {
self.signers.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 /// Creates new contract statistics from a list of contracts
pub fn new(contracts: &[Contract]) -> Self { pub fn new(contracts: &[Contract]) -> Self {
let total_contracts = contracts.len(); let total_contracts = contracts.len();
let draft_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Draft).count(); let draft_contracts = contracts
let pending_signature_contracts = contracts.iter().filter(|c| c.status == ContractStatus::PendingSignatures).count(); .iter()
let signed_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Signed).count(); .filter(|c| c.status == ContractStatus::Draft)
let expired_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Expired).count(); .count();
let cancelled_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Cancelled).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 { Self {
total_contracts, total_contracts,

View File

@ -14,6 +14,7 @@ pub enum DefiPositionStatus {
Cancelled Cancelled
} }
#[allow(dead_code)]
impl DefiPositionStatus { impl DefiPositionStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -35,6 +36,7 @@ pub enum DefiPositionType {
Collateral, Collateral,
} }
#[allow(dead_code)]
impl DefiPositionType { impl DefiPositionType {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -95,6 +97,7 @@ pub struct DefiDatabase {
receiving_positions: HashMap<String, ReceivingPosition>, receiving_positions: HashMap<String, ReceivingPosition>,
} }
#[allow(dead_code)]
impl DefiDatabase { impl DefiDatabase {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {

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

View File

@ -110,6 +110,7 @@ pub struct FlowStep {
pub logs: Vec<FlowLog>, pub logs: Vec<FlowLog>,
} }
#[allow(dead_code)]
impl FlowStep { impl FlowStep {
/// Creates a new flow step /// Creates a new flow step
pub fn new(name: String, description: String, order: u32) -> Self { pub fn new(name: String, description: String, order: u32) -> Self {
@ -189,6 +190,7 @@ pub struct FlowLog {
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
} }
#[allow(dead_code)]
impl FlowLog { impl FlowLog {
/// Creates a new flow log /// Creates a new flow log
pub fn new(message: String) -> Self { pub fn new(message: String) -> Self {
@ -231,6 +233,7 @@ pub struct Flow {
pub current_step: Option<FlowStep>, pub current_step: Option<FlowStep>,
} }
#[allow(dead_code)]
impl Flow { impl Flow {
/// Creates a new flow /// Creates a new flow
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self { pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {

View File

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

View File

@ -1,7 +1,7 @@
use crate::models::asset::AssetType;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::models::asset::{Asset, AssetType};
/// Status of a marketplace listing /// Status of a marketplace listing
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -12,6 +12,7 @@ pub enum ListingStatus {
Expired, Expired,
} }
#[allow(dead_code)]
impl ListingStatus { impl ListingStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -63,6 +64,7 @@ pub enum BidStatus {
Cancelled, Cancelled,
} }
#[allow(dead_code)]
impl BidStatus { impl BidStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -103,6 +105,7 @@ pub struct Listing {
pub image_url: Option<String>, pub image_url: Option<String>,
} }
#[allow(dead_code)]
impl Listing { impl Listing {
/// Creates a new listing /// Creates a new listing
pub fn new( pub fn new(
@ -150,7 +153,13 @@ impl Listing {
} }
/// Adds a bid to the 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 { if self.status != ListingStatus::Active {
return Err("Listing is not active".to_string()); return Err("Listing is not active".to_string());
} }
@ -160,7 +169,10 @@ impl Listing {
} }
if currency != self.currency { 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 // 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 /// Gets the highest bid on the listing
pub fn highest_bid(&self) -> Option<&Bid> { pub fn highest_bid(&self) -> Option<&Bid> {
self.bids.iter() self.bids
.iter()
.filter(|b| b.status == BidStatus::Active) .filter(|b| b.status == BidStatus::Active)
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
} }
/// Marks the listing as sold /// Marks the listing as sold
pub fn mark_as_sold(&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 { if self.status != ListingStatus::Active {
return Err("Listing is not active".to_string()); 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 listings_by_type = std::collections::HashMap::new();
let mut sales_by_asset_type = std::collections::HashMap::new(); let mut sales_by_asset_type = std::collections::HashMap::new();
let active_listings = listings.iter() let active_listings = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.count(); .count();
let sold_listings = listings.iter() let sold_listings = listings
.iter()
.filter(|l| l.status == ListingStatus::Sold) .filter(|l| l.status == ListingStatus::Sold)
.count(); .count();

View 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);
}
}

View File

@ -1,17 +1,18 @@
// Export models // 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 asset;
pub mod marketplace; pub mod calendar;
pub mod contract;
pub mod defi; 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 // Re-export models for easier imports
pub use calendar::CalendarViewMode;
pub use defi::initialize_mock_data;
// Mock user exports removed - import directly from mock_user module when needed
pub use ticket::{Ticket, TicketComment, TicketPriority, TicketStatus};
pub use user::User; pub use user::User;
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority};
pub use calendar::{CalendarEvent, CalendarViewMode};
pub use marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
pub use defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB, initialize_mock_data};

View File

@ -76,6 +76,7 @@ pub struct Ticket {
pub assigned_to: Option<i32>, pub assigned_to: Option<i32>,
} }
#[allow(dead_code)]
impl Ticket { impl Ticket {
/// Creates a new ticket /// Creates a new ticket
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self { pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {

View File

@ -4,6 +4,7 @@ use bcrypt::{hash, verify, DEFAULT_COST};
/// Represents a user in the system /// Represents a user in the system
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct User { pub struct User {
/// Unique identifier for the user /// Unique identifier for the user
pub id: Option<i32>, pub id: Option<i32>,
@ -31,6 +32,7 @@ pub enum UserRole {
Admin, Admin,
} }
#[allow(dead_code)]
impl User { impl User {
/// Creates a new user with default values /// Creates a new user with default values
pub fn new(name: String, email: String) -> Self { pub fn new(name: String, email: String) -> Self {
@ -125,6 +127,7 @@ impl User {
/// Represents user login credentials /// Represents user login credentials
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LoginCredentials { pub struct LoginCredentials {
pub email: String, pub email: String,
pub password: String, pub password: String,
@ -132,6 +135,7 @@ pub struct LoginCredentials {
/// Represents user registration data /// Represents user registration data
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct RegistrationData { pub struct RegistrationData {
pub name: String, pub name: String,
pub email: String, pub email: String,

View File

@ -1,28 +1,31 @@
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::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 /// Configures all application routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Configure health check routes (no authentication required)
crate::controllers::health::configure_health_routes(cfg);
// Configure session middleware with the consistent key // Configure session middleware with the consistent key
let session_middleware = SessionMiddleware::builder( let session_middleware =
CookieSessionStore::default(), SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
SESSION_KEY.clone() .cookie_secure(false) // Set to true in production with HTTPS
) .build();
.cookie_secure(false) // Set to true in production with HTTPS
.build();
// Public routes that don't require authentication // Public routes that don't require authentication
cfg.service( cfg.service(
@ -33,67 +36,187 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/about", web::get().to(HomeController::about)) .route("/about", web::get().to(HomeController::about))
.route("/contact", web::get().to(HomeController::contact)) .route("/contact", web::get().to(HomeController::contact))
.route("/contact", web::post().to(HomeController::submit_contact)) .route("/contact", web::post().to(HomeController::submit_contact))
// Auth routes // Auth routes
.route("/login", web::get().to(AuthController::login_page)) .route("/login", web::get().to(AuthController::login_page))
.route("/login", web::post().to(AuthController::login)) .route("/login", web::post().to(AuthController::login))
.route("/register", web::get().to(AuthController::register_page)) .route("/register", web::get().to(AuthController::register_page))
.route("/register", web::post().to(AuthController::register)) .route("/register", web::post().to(AuthController::register))
.route("/logout", web::get().to(AuthController::logout)) .route("/logout", web::get().to(AuthController::logout))
// Protected routes that require authentication // Protected routes that require authentication
// These routes will be protected by the JwtAuth middleware in the main.rs file // These routes will be protected by the JwtAuth middleware in the main.rs file
.route("/editor", web::get().to(HomeController::editor)) .route("/editor", web::get().to(HomeController::editor))
// Ticket routes // Ticket routes
.route("/tickets", web::get().to(TicketController::list_tickets)) .route("/tickets", web::get().to(TicketController::list_tickets))
.route("/tickets/new", web::get().to(TicketController::new_ticket)) .route("/tickets/new", web::get().to(TicketController::new_ticket))
.route("/tickets", web::post().to(TicketController::create_ticket)) .route("/tickets", web::post().to(TicketController::create_ticket))
.route("/tickets/{id}", web::get().to(TicketController::show_ticket)) .route(
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment)) "/tickets/{id}",
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status)) 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)) .route("/my-tickets", web::get().to(TicketController::my_tickets))
// Calendar routes // Calendar routes
.route("/calendar", web::get().to(CalendarController::calendar)) .route("/calendar", web::get().to(CalendarController::calendar))
.route("/calendar/events/new", web::get().to(CalendarController::new_event)) .route(
.route("/calendar/events", web::post().to(CalendarController::create_event)) "/calendar/events/new",
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event)) 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 // Governance routes
.route("/governance", web::get().to(GovernanceController::index)) .route("/governance", web::get().to(GovernanceController::index))
.route("/governance/proposals", web::get().to(GovernanceController::proposals)) .route(
.route("/governance/proposals/{id}", web::get().to(GovernanceController::proposal_detail)) "/governance/proposals",
.route("/governance/proposals/{id}/vote", web::post().to(GovernanceController::submit_vote)) web::get().to(GovernanceController::proposals),
.route("/governance/create", web::get().to(GovernanceController::create_proposal_form)) )
.route("/governance/create", web::post().to(GovernanceController::submit_proposal)) .route(
.route("/governance/my-votes", web::get().to(GovernanceController::my_votes)) "/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 // Flow routes
.service( .service(
web::scope("/flows") web::scope("/flows")
.route("", web::get().to(FlowController::index)) .route("", web::get().to(FlowController::index))
.route("/list", web::get().to(FlowController::list_flows)) .route("/list", web::get().to(FlowController::list_flows))
.route("/{id}", web::get().to(FlowController::flow_detail)) .route("/{id}", web::get().to(FlowController::flow_detail))
.route("/{id}/advance", web::post().to(FlowController::advance_flow_step)) .route(
.route("/{id}/stuck", web::post().to(FlowController::mark_flow_step_stuck)) "/{id}/advance",
.route("/{id}/step/{step_id}/log", web::post().to(FlowController::add_log_to_flow_step)) 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::get().to(FlowController::create_flow_form))
.route("/create", web::post().to(FlowController::create_flow)) .route("/create", web::post().to(FlowController::create_flow))
.route("/my-flows", web::get().to(FlowController::my_flows)) .route("/my-flows", web::get().to(FlowController::my_flows)),
) )
// Contract routes // Contract routes
.service( .service(
web::scope("/contracts") web::scope("/contracts")
.route("", web::get().to(ContractController::index)) .route("", web::get().to(ContractController::index))
.route("/", web::get().to(ContractController::index)) // Handle trailing slash
.route("/list", web::get().to(ContractController::list)) .route("/list", web::get().to(ContractController::list))
.route("/my", web::get().to(ContractController::my_contracts)) .route("/list/", web::get().to(ContractController::list)) // Handle trailing slash
.route("/{id}", web::get().to(ContractController::detail)) .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))
.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))
.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 // Asset routes
.service( .service(
web::scope("/assets") web::scope("/assets")
@ -104,49 +227,113 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/create", web::post().to(AssetController::create)) .route("/create", web::post().to(AssetController::create))
.route("/test", web::get().to(AssetController::test)) .route("/test", web::get().to(AssetController::test))
.route("/{id}", web::get().to(AssetController::detail)) .route("/{id}", web::get().to(AssetController::detail))
.route("/{id}/valuation", web::post().to(AssetController::add_valuation)) .route(
.route("/{id}/transaction", web::post().to(AssetController::add_transaction)) "/{id}/valuation",
.route("/{id}/status/{status}", web::post().to(AssetController::update_status)) 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 // Marketplace routes
.service( .service(
web::scope("/marketplace") web::scope("/marketplace")
.route("", web::get().to(MarketplaceController::index)) .route("", web::get().to(MarketplaceController::index))
.route("/listings", web::get().to(MarketplaceController::list_listings)) .route(
"/listings",
web::get().to(MarketplaceController::list_listings),
)
.route("/my", web::get().to(MarketplaceController::my_listings)) .route("/my", web::get().to(MarketplaceController::my_listings))
.route("/create", web::get().to(MarketplaceController::create_listing_form)) .route(
.route("/create", web::post().to(MarketplaceController::create_listing)) "/create",
.route("/{id}", web::get().to(MarketplaceController::listing_detail)) web::get().to(MarketplaceController::create_listing_form),
.route("/{id}/bid", web::post().to(MarketplaceController::submit_bid)) )
.route("/{id}/purchase", web::post().to(MarketplaceController::purchase_listing)) .route(
.route("/{id}/cancel", web::post().to(MarketplaceController::cancel_listing)) "/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 // DeFi routes
.service( .service(
web::scope("/defi") web::scope("/defi")
.route("", web::get().to(DefiController::index)) .route("", web::get().to(DefiController::index))
.route("/providing", web::post().to(DefiController::create_providing)) .route(
.route("/receiving", web::post().to(DefiController::create_receiving)) "/providing",
web::post().to(DefiController::create_providing),
)
.route(
"/receiving",
web::post().to(DefiController::create_receiving),
)
.route("/liquidity", web::post().to(DefiController::add_liquidity)) .route("/liquidity", web::post().to(DefiController::add_liquidity))
.route("/staking", web::post().to(DefiController::create_staking)) .route("/staking", web::post().to(DefiController::create_staking))
.route("/swap", web::post().to(DefiController::swap_tokens)) .route("/swap", web::post().to(DefiController::swap_tokens))
.route("/collateral", web::post().to(DefiController::create_collateral)) .route(
"/collateral",
web::post().to(DefiController::create_collateral),
),
) )
// Company routes // Company routes
.service( .service(
web::scope("/company") web::scope("/company")
.route("", web::get().to(CompanyController::index)) .route("", web::get().to(CompanyController::index))
.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("/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 // Keep the /protected scope for any future routes that should be under that path
cfg.service( cfg.service(
web::scope("/protected") web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
.wrap(JwtAuth) // Apply JWT authentication middleware
); );
} }

View File

@ -1,16 +1,20 @@
use actix_web::{error, Error, HttpResponse}; use actix_web::{Error, HttpResponse};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use tera::{self, Context, Function, Tera, Value}; use pulldown_cmark::{Options, Parser, html};
use std::error::Error as StdError; use std::error::Error as StdError;
use tera::{self, Context, Function, Tera, Value};
// Export modules // Export modules
pub mod redis_service; pub mod redis_service;
pub mod secure_logging;
pub mod stripe_security;
// Re-export for easier imports // Re-export for easier imports
pub use redis_service::RedisCalendarService; // pub use redis_service::RedisCalendarService; // Currently unused
/// Error type for template rendering /// Error type for template rendering
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub struct TemplateError { pub struct TemplateError {
pub message: String, pub message: String,
pub details: String, pub details: String,
@ -25,10 +29,16 @@ impl std::fmt::Display for TemplateError {
impl std::error::Error 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) { pub fn register_tera_functions(tera: &mut tera::Tera) {
tera.register_function("now", NowFunction); tera.register_function("now", NowFunction);
tera.register_function("format_date", FormatDateFunction); tera.register_function("format_date", FormatDateFunction);
tera.register_function("local_time", LocalTimeFunction);
// Register custom filters
tera.register_filter("format_hour", format_hour_filter);
tera.register_filter("extract_hour", extract_hour_filter);
tera.register_filter("format_time", format_time_filter);
} }
/// Tera function to get the current date/time /// Tera function to get the current date/time
@ -46,7 +56,7 @@ impl Function for NowFunction {
}; };
let now = Utc::now(); let now = Utc::now();
// Special case for just getting the year // Special case for just getting the year
if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) { if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(Value::String(now.format("%Y").to_string())); return Ok(Value::String(now.format("%Y").to_string()));
@ -68,14 +78,10 @@ impl Function for FormatDateFunction {
None => { None => {
return Err(tera::Error::msg( return Err(tera::Error::msg(
"The 'timestamp' argument must be a valid timestamp", "The 'timestamp' argument must be a valid timestamp",
)) ));
} }
}, },
None => { None => return Err(tera::Error::msg("The 'timestamp' argument is required")),
return Err(tera::Error::msg(
"The 'timestamp' argument is required",
))
}
}; };
let format = match args.get("format") { let format = match args.get("format") {
@ -89,23 +95,130 @@ impl Function for FormatDateFunction {
// Convert timestamp to DateTime using the non-deprecated method // Convert timestamp to DateTime using the non-deprecated method
let datetime = match DateTime::from_timestamp(timestamp, 0) { let datetime = match DateTime::from_timestamp(timestamp, 0) {
Some(dt) => dt, Some(dt) => dt,
None => { None => return Err(tera::Error::msg("Failed to convert timestamp to datetime")),
return Err(tera::Error::msg(
"Failed to convert timestamp to datetime",
))
}
}; };
Ok(Value::String(datetime.format(format).to_string())) Ok(Value::String(datetime.format(format).to_string()))
} }
} }
/// Tera function to convert UTC datetime to local time
#[derive(Clone)]
pub struct LocalTimeFunction;
impl Function for LocalTimeFunction {
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
let datetime_value = match args.get("datetime") {
Some(val) => val,
None => return Err(tera::Error::msg("The 'datetime' argument is required")),
};
let format = match args.get("format") {
Some(val) => match val.as_str() {
Some(s) => s,
None => "%Y-%m-%d %H:%M",
},
None => "%Y-%m-%d %H:%M",
};
// The datetime comes from Rust as a serialized DateTime<Utc>
// We need to handle it properly
let utc_datetime = if let Some(dt_str) = datetime_value.as_str() {
// Try to parse as RFC3339 first
match DateTime::parse_from_rfc3339(dt_str) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => {
// Try to parse as our standard format
match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => return Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
} else {
return Err(tera::Error::msg("Datetime must be a string"));
};
// Convert UTC to local time (EEST = UTC+3)
// In a real application, you'd want to get the user's timezone from their profile
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
let local_datetime = utc_datetime.with_timezone(&local_offset);
Ok(Value::String(local_datetime.format(format).to_string()))
}
}
/// Tera filter to format hour with zero padding
pub fn format_hour_filter(
value: &Value,
_args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_i64() {
Some(hour) => Ok(Value::String(format!("{:02}", hour))),
None => Err(tera::Error::msg("Value must be a number")),
}
}
/// Tera filter to extract hour from datetime string
pub fn extract_hour_filter(
value: &Value,
_args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(datetime_str) => {
// Try to parse as RFC3339 first
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
Ok(Value::String(dt.format("%H").to_string()))
} else {
// Try to parse as our standard format
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => Ok(Value::String(dt.format("%H").to_string())),
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
None => Err(tera::Error::msg("Value must be a string")),
}
}
/// Tera filter to format time from datetime string
pub fn format_time_filter(
value: &Value,
args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
let format = match args.get("format") {
Some(val) => match val.as_str() {
Some(s) => s,
None => "%H:%M",
},
None => "%H:%M",
};
match value.as_str() {
Some(datetime_str) => {
// Try to parse as RFC3339 first
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
Ok(Value::String(dt.format(format).to_string()))
} else {
// Try to parse as our standard format
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => Ok(Value::String(dt.format(format).to_string())),
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
None => Err(tera::Error::msg("Value must be a string")),
}
}
/// Formats a date for display /// Formats a date for display
#[allow(dead_code)]
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String { pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
date.format(format).to_string() date.format(format).to_string()
} }
/// Truncates a string to a maximum length and adds an ellipsis if truncated /// Truncates a string to a maximum length and adds an ellipsis if truncated
#[allow(dead_code)]
pub fn truncate_string(s: &str, max_length: usize) -> String { pub fn truncate_string(s: &str, max_length: usize) -> String {
if s.len() <= max_length { if s.len() <= max_length {
s.to_string() s.to_string()
@ -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 /// Renders a template with error handling
/// ///
/// This function attempts to render a template and handles any errors by rendering /// This function attempts to render a template and handles any errors by rendering
@ -124,38 +257,41 @@ pub fn render_template(
ctx: &Context, ctx: &Context,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
println!("DEBUG: Attempting to render template: {}", template_name); println!("DEBUG: Attempting to render template: {}", template_name);
// Print all context keys for debugging // Print all context keys for debugging
let mut keys = Vec::new(); let mut keys = Vec::new();
for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() { for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() {
keys.push(key.clone()); keys.push(key.clone());
} }
println!("DEBUG: Context keys: {:?}", keys); println!("DEBUG: Context keys: {:?}", keys);
match tmpl.render(template_name, ctx) { match tmpl.render(template_name, ctx) {
Ok(content) => { Ok(content) => {
println!("DEBUG: Successfully rendered template: {}", template_name); println!("DEBUG: Successfully rendered template: {}", template_name);
Ok(HttpResponse::Ok().content_type("text/html").body(content)) Ok(HttpResponse::Ok().content_type("text/html").body(content))
}, }
Err(e) => { Err(e) => {
// Log the error with more details // Log the error with more details
println!("DEBUG: Template rendering error for {}: {}", template_name, e); println!(
"DEBUG: Template rendering error for {}: {}",
template_name, e
);
println!("DEBUG: Error details: {:?}", e); println!("DEBUG: Error details: {:?}", e);
// Print the error cause chain for better debugging // Print the error cause chain for better debugging
let mut current_error: Option<&dyn StdError> = Some(&e); let mut current_error: Option<&dyn StdError> = Some(&e);
let mut error_chain = Vec::new(); let mut error_chain = Vec::new();
while let Some(error) = current_error { while let Some(error) = current_error {
error_chain.push(format!("{}", error)); error_chain.push(format!("{}", error));
current_error = error.source(); current_error = error.source();
} }
println!("DEBUG: Error chain: {:?}", error_chain); println!("DEBUG: Error chain: {:?}", error_chain);
// Log the error // Log the error
log::error!("Template rendering error: {}", e); log::error!("Template rendering error: {}", e);
// Create a simple error response with more detailed information // Create a simple error response with more detailed information
let error_html = format!( let error_html = format!(
r#"<!DOCTYPE html> r#"<!DOCTYPE html>
@ -187,9 +323,9 @@ pub fn render_template(
e, e,
error_chain.join("\n") error_chain.join("\n")
); );
println!("DEBUG: Returning simple error page"); println!("DEBUG: Returning simple error page");
Ok(HttpResponse::InternalServerError() Ok(HttpResponse::InternalServerError()
.content_type("text/html") .content_type("text/html")
.body(error_html)) .body(error_html))
@ -207,4 +343,4 @@ mod tests {
assert_eq!(truncate_string("Hello, world!", 5), "Hello..."); assert_eq!(truncate_string("Hello, world!", 5), "Hello...");
assert_eq!(truncate_string("", 5), ""); assert_eq!(truncate_string("", 5), "");
} }
} }

View File

@ -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 redis::{Client, Commands, Connection, RedisError};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use lazy_static::lazy_static;
use crate::models::CalendarEvent;
// Create a lazy static Redis client that can be used throughout the application // Create a lazy static Redis client that can be used throughout the application
lazy_static! { lazy_static! {
@ -11,21 +13,21 @@ lazy_static! {
/// Initialize the Redis client /// Initialize the Redis client
pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> { pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> {
let client = redis::Client::open(redis_url)?; let client = redis::Client::open(redis_url)?;
// Test the connection // Test the connection
let _: Connection = client.get_connection()?; let _: Connection = client.get_connection()?;
// Store the client in the lazy static // Store the client in the lazy static
let mut client_guard = REDIS_CLIENT.lock().unwrap(); let mut client_guard = REDIS_CLIENT.lock().unwrap();
*client_guard = Some(client); *client_guard = Some(client);
Ok(()) Ok(())
} }
/// Get a Redis connection /// Get a Redis connection
pub fn get_connection() -> Result<Connection, RedisError> { pub fn get_connection() -> Result<Connection, RedisError> {
let client_guard = REDIS_CLIENT.lock().unwrap(); let client_guard = REDIS_CLIENT.lock().unwrap();
if let Some(client) = &*client_guard { if let Some(client) = &*client_guard {
client.get_connection() client.get_connection()
} else { } else {
@ -42,14 +44,14 @@ pub struct RedisCalendarService;
impl RedisCalendarService { impl RedisCalendarService {
/// Key prefix for calendar events /// Key prefix for calendar events
const EVENT_KEY_PREFIX: &'static str = "calendar:event:"; const EVENT_KEY_PREFIX: &'static str = "calendar:event:";
/// Key for the set of all event IDs /// Key for the set of all event IDs
const ALL_EVENTS_KEY: &'static str = "calendar:all_events"; const ALL_EVENTS_KEY: &'static str = "calendar:all_events";
/// Save a calendar event to Redis /// Save a calendar event to Redis
pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> { pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Convert the event to JSON // Convert the event to JSON
let json = event.to_json().map_err(|e| { let json = event.to_json().map_err(|e| {
RedisError::from(std::io::Error::new( RedisError::from(std::io::Error::new(
@ -57,25 +59,25 @@ impl RedisCalendarService {
format!("Failed to serialize event: {}", e), format!("Failed to serialize event: {}", e),
)) ))
})?; })?;
// Save the event // Save the event
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.base_data.id);
let _: () = conn.set(event_key, json)?; let _: () = conn.set(event_key, json)?;
// Add the event ID to the set of all events // Add the event ID to the set of all events
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.id)?; let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.base_data.id)?;
Ok(()) Ok(())
} }
/// Get a calendar event from Redis by ID /// Get a calendar event from Redis by ID
pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> { pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Get the event JSON // Get the event JSON
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
let json: Option<String> = conn.get(event_key)?; let json: Option<String> = conn.get(event_key)?;
// Parse the JSON // Parse the JSON
if let Some(json) = json { if let Some(json) = json {
let event = CalendarEvent::from_json(&json).map_err(|e| { let event = CalendarEvent::from_json(&json).map_err(|e| {
@ -84,34 +86,34 @@ impl RedisCalendarService {
format!("Failed to deserialize event: {}", e), format!("Failed to deserialize event: {}", e),
)) ))
})?; })?;
Ok(Some(event)) Ok(Some(event))
} else { } else {
Ok(None) Ok(None)
} }
} }
/// Delete a calendar event from Redis /// Delete a calendar event from Redis
pub fn delete_event(id: &str) -> Result<bool, RedisError> { pub fn delete_event(id: &str) -> Result<bool, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Delete the event // Delete the event
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
let deleted: i32 = conn.del(event_key)?; let deleted: i32 = conn.del(event_key)?;
// Remove the event ID from the set of all events // Remove the event ID from the set of all events
let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?; let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?;
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Get all calendar events from Redis /// Get all calendar events from Redis
pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> { pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Get all event IDs // Get all event IDs
let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?; let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?;
// Get all events // Get all events
let mut events = Vec::new(); let mut events = Vec::new();
for id in event_ids { for id in event_ids {
@ -119,23 +121,23 @@ impl RedisCalendarService {
events.push(event); events.push(event);
} }
} }
Ok(events) Ok(events)
} }
/// Get events for a specific date range /// Get events for a specific date range
pub fn get_events_in_range( pub fn get_events_in_range(
start: chrono::DateTime<chrono::Utc>, start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>, end: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<CalendarEvent>, RedisError> { ) -> Result<Vec<CalendarEvent>, RedisError> {
let all_events = Self::get_all_events()?; let all_events = Self::get_all_events()?;
// Filter events that fall within the date range // Filter events that fall within the date range
let filtered_events = all_events let filtered_events = all_events
.into_iter() .into_iter()
.filter(|event| event.start_time <= end && event.end_time >= start) .filter(|event| event.start_time <= end && event.end_time >= start)
.collect(); .collect();
Ok(filtered_events) Ok(filtered_events)
} }
} }

View 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"));
}
}

View 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,
&current_signature_header,
webhook_secret,
Some(300),
);
assert!(result.is_ok());
assert!(result.unwrap());
}
}

View 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"));
}
}

View 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

View File

@ -5,29 +5,29 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<h1>Create New Event</h1> <h1>Create New Event</h1>
{% if error %} {% if error %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ error }} {{ error }}
</div> </div>
{% endif %} {% endif %}
<form action="/calendar/new" method="post"> <form action="/calendar/events" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Event Title</label> <label for="title" class="form-label">Event Title</label>
<input type="text" class="form-control" id="title" name="title" required> <input type="text" class="form-control" id="title" name="title" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea> <textarea class="form-control" id="description" name="description" rows="3"></textarea>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="all_day" name="all_day"> <input type="checkbox" class="form-check-input" id="all_day" name="all_day">
<label class="form-check-label" for="all_day">All Day Event</label> <label class="form-check-label" for="all_day">All Day Event</label>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<label for="start_time" class="form-label">Start Time</label> <label for="start_time" class="form-label">Start Time</label>
@ -38,7 +38,14 @@
<input type="datetime-local" class="form-control" id="end_time" name="end_time" required> <input type="datetime-local" class="form-control" id="end_time" name="end_time" required>
</div> </div>
</div> </div>
<!-- Show selected date info when coming from calendar date click -->
<div id="selected-date-info" class="alert alert-info" style="display: none;">
<strong>Selected Date:</strong> <span id="selected-date-display"></span>
<br>
<small>The date is pre-selected. You can only modify the time portion.</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="color" class="form-label">Event Color</label> <label for="color" class="form-label">Event Color</label>
<select class="form-control" id="color" name="color"> <select class="form-control" id="color" name="color">
@ -50,7 +57,7 @@
<option value="#24C1E0">Cyan</option> <option value="#24C1E0">Cyan</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary">Create Event</button> <button type="submit" class="btn btn-primary">Create Event</button>
<a href="/calendar" class="btn btn-secondary">Cancel</a> <a href="/calendar" class="btn btn-secondary">Cancel</a>
@ -59,37 +66,106 @@
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// Check if we came from a date click (URL parameter)
const urlParams = new URLSearchParams(window.location.search);
const selectedDate = urlParams.get('date');
if (selectedDate) {
// Show the selected date info
document.getElementById('selected-date-info').style.display = 'block';
document.getElementById('selected-date-display').textContent = new Date(selectedDate).toLocaleDateString();
// Pre-fill the date portion and restrict date changes
const startTimeInput = document.getElementById('start_time');
const endTimeInput = document.getElementById('end_time');
// Set default times (9 AM to 10 AM on the selected date)
const startDateTime = new Date(selectedDate + 'T09:00');
const endDateTime = new Date(selectedDate + 'T10:00');
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
startTimeInput.value = startDateTime.toISOString().slice(0, 16);
endTimeInput.value = endDateTime.toISOString().slice(0, 16);
// Set minimum and maximum date to the selected date to prevent changing the date
const minDate = selectedDate + 'T00:00';
const maxDate = selectedDate + 'T23:59';
startTimeInput.min = minDate;
startTimeInput.max = maxDate;
endTimeInput.min = minDate;
endTimeInput.max = maxDate;
// Add event listeners to ensure end time is after start time
startTimeInput.addEventListener('change', function () {
const startTime = new Date(this.value);
const endTime = new Date(endTimeInput.value);
if (endTime <= startTime) {
// Set end time to 1 hour after start time
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
}
// Update end time minimum to be after start time
endTimeInput.min = this.value;
});
endTimeInput.addEventListener('change', function () {
const startTime = new Date(startTimeInput.value);
const endTime = new Date(this.value);
if (endTime <= startTime) {
// Reset to 1 hour after start time
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
this.value = newEndTime.toISOString().slice(0, 16);
}
});
} else {
// No date selected, set default to current time
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
document.getElementById('start_time').value = now.toISOString().slice(0, 16);
document.getElementById('end_time').value = oneHourLater.toISOString().slice(0, 16);
}
// Convert datetime-local inputs to RFC3339 format on form submission // Convert datetime-local inputs to RFC3339 format on form submission
document.querySelector('form').addEventListener('submit', function(e) { document.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
const startTime = document.getElementById('start_time').value; const startTime = document.getElementById('start_time').value;
const endTime = document.getElementById('end_time').value; const endTime = document.getElementById('end_time').value;
// Validate that end time is after start time
if (new Date(endTime) <= new Date(startTime)) {
alert('End time must be after start time');
return;
}
// Convert to RFC3339 format // Convert to RFC3339 format
const startRFC = new Date(startTime).toISOString(); const startRFC = new Date(startTime).toISOString();
const endRFC = new Date(endTime).toISOString(); const endRFC = new Date(endTime).toISOString();
// Create hidden inputs for the RFC3339 values // Create hidden inputs for the RFC3339 values
const startInput = document.createElement('input'); const startInput = document.createElement('input');
startInput.type = 'hidden'; startInput.type = 'hidden';
startInput.name = 'start_time'; startInput.name = 'start_time';
startInput.value = startRFC; startInput.value = startRFC;
const endInput = document.createElement('input'); const endInput = document.createElement('input');
endInput.type = 'hidden'; endInput.type = 'hidden';
endInput.name = 'end_time'; endInput.name = 'end_time';
endInput.value = endRFC; endInput.value = endRFC;
// Remove the original inputs // Remove the original inputs
document.getElementById('start_time').removeAttribute('name'); document.getElementById('start_time').removeAttribute('name');
document.getElementById('end_time').removeAttribute('name'); document.getElementById('end_time').removeAttribute('name');
// Add the hidden inputs to the form // Add the hidden inputs to the form
this.appendChild(startInput); this.appendChild(startInput);
this.appendChild(endInput); this.appendChild(endInput);
// Submit the form // Submit the form
this.submit(); this.submit();
}); });

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

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

View File

@ -15,55 +15,71 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Example rows --> {% if companies and companies|length > 0 %}
{% for company in companies %}
<tr> <tr>
<td>Zanzibar Digital Solutions</td> <td>{{ company.name }}</td>
<td>Startup FZC</td> <td>
<td><span class="badge bg-success">Active</span></td> {% if company.business_type == "Starter" %}Startup FZC
<td>2025-04-01</td> {% 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> <td>
<div class="btn-group"> <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/view/{{ company.base_data.id }}" class="btn btn-sm btn-outline-primary">
<a href="/company/switch/company1" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a> <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> </div>
</td> </td>
</tr> </tr>
{% endfor %}
{% else %}
<tr> <tr>
<td>Blockchain Innovations Ltd</td> <td colspan="5" class="text-center py-4">
<td>Growth FZC</td> <div class="text-muted">
<td><span class="badge bg-success">Active</span></td> <i class="bi bi-building display-4 mb-3"></i>
<td>2025-03-15</td> <h5>No Companies Found</h5>
<td> <p>You haven't registered any companies yet. Get started by registering your first company.
<div class="btn-group"> </p>
<a href="/company/view/company2" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a> <button class="btn btn-primary" onclick="document.querySelector('#register-tab').click()">
<a href="/company/switch/company2" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a> <i class="bi bi-plus-circle me-1"></i> Register Your First Company
</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr> {% endif %}
<td>Sustainable Energy Cooperative</td>
<td>Cooperative FZC</td>
<td><span class="badge bg-warning text-dark">Pending</span></td>
<td>2025-05-01</td>
<td>
<div class="btn-group">
<a href="/company/view/company3" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
<a href="/company/switch/company3" class="btn btn-sm btn-primary"><i class="bi bi-box-arrow-in-right"></i> Switch to Entity</a>
</div>
</td>
</tr>
<!-- More rows dynamically rendered here -->
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Company Details Modal --> <!-- Company Details Modal -->
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel" 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-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header bg-light"> <div class="modal-header bg-light">
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details</h5> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -121,7 +137,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
@ -186,8 +202,9 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i 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> </div>
</div> </div>
</div> </div>

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

View 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

View File

@ -1,31 +1,80 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ company_name }} - Company Details{% endblock %} {% block title %}{{ company.name }} - Company Details{% endblock %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
<style> <style>
.badge-signed { .badge-signed {
background-color: #198754; background-color: #198754;
color: white; color: white;
} }
.badge-pending {
background-color: #ffc107; .badge-pending {
color: #212529; background-color: #ffc107;
} color: #212529;
</style> }
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-building me-2"></i>{{ company_name }}</h2> <h2><i class="bi bi-building me-2"></i>{{ company.name }}</h2>
<div> <div>
<a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to Companies</a> <a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to
<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> 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>
</div> </div>
<!-- Profile Completion Status -->
{% if not company.email or company.email == "" or not company.phone or company.phone == "" or not company.address or
company.address == "" %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<div class="d-flex align-items-center">
<div class="me-3">
<i class="bi bi-info-circle fs-4"></i>
</div>
<div class="flex-grow-1">
<h6 class="alert-heading mb-1">Complete Your Company Profile</h6>
<p class="mb-2">Your company profile is missing some essential information. Add the missing details to
improve your company's visibility and professionalism.</p>
<div class="d-flex gap-2">
<a href="/company/edit/{{ company.base_data.id }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-pencil me-1"></i>Complete Profile
</a>
<small class="text-muted align-self-center">
Missing:
{% if not company.email or company.email == "" %}Email{% endif %}
{% if not company.phone or company.phone == "" %}{% if not company.email or company.email == ""
%}, {% endif %}Phone{% endif %}
{% if not company.address or company.address == "" %}{% if not company.email or company.email ==
"" or not company.phone or company.phone == "" %}, {% endif %}Address{% endif %}
</small>
</div>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<!-- Success/Error Messages -->
{% if success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
@ -36,29 +85,49 @@
<table class="table table-borderless"> <table class="table table-borderless">
<tr> <tr>
<th style="width: 30%">Company Name:</th> <th style="width: 30%">Company Name:</th>
<td>{{ company_name }}</td> <td>{{ company.name }}</td>
</tr> </tr>
<tr> <tr>
<th>Type:</th> <th>Type:</th>
<td>{{ company_type }}</td>
</tr>
<tr>
<th>Registration Date:</th>
<td>{{ registration_date }}</td>
</tr>
<tr>
<th>Status:</th>
<td> <td>
{% if status == "Active" %} {% if company.business_type == "Starter" %}Startup FZC
<span class="badge bg-success">{{ status }}</span> {% elif company.business_type == "Global" %}Growth FZC
{% else %} {% elif company.business_type == "Coop" %}Cooperative FZC
<span class="badge bg-warning text-dark">{{ status }}</span> {% elif company.business_type == "Single" %}Single FZC
{% elif company.business_type == "Twin" %}Twin FZC
{% else %}{{ company.business_type }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Purpose:</th> <th>Registration Number:</th>
<td>{{ purpose }}</td> <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> </tr>
</table> </table>
</div> </div>
@ -67,28 +136,86 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing Information</h5> <h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Additional Information</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<table class="table table-borderless"> <table class="table table-borderless">
<tr> <tr>
<th style="width: 30%">Plan:</th> <th style="width: 30%">Email:</th>
<td>{{ plan }}</td> <td>
{% if company.email and company.email != "" %}
{{ company.email }}
{% else %}
<span class="text-muted">Not provided</span>
<a href="/company/edit/{{ company.base_data.id }}"
class="btn btn-sm btn-outline-secondary ms-2">
<i class="bi bi-plus-circle me-1"></i>Add
</a>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th>Next Billing:</th> <th>Phone:</th>
<td>{{ next_billing }}</td> <td>
{% if company.phone and company.phone != "" %}
{{ company.phone }}
{% else %}
<span class="text-muted">Not provided</span>
<a href="/company/edit/{{ company.base_data.id }}"
class="btn btn-sm btn-outline-secondary ms-2">
<i class="bi bi-plus-circle me-1"></i>Add
</a>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th>Payment Method:</th> <th>Website:</th>
<td>{{ payment_method }}</td> <td>
{% if company.website and company.website != "" %}
<a href="{{ company.website }}" target="_blank">{{ company.website }}</a>
{% else %}
<span class="text-muted">Not provided</span>
<a href="/company/edit/{{ company.base_data.id }}"
class="btn btn-sm btn-outline-secondary ms-2">
<i class="bi bi-plus-circle me-1"></i>Add
</a>
{% endif %}
</td>
</tr>
<tr>
<th>Address:</th>
<td>
{% if company.address and company.address != "" %}
{{ company.address }}
{% else %}
<span class="text-muted">Not provided</span>
<a href="/company/edit/{{ company.base_data.id }}"
class="btn btn-sm btn-outline-secondary ms-2">
<i class="bi bi-plus-circle me-1"></i>Add
</a>
{% endif %}
</td>
</tr>
<tr>
<th>Fiscal Year End:</th>
<td>
{% if company.fiscal_year_end and company.fiscal_year_end != "" %}
{{ company.fiscal_year_end }}
{% else %}
<span class="text-muted">Not specified</span>
<a href="/company/edit/{{ company.base_data.id }}"
class="btn btn-sm btn-outline-secondary ms-2">
<i class="bi bi-plus-circle me-1"></i>Add
</a>
{% endif %}
</td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
@ -104,12 +231,21 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if shareholders and shareholders|length > 0 %}
{% for shareholder in shareholders %} {% for shareholder in shareholders %}
<tr> <tr>
<td>{{ shareholder.0 }}</td> <td>{{ shareholder.name }}</td>
<td>{{ shareholder.1 }}</td> <td>{{ shareholder.percentage }}%</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %}
<tr>
<td colspan="2" class="text-center text-muted py-3">
<i class="bi bi-people me-1"></i>
No shareholders registered yet
</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -118,49 +254,91 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-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>
<div class="card-body"> <div class="card-body">
<table class="table table-striped"> {% if payment_info %}
<thead> <table class="table table-borderless">
<tr> <tr>
<th>Contract</th> <th style="width: 40%">Payment Status:</th>
<th>Status</th> <td>
<th>Action</th> {% if payment_info.status == "Succeeded" %}
</tr> <span class="badge bg-success">
</thead> <i class="bi bi-check-circle me-1"></i>Paid
<tbody> </span>
{% for contract in contracts %} {% elif payment_info.status == "Pending" %}
<tr> <span class="badge bg-warning">
<td>{{ contract.0 }}</td> <i class="bi bi-clock me-1"></i>Pending
<td> </span>
{% if contract.1 == "Signed" %} {% elif payment_info.status == "Failed" %}
<span class="badge bg-success">{{ contract.1 }}</span> <span class="badge bg-danger">
{% else %} <i class="bi bi-x-circle me-1"></i>Failed
<span class="badge bg-warning text-dark">{{ contract.1 }}</span> </span>
{% endif %} {% else %}
</td> <span class="badge bg-secondary">{{ payment_info.status }}</span>
<td> {% endif %}
<a href="/contracts/view/{{ contract.0 | lower | replace(from=' ', to='-') }}" class="btn btn-sm btn-outline-primary">View</a> </td>
</td> </tr>
</tr> <tr>
{% endfor %} <th>Payment Plan:</th>
</tbody> <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>
<code class="small">{{ payment_info.payment_intent_id }}</code>
</td>
</tr>
{% endif %}
</table> </table>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-credit-card me-1"></i>
No payment information available
<br>
<small class="text-muted">This company may have been created before payment integration</small>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Actions</h5> <h5 class="mb-0"><i class="bi bi-gear me-2"></i>Actions</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/company/edit/{{ company_id }}" class="btn btn-outline-primary"><i class="bi bi-pencil me-1"></i>Edit Company</a> <a href="/company/edit/{{ company.base_data.id }}" class="btn btn-outline-primary"><i
<a href="/company/documents/{{ company_id }}" class="btn btn-outline-secondary"><i class="bi bi-file-earmark me-1"></i>Manage Documents</a> class="bi bi-pencil me-1"></i>Edit Company</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/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> </div>
</div> </div>
@ -168,10 +346,10 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
console.log('Company view page loaded'); console.log('Company view page loaded');
}); });
</script> </script>
{% endblock %} {% endblock %}

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

View 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

View File

@ -36,27 +36,41 @@
<label for="status" class="form-label">Status</label> <label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status"> <select class="form-select" id="status" name="status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="Draft">Draft</option> <option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>Draft
<option value="PendingSignatures">Pending Signatures</option> </option>
<option value="Signed">Signed</option> <option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
<option value="Expired">Expired</option> %}selected{% endif %}>Pending Signatures</option>
<option value="Cancelled">Cancelled</option> <option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
Signed</option>
<option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif %}>
Expired</option>
<option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{% endif
%}>Cancelled</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="type" class="form-label">Contract Type</label> <label for="type" class="form-label">Contract Type</label>
<select class="form-select" id="type" name="type"> <select class="form-select" id="type" name="type">
<option value="">All Types</option> <option value="">All Types</option>
<option value="Service">Service Agreement</option> <option value="Service Agreement" {% if current_type_filter=="Service Agreement"
<option value="Employment">Employment Contract</option> %}selected{% endif %}>Service Agreement</option>
<option value="NDA">Non-Disclosure Agreement</option> <option value="Employment Contract" {% if current_type_filter=="Employment Contract"
<option value="SLA">Service Level Agreement</option> %}selected{% endif %}>Employment Contract</option>
<option value="Other">Other</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> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="search" class="form-label">Search</label> <label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search" 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>
<div class="col-md-3 d-flex align-items-end"> <div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Apply Filters</button> <button type="submit" class="btn btn-primary w-100">Apply Filters</button>
@ -98,7 +112,8 @@
</td> </td>
<td>{{ contract.contract_type }}</td> <td>{{ contract.contract_type }}</td>
<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 }} {{ contract.status }}
</span> </span>
</td> </td>
@ -112,9 +127,14 @@
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
{% if contract.status == 'Draft' %} {% if contract.status == 'Draft' %}
<a href="/contracts/{{ contract.id }}/edit" 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> <i class="bi bi-pencil"></i>
</a> </a>
<button class="btn btn-sm btn-outline-danger"
onclick="deleteContract({{ contract.id }}, '{{ contract.title | replace(from="'", to="\\'") }}')">
<i class="bi bi-trash"></i>
</button>
{% endif %} {% endif %}
</div> </div>
</td> </td>
@ -137,4 +157,70 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone!
</div>
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
<p>This will permanently remove the contract and all its associated data.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash me-1"></i> Delete Contract
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
console.log('Contracts list scripts loading...');
// Delete function using Bootstrap modal
window.deleteContract = function (contractId, contractTitle) {
console.log('Delete function called:', contractId, contractTitle);
// Set the contract title in the modal
document.getElementById('contractTitle').textContent = contractTitle;
// Store the contract ID for later use
window.currentDeleteContractId = contractId;
// Show the modal
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
deleteModal.show();
};
console.log('deleteContract function defined:', typeof window.deleteContract);
document.addEventListener('DOMContentLoaded', function () {
// Handle confirm delete button click
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
console.log('User confirmed deletion, submitting form...');
// Create and submit form
const form = document.createElement('form');
form.method = 'POST';
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
});
});
</script>
{% endblock %}

View File

@ -25,12 +25,14 @@
<div class="card-body"> <div class="card-body">
<form action="/contracts/create" method="post"> <form action="/contracts/create" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Contract Title <span 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> <input type="text" class="form-control" id="title" name="title" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="contract_type" class="form-label">Contract Type <span 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> <select class="form-select" id="contract_type" name="contract_type" required>
<option value="" selected disabled>Select a contract type</option> <option value="" selected disabled>Select a contract type</option>
{% for type in contract_types %} {% for type in contract_types %}
@ -38,28 +40,59 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description <span class="text-danger">*</span></label> <label for="description" class="form-label">Description <span
<textarea class="form-control" id="description" name="description" rows="3" required></textarea> class="text-danger">*</span></label>
<textarea class="form-control" id="description" name="description" rows="3"
required></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="content" class="form-label">Contract Content</label> <label for="content" class="form-label">Contract Content (Markdown)</label>
<textarea class="form-control" id="content" name="content" rows="10"></textarea> <textarea class="form-control" id="content" name="content" rows="10" placeholder="# Contract Title
<div class="form-text">You can leave this blank and add content later.</div>
## 1. Introduction
This contract outlines the terms and conditions...
## 2. Scope of Work
- Task 1
- Task 2
- Task 3
## 3. Payment Terms
Payment will be made according to the following schedule:
| Milestone | Amount | Due Date |
|-----------|--------|----------|
| Start | $1,000 | Upon signing |
| Completion | $2,000 | Upon delivery |
## 4. Terms and Conditions
**Important:** All parties must agree to these terms.
> This is a blockquote for important notices.
---
*For questions, contact [support@example.com](mailto:support@example.com)*"></textarea>
<div class="form-text">
<strong>Markdown Support:</strong> You can use markdown formatting including headers
(#), lists (-), tables (|), bold (**text**), italic (*text*), links, and more.
<a href="/editor" target="_blank">Open Markdown Editor</a> for a live preview.
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="effective_date" class="form-label">Effective Date</label> <label for="effective_date" class="form-label">Effective Date</label>
<input type="date" class="form-control" id="effective_date" name="effective_date"> <input type="date" class="form-control" id="effective_date" name="effective_date">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="expiration_date" class="form-label">Expiration Date</label> <label for="expiration_date" class="form-label">Expiration Date</label>
<input type="date" class="form-control" id="expiration_date" name="expiration_date"> <input type="date" class="form-control" id="expiration_date" name="expiration_date">
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/contracts" class="btn btn-outline-secondary me-md-2">Cancel</a> <a href="/contracts" class="btn btn-outline-secondary me-md-2">Cancel</a>
<button type="submit" class="btn btn-primary">Create Contract</button> <button type="submit" class="btn btn-primary">Create Contract</button>
@ -68,14 +101,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Tips</h5> <h5 class="mb-0">Tips</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p>Creating a new contract is just the first step. After creating the contract, you'll be able to:</p> <p>Creating a new contract is just the first step. After creating the contract, you'll be able to:
</p>
<ul> <ul>
<li>Add signers who need to approve the contract</li> <li>Add signers who need to approve the contract</li>
<li>Edit the contract content</li> <li>Edit the contract content</li>
@ -85,7 +119,7 @@
<p>The contract will be in <strong>Draft</strong> status until you send it for signatures.</p> <p>The contract will be in <strong>Draft</strong> status until you send it for signatures.</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Contract Templates</h5> <h5 class="mb-0">Contract Templates</h5>
@ -93,16 +127,20 @@
<div class="card-body"> <div class="card-body">
<p>You can use one of our pre-defined templates to get started quickly:</p> <p>You can use one of our pre-defined templates to get started quickly:</p>
<div class="list-group"> <div class="list-group">
<button type="button" class="list-group-item list-group-item-action" onclick="loadTemplate('nda')"> <button type="button" class="list-group-item list-group-item-action"
onclick="loadTemplate('nda')">
Non-Disclosure Agreement Non-Disclosure Agreement
</button> </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 Service Agreement
</button> </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 Employment Contract
</button> </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 Service Level Agreement
</button> </button>
</div> </div>
@ -121,19 +159,101 @@
let description = ''; let description = '';
let content = ''; let content = '';
let contractType = ''; let contractType = '';
switch(type) { switch (type) {
case 'nda': case 'nda':
title = 'Non-Disclosure Agreement'; title = 'Non-Disclosure Agreement';
description = 'Standard NDA for protecting confidential information'; description = 'Standard NDA for protecting confidential information';
contractType = 'Non-Disclosure Agreement'; contractType = 'Non-Disclosure Agreement';
content = '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; break;
case 'service': case 'service':
title = 'Service Agreement'; title = 'Service Agreement';
description = 'Agreement for providing professional services'; description = 'Agreement for providing professional services';
contractType = 'Service Agreement'; contractType = 'Service Agreement';
content = '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; break;
case 'employment': case 'employment':
title = 'Employment Contract'; title = 'Employment Contract';
@ -148,19 +268,19 @@
content = 'This Service Level Agreement (the "SLA") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Service Levels\n2. Performance Metrics\n3. Remedies for Failure\n...'; content = 'This Service Level Agreement (the "SLA") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Service Levels\n2. Performance Metrics\n3. Remedies for Failure\n...';
break; break;
} }
document.getElementById('title').value = title; document.getElementById('title').value = title;
document.getElementById('description').value = description; document.getElementById('description').value = description;
document.getElementById('content').value = content; document.getElementById('content').value = content;
// Set the select option // Set the select option
const selectElement = document.getElementById('contract_type'); const selectElement = document.getElementById('contract_type');
for(let i = 0; i < selectElement.options.length; i++) { for (let i = 0; i < selectElement.options.length; i++) {
if(selectElement.options[i].text === contractType) { if (selectElement.options[i].text === contractType) {
selectElement.selectedIndex = i; selectElement.selectedIndex = i;
break; break;
} }
} }
} }
</script> </script>
{% endblock %} {% endblock %}

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

View File

@ -11,58 +11,108 @@
</div> </div>
</div> </div>
{% if stats.total_contracts > 0 %}
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-primary h-100"> <div class="card text-white bg-primary h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Total</h5> <h5 class="card-title mb-1">Total</h5>
<p class="display-4">{{ stats.total_contracts }}</p> <h3 class="mb-0">{{ stats.total_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-secondary h-100"> <div class="card text-white bg-secondary h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Draft</h5> <h5 class="card-title mb-1">Draft</h5>
<p class="display-4">{{ stats.draft_contracts }}</p> <h3 class="mb-0">{{ stats.draft_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-warning h-100"> <div class="card text-white bg-warning h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Pending</h5> <h5 class="card-title mb-1">Pending</h5>
<p class="display-4">{{ stats.pending_signature_contracts }}</p> <h3 class="mb-0">{{ stats.pending_signature_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-success h-100"> <div class="card text-white bg-success h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Signed</h5> <h5 class="card-title mb-1">Signed</h5>
<p class="display-4">{{ stats.signed_contracts }}</p> <h3 class="mb-0">{{ stats.signed_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-danger h-100"> <div class="card text-white bg-danger h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Expired</h5> <h5 class="card-title mb-1">Expired</h5>
<p class="display-4">{{ stats.expired_contracts }}</p> <h3 class="mb-0">{{ stats.expired_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card text-white bg-dark h-100"> <div class="card text-white bg-dark h-100">
<div class="card-body"> <div class="card-body text-center">
<h5 class="card-title">Cancelled</h5> <h5 class="card-title mb-1">Cancelled</h5>
<p class="display-4">{{ stats.cancelled_contracts }}</p> <h3 class="mb-0">{{ stats.cancelled_contracts }}</h3>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% else %}
<!-- Empty State Welcome Message -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
</div>
<h3 class="text-muted mb-3">Welcome to Contract Management</h3>
<p class="lead text-muted mb-4">
You haven't created any contracts yet. Get started by creating your first contract to manage
legal agreements and track signatures.
</p>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100 border-primary">
<div class="card-body text-center">
<i class="bi bi-plus-circle text-primary fs-2 mb-2"></i>
<h6 class="card-title">Create Contract</h6>
<p class="card-text small text-muted">Start with a new legal agreement</p>
<a href="/contracts/create" class="btn btn-primary btn-sm">Get Started</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-success">
<div class="card-body text-center">
<i class="bi bi-question-circle text-success fs-2 mb-2"></i>
<h6 class="card-title">Need Help?</h6>
<p class="card-text small text-muted">Learn how to use the system</p>
<button class="btn btn-outline-success btn-sm"
onclick="showHelpModal()">Learn More</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if stats.total_contracts > 0 %}
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
@ -86,6 +136,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Pending Signature Contracts --> <!-- Pending Signature Contracts -->
{% if pending_signature_contracts and pending_signature_contracts | length > 0 %} {% if pending_signature_contracts and pending_signature_contracts | length > 0 %}
@ -168,7 +219,8 @@
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary"> <a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
<a href="/contracts/{{ contract.id }}/edit" 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> <i class="bi bi-pencil"></i>
</a> </a>
</div> </div>
@ -183,5 +235,115 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Recent Activity Section -->
{% if recent_activities and recent_activities | length > 0 %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Recent Activity</h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for activity in recent_activities %}
<div class="list-group-item border-start-0 border-end-0 py-3">
<div class="d-flex">
<div class="me-3">
<i class="{{ activity.icon }} fs-5"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ activity.user }}</strong>
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M")
}}</small>
</div>
<p class="mb-1">{{ activity.description }}</p>
<small class="text-muted">{{ activity.title }}</small>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-footer text-center">
<a href="/contracts/activities" class="btn btn-sm btn-outline-info">See More Activities</a>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Help Modal -->
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="helpModalLabel">
<i class="bi bi-question-circle me-2"></i>Getting Started with Contract Management
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-1-circle text-primary me-2"></i>Create Your First Contract</h6>
<p class="small text-muted mb-3">
Start by creating a new contract. Choose from various contract types like Service
Agreements, NDAs, or Employment Contracts.
</p>
<h6><i class="bi bi-2-circle text-primary me-2"></i>Add Contract Details</h6>
<p class="small text-muted mb-3">
Fill in the contract title, description, and terms. You can use Markdown formatting for rich
text content.
</p>
<h6><i class="bi bi-3-circle text-primary me-2"></i>Add Signers</h6>
<p class="small text-muted mb-3">
Add people who need to sign the contract. Each signer will receive a unique signing link.
</p>
</div>
<div class="col-md-6">
<h6><i class="bi bi-4-circle text-success me-2"></i>Send for Signatures</h6>
<p class="small text-muted mb-3">
Once your contract is ready, send it for signatures. Signers can review and sign digitally.
</p>
<h6><i class="bi bi-5-circle text-success me-2"></i>Track Progress</h6>
<p class="small text-muted mb-3">
Monitor signature progress, send reminders, and view signed documents from the dashboard.
</p>
<h6><i class="bi bi-6-circle text-success me-2"></i>Manage Contracts</h6>
<p class="small text-muted mb-3">
View all contracts, filter by status, and manage the complete contract lifecycle.
</p>
</div>
</div>
<div class="alert alert-info mt-3">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> You can save contracts as drafts and come back to edit them later before
sending for signatures.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a href="/contracts/create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Create My First Contract
</a>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
function showHelpModal() {
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
helpModal.show();
}
</script>
{% endblock %}

View File

@ -13,7 +13,10 @@
</ol> </ol>
</nav> </nav>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h1 class="display-5 mb-0">My Contracts</h1> <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"> <div class="btn-group">
<a href="/contracts/create" class="btn btn-primary"> <a href="/contracts/create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Create New Contract <i class="bi bi-plus-circle me-1"></i> Create New Contract
@ -23,41 +26,136 @@
</div> </div>
</div> </div>
<!-- Quick Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Total Contracts</h6>
<h3 class="mb-0">{{ contracts|length }}</h3>
</div>
<div class="align-self-center">
<i class="bi bi-file-earmark-text fs-2"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Pending Signatures</h6>
<h3 class="mb-0" id="pending-count">0</h3>
</div>
<div class="align-self-center">
<i class="bi bi-clock fs-2"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Signed</h6>
<h3 class="mb-0" id="signed-count">0</h3>
</div>
<div class="align-self-center">
<i class="bi bi-check-circle fs-2"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-secondary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Drafts</h6>
<h3 class="mb-0" id="draft-count">0</h3>
</div>
<div class="align-self-center">
<i class="bi bi-pencil fs-2"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters --> <!-- Filters -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Filters</h5> <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>
<div class="card-body"> <div class="collapse show" id="filtersCollapse">
<form action="/contracts/my-contracts" method="get" class="row g-3"> <div class="card-body">
<div class="col-md-4"> <form action="/contracts/my-contracts" method="get" class="row g-3">
<label for="status" class="form-label">Status</label> <div class="col-md-3">
<select class="form-select" id="status" name="status"> <label for="status" class="form-label">Status</label>
<option value="">All Statuses</option> <select class="form-select" id="status" name="status">
<option value="Draft">Draft</option> <option value="">All Statuses</option>
<option value="PendingSignatures">Pending Signatures</option> <option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>
<option value="Signed">Signed</option> Draft</option>
<option value="Expired">Expired</option> <option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
<option value="Cancelled">Cancelled</option> %}selected{% endif %}>Pending Signatures</option>
</select> <option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
</div> Signed</option>
<div class="col-md-4"> <option value="Active" {% if current_status_filter=="Active" %}selected{% endif %}>
<label for="type" class="form-label">Contract Type</label> Active</option>
<select class="form-select" id="type" name="type"> <option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif
<option value="">All Types</option> %}>Expired</option>
<option value="Service">Service Agreement</option> <option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{%
<option value="Employment">Employment Contract</option> endif %}>Cancelled</option>
<option value="NDA">Non-Disclosure Agreement</option> </select>
<option value="SLA">Service Level Agreement</option> </div>
<option value="Other">Other</option> <div class="col-md-3">
</select> <label for="type" class="form-label">Contract Type</label>
</div> <select class="form-select" id="type" name="type">
<div class="col-md-4 d-flex align-items-end"> <option value="">All Types</option>
<button type="submit" class="btn btn-primary w-100">Apply Filters</button> <option value="Service Agreement" {% if current_type_filter=="Service Agreement"
</div> %}selected{% endif %}>Service Agreement</option>
</form> <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-3">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Search by title or description"
value="{{ current_search_filter | default(value='') }}">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -67,48 +165,122 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">My Contracts</h5> <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>
<div class="card-body"> <div class="card-body">
{% if contracts and contracts | length > 0 %} {% if contracts and contracts | length > 0 %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover align-middle">
<thead> <thead class="table-light">
<tr> <tr>
<th>Contract Title</th> <th scope="col">
<th>Type</th> <div class="d-flex align-items-center">
<th>Status</th> Contract Title
<th>Signers</th> <i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
<th>Created</th> onclick="sortTable(0)"></i>
<th>Updated</th> </div>
<th>Actions</th> </th>
<th scope="col">Type</th>
<th scope="col">Status</th>
<th scope="col">Progress</th>
<th scope="col">
<div class="d-flex align-items-center">
Created
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
onclick="sortTable(4)"></i>
</div>
</th>
<th scope="col">Last Updated</th>
<th scope="col" class="text-center">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for contract in contracts %} {% for contract in contracts %}
<tr> <tr
class="{% if contract.status == 'Expired' %}table-danger{% elif contract.status == 'PendingSignatures' %}table-warning{% elif contract.status == 'Signed' %}table-success{% endif %}">
<td> <td>
<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>
<td>{{ contract.contract_type }}</td>
<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 }} {{ contract.status }}
</span> </span>
</td> </td>
<td>{{ contract.signed_signers }}/{{ contract.signers|length }}</td>
<td>{{ contract.created_at | date(format="%Y-%m-%d") }}</td>
<td>{{ contract.updated_at | date(format="%Y-%m-%d") }}</td>
<td> <td>
{% if contract.signers|length > 0 %}
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 60px; height: 8px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: 0%" data-contract-id="{{ contract.id }}">
</div>
</div>
<small class="text-muted">{{ contract.signed_signers }}/{{
contract.signers|length }}</small>
</div>
{% else %}
<span class="text-muted small">No signers</span>
{% endif %}
</td>
<td>
<div class="small">
{{ contract.created_at | date(format="%b %d, %Y") }}
<div class="text-muted">{{ contract.created_at | date(format="%I:%M %p") }}
</div>
</div>
</td>
<td>
<div class="small">
{{ contract.updated_at | date(format="%b %d, %Y") }}
<div class="text-muted">{{ contract.updated_at | date(format="%I:%M %p") }}
</div>
</div>
</td>
<td class="text-center">
<div class="btn-group"> <div class="btn-group">
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary"> <a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary"
title="View Details">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
{% if contract.status == 'Draft' %} {% if contract.status == 'Draft' %}
<a href="/contracts/{{ contract.id }}/edit" 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> <i class="bi bi-pencil"></i>
</a> </a>
<button class="btn btn-sm btn-outline-danger" title="Delete Contract"
onclick="deleteContract('{{ contract.id }}', '{{ contract.title }}')">
<i class="bi bi-trash"></i>
</button>
{% endif %} {% endif %}
</div> </div>
</td> </td>
@ -119,11 +291,20 @@
</div> </div>
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-file-earmark-text fs-1 text-muted"></i> <div class="mb-4">
<p class="mt-3 text-muted">You don't have any contracts yet</p> <i class="bi bi-file-earmark-text display-1 text-muted"></i>
<a href="/contracts/create" class="btn btn-primary mt-2"> </div>
<i class="bi bi-plus-circle me-1"></i> Create Your First Contract <h4 class="text-muted mb-3">No Contracts Found</h4>
</a> <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> </div>
{% endif %} {% endif %}
</div> </div>
@ -131,4 +312,166 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone!
</div>
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
<p>This will permanently remove:</p>
<ul>
<li>The contract document and all its content</li>
<li>All signers and their signatures</li>
<li>All revisions and history</li>
<li>Any associated files or attachments</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash me-1"></i> Delete Contract
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
console.log('My Contracts page scripts loading...');
// Delete contract functionality using Bootstrap modal
window.deleteContract = function (contractId, contractTitle) {
console.log('Delete contract called:', contractId, contractTitle);
// Set the contract title in the modal
document.getElementById('contractTitle').textContent = contractTitle;
// Store the contract ID for later use
window.currentDeleteContractId = contractId;
// Show the modal
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
deleteModal.show();
};
// Simple table sorting functionality
window.sortTable = function (columnIndex) {
console.log('Sorting table by column:', columnIndex);
const table = document.querySelector('.table tbody');
const rows = Array.from(table.querySelectorAll('tr'));
// Toggle sort direction
const isAscending = table.dataset.sortDirection !== 'asc';
table.dataset.sortDirection = isAscending ? 'asc' : 'desc';
rows.sort((a, b) => {
const aText = a.cells[columnIndex].textContent.trim();
const bText = b.cells[columnIndex].textContent.trim();
// Handle date sorting for created/updated columns
if (columnIndex === 4 || columnIndex === 5) {
const aDate = new Date(aText);
const bDate = new Date(bText);
return isAscending ? aDate - bDate : bDate - aDate;
}
// Handle text sorting
return isAscending ? aText.localeCompare(bText) : bText.localeCompare(aText);
});
// Re-append sorted rows
rows.forEach(row => table.appendChild(row));
// Update sort indicators
document.querySelectorAll('.bi-arrow-down-up').forEach(icon => {
icon.className = 'bi bi-arrow-down-up ms-1 text-muted';
});
const currentIcon = document.querySelectorAll('.bi-arrow-down-up')[columnIndex === 4 ? 1 : 0];
if (currentIcon) {
currentIcon.className = `bi ${isAscending ? 'bi-arrow-up' : 'bi-arrow-down'} ms-1 text-primary`;
}
};
// Calculate statistics and update progress bars
function updateStatistics() {
const rows = document.querySelectorAll('.table tbody tr');
let totalContracts = rows.length;
let pendingCount = 0;
let signedCount = 0;
let draftCount = 0;
rows.forEach(row => {
const statusCell = row.cells[2];
const statusText = statusCell.textContent.trim();
if (statusText.includes('PendingSignatures') || statusText.includes('Pending')) {
pendingCount++;
} else if (statusText.includes('Signed')) {
signedCount++;
} else if (statusText.includes('Draft')) {
draftCount++;
}
// Update progress bars
const progressBar = row.querySelector('.progress-bar');
if (progressBar) {
const signersText = row.cells[3].textContent.trim();
if (signersText !== 'No signers') {
const [signed, total] = signersText.split('/').map(n => parseInt(n));
const percentage = total > 0 ? Math.round((signed / total) * 100) : 0;
progressBar.style.width = percentage + '%';
}
}
});
// Update statistics cards
document.getElementById('pending-count').textContent = pendingCount;
document.getElementById('signed-count').textContent = signedCount;
document.getElementById('draft-count').textContent = draftCount;
// Update total count badge
const badge = document.querySelector('.badge.bg-primary');
if (badge) {
badge.textContent = totalContracts;
}
}
document.addEventListener('DOMContentLoaded', function () {
// Calculate initial statistics
updateStatistics();
// Handle confirm delete button click
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
console.log('User confirmed deletion, submitting form...');
// Create and submit form
const form = document.createElement('form');
form.method = 'POST';
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
});
});
console.log('My Contracts page scripts loaded successfully');
</script>
{% endblock %}

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

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

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

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

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

View File

@ -4,69 +4,74 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-4"> <!-- Header -->
<div class="col-12"> {% include "governance/_header.html" %}
<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>
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="row mb-4"> {% include "governance/_tabs.html" %}
<!-- Info Alert -->
<div class="row">
<div class="col-12"> <div class="col-12">
<ul class="nav nav-tabs"> <div class="alert alert-info alert-dismissible fade show">
<li class="nav-item"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<a class="nav-link" href="/governance">Dashboard</a> <h5><i class="bi bi-info-circle"></i> About Creating Proposals</h5>
</li> <p>Creating a proposal is an important step in our community governance process. Well-crafted proposals
<li class="nav-item"> clearly state the problem, solution, and implementation details. The community will review and vote
<a class="nav-link" href="/governance/proposals">All Proposals</a> on your proposal, so be thorough and thoughtful in your submission.</p>
</li> <div class="mt-2">
<li class="nav-item"> <a href="/governance/proposal-templates" class="btn btn-sm btn-outline-primary"><i
<a class="nav-link" href="/governance/my-votes">My Votes</a> class="bi bi-file-earmark-text"></i> Proposal Templates</a>
</li> </div>
<li class="nav-item"> </div>
<a class="nav-link active" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div> </div>
</div> </div>
<!-- Proposal Form --> <!-- Proposal Form and Guidelines in Flex Layout -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8 mx-auto"> <!-- Proposal Form Column -->
<div class="card"> <div class="col-lg-8">
<div class="card h-100">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">New Proposal</h5> <h5 class="mb-0">New Proposal</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="/governance/create" method="post"> <form action="/governance/create" method="post" id="proposalForm" novalidate>
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Title</label> <label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" required <input type="text" class="form-control" id="title" name="title" required minlength="5"
placeholder="Enter a clear, concise title for your proposal"> 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 class="form-text">Make it descriptive and specific</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="6" required <textarea class="form-control" id="description" name="description" rows="8" required
placeholder="Provide a detailed description of your proposal..."></textarea> 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 class="form-text">Explain the purpose, benefits, and implementation details</div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="voting_start_date" class="form-label">Voting Start Date</label> <label for="voting_start_date" class="form-label">Voting Start Date</label>
<input type="date" class="form-control" id="voting_start_date" name="voting_start_date"> <input type="date" class="form-control" id="voting_start_date" name="voting_start_date">
<div class="invalid-feedback" id="start_date_feedback">Please select a valid start date.
</div>
<div class="form-text">When should voting begin?</div> <div class="form-text">When should voting begin?</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="voting_end_date" class="form-label">Voting End Date</label> <label for="voting_end_date" class="form-label">Voting End Date</label>
<input type="date" class="form-control" id="voting_end_date" name="voting_end_date"> <input type="date" class="form-control" id="voting_end_date" name="voting_end_date">
<div class="invalid-feedback" id="end_date_feedback">End date must be after start date.
</div>
<div class="form-text">When should voting end?</div> <div class="form-text">When should voting end?</div>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="draft" name="draft" value="true"> <input class="form-check-input" type="checkbox" id="draft" name="draft" value="true">
@ -75,7 +80,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Submit Proposal</button> <button type="submit" class="btn btn-primary">Submit Proposal</button>
<a href="/governance" class="btn btn-outline-secondary">Cancel</a> <a href="/governance" class="btn btn-outline-secondary">Cancel</a>
@ -84,12 +89,10 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Guidelines Column -->
<!-- Guidelines Card --> <div class="col-lg-4">
<div class="row mb-4"> <div class="card bg-light h-100">
<div class="col-md-8 mx-auto">
<div class="card bg-light">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Proposal Guidelines</h5> <h5 class="mb-0">Proposal Guidelines</h5>
</div> </div>
@ -116,4 +119,111 @@
</div> </div>
</div> </div>
</div> </div>
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('proposalForm');
const startDateInput = document.getElementById('voting_start_date');
const endDateInput = document.getElementById('voting_end_date');
const startDateFeedback = document.getElementById('start_date_feedback');
const endDateFeedback = document.getElementById('end_date_feedback');
// Set default dates
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
// Format dates for input fields
const formatDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Set default values
startDateInput.value = formatDate(tomorrow);
endDateInput.value = formatDate(nextWeek);
// Validate dates when they change
function validateDates() {
const startDate = new Date(startDateInput.value);
const endDate = new Date(endDateInput.value);
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0); // Reset time to start of day
let startValid = true;
let endValid = true;
// Validate start date is not in the past
if (startDate < currentDate) {
startDateInput.classList.add('is-invalid');
startDateFeedback.textContent = 'Start date cannot be in the past.';
startValid = false;
} else {
startDateInput.classList.remove('is-invalid');
}
// Validate end date is after start date
if (endDate < startDate) {
endDateInput.classList.add('is-invalid');
endDateFeedback.textContent = 'End date must be after start date.';
endValid = false;
} else {
endDateInput.classList.remove('is-invalid');
}
return startValid && endValid;
}
// Validate on input
startDateInput.addEventListener('change', validateDates);
endDateInput.addEventListener('change', validateDates);
// Form submission validation
form.addEventListener('submit', function (event) {
let formValid = true;
// Validate required fields
const requiredFields = form.querySelectorAll('[required]');
requiredFields.forEach(field => {
if (!field.value.trim()) {
field.classList.add('is-invalid');
formValid = false;
} else {
field.classList.remove('is-invalid');
}
// Check minlength if specified
if (field.minLength && field.value.length < field.minLength) {
field.classList.add('is-invalid');
formValid = false;
}
});
// Validate dates
const datesValid = validateDates();
formValid = formValid && datesValid;
// If form is not valid, prevent submission
if (!formValid) {
event.preventDefault();
// Scroll to the first invalid element
const firstInvalid = form.querySelector('.is-invalid');
if (firstInvalid) {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalid.focus();
}
}
});
// Initial validation
validateDates();
});
</script>
{% endblock %} {% endblock %}
{% endblock %}

View File

@ -3,170 +3,192 @@
{% block title %}Governance Dashboard{% endblock %} {% block title %}Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Navigation Tabs --> <!-- Header -->
<div class="row mb-3"> {% include "governance/_header.html" %}
<div class="col-12">
<ul class="nav nav-tabs"> <!-- Navigation Tabs -->
<li class="nav-item"> {% include "governance/_tabs.html" %}
<a class="nav-link active" href="/governance">Dashboard</a>
</li> <!-- Info Alert -->
<li class="nav-item"> <div class="row mb-2">
<a class="nav-link" href="/governance/proposals">All Proposals</a> <div class="col-12">
</li> <div class="alert alert-info alert-dismissible fade show">
<li class="nav-item"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<a class="nav-link" href="/governance/my-votes">My Votes</a> <h5><i class="bi bi-info-circle"></i> About Governance</h5>
</li> <p>The governance system allows token holders to participate in decision-making processes by voting on
<li class="nav-item"> proposals that affect the platform's future. Create proposals, cast votes, and help shape the direction
<a class="nav-link" href="/governance/create">Create Proposal</a> of our decentralized ecosystem.</p>
</li> <div class="mt-2">
</ul> <a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i>
Read Documentation</a>
</div>
</div> </div>
</div> </div>
</div>
<!-- Info Alert --> <!-- Dashboard Main Content -->
<div class="row mb-2"> <div class="row mb-3">
<div class="col-12"> <!-- Voting Pane for Nearest Deadline Proposal -->
<div class="alert alert-info alert-dismissible fade show"> <div class="col-lg-8 mb-4 mb-lg-0">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> {% if nearest_proposal is defined %}
<h5><i class="bi bi-info-circle"></i> About Governance</h5> <div class="card h-100">
<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="card-header d-flex justify-content-between align-items-center">
<div class="mt-2"> <h5 class="mb-0">Urgent: Voting Closes Soon</h5>
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a> <div>
<span class="badge bg-warning text-dark me-2">Ends: {{ nearest_proposal.vote_end_date |
date(format="%Y-%m-%d") }}</span>
<a href="/governance/proposals/{{ nearest_proposal.base_data.id }}"
class="btn btn-sm btn-outline-primary">View Full Proposal</a>
</div>
</div>
<div class="card-body">
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
<div class="mb-4">
<p>{{ nearest_proposal.description }}</p>
</div>
{% set yes_percent = 0 %}
{% set no_percent = 0 %}
{% set abstain_percent = 0 %}
{% set total_votes = 0 %}
{% if nearest_proposal_results is defined %}
{% if nearest_proposal_results.total_votes > 0 %}
{% set yes_percent = (nearest_proposal_results.yes_count * 100 / nearest_proposal_results.total_votes) |
int %}
{% set no_percent = (nearest_proposal_results.no_count * 100 / nearest_proposal_results.total_votes) |
int %}
{% set abstain_percent = (nearest_proposal_results.abstain_count * 100 /
nearest_proposal_results.total_votes) |
int %}
{% endif %}
{% set total_votes = nearest_proposal_results.total_votes %}
{% endif %}
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100">{{ yes_percent }}% Yes
</div> </div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100">{{ no_percent }}% No
</div>
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"
aria-valuenow="{{ abstain_percent }}" aria-valuemin="0" aria-valuemax="100">{{ abstain_percent
}}% Abstain
</div>
</div>
<div class="d-flex justify-content-between text-muted small mb-4">
<span>{{ total_votes }} votes cast</span>
<span>Quorum: {% if total_votes >= 20 %}75% reached{% else %}Not reached{% endif %}</span>
</div>
<div class="mb-4">
<h5 class="mb-3">Cast Your Vote</h5>
<form action="/governance/proposals/{{ nearest_proposal.base_data.id }}/vote" method="post">
<div class="mb-3">
<input type="text" class="form-control" name="comment"
placeholder="Optional comment on your vote" aria-label="Vote comment">
</div>
<div class="d-flex justify-content-between">
<button type="submit" name="vote_type" value="Yes" class="btn btn-success">Vote Yes</button>
<button type="submit" name="vote_type" value="No" class="btn btn-danger">Vote No</button>
<button type="submit" name="vote_type" value="Abstain"
class="btn btn-secondary">Abstain</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
{% else %}
<!-- Dashboard Main Content --> <div class="card h-100">
<div class="row mb-3"> <div class="card-body text-center py-5">
<!-- Voting Pane for Nearest Deadline Proposal --> <i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<div class="col-lg-8 mb-4 mb-lg-0"> <h5>No active proposals requiring votes</h5>
{% if nearest_proposal is defined %} <p class="text-muted">When new proposals are created, they will appear here for voting.</p>
<div class="card h-100"> <a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
<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>
</div>
</div>
<div class="card-body">
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
<div class="mb-4">
<p>{{ nearest_proposal.description }}</p>
</div>
<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>
<div class="d-flex justify-content-between text-muted small mb-4">
<span>26 votes cast</span>
<span>Quorum: 75% reached</span>
</div>
<div class="mb-4">
<h5 class="mb-3">Cast Your Vote</h5>
<form>
<div class="mb-3">
<input type="text" class="form-control" placeholder="Optional comment on your vote" aria-label="Vote comment">
</div>
<div 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>
</div>
</form>
</div>
</div>
</div> </div>
{% else %}
<div class="card h-100">
<div class="card-body text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>No active proposals requiring votes</h5>
<p class="text-muted">When new proposals are created, they will appear here for voting.</p>
<a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
</div>
</div>
{% endif %}
</div> </div>
{% endif %}
<!-- Recent Activity Timeline --> </div>
<div class="col-lg-4">
<div class="card h-100"> <!-- Recent Activity Timeline -->
<div class="card-header"> <div class="col-lg-4">
<h5 class="mb-0">Recent Activity</h5> <div class="card h-100">
</div> <div class="card-header">
<div class="card-body p-0"> <h5 class="mb-0">Recent Activity</h5>
<div class="list-group list-group-flush"> </div>
{% for activity in recent_activity %} <div class="card-body p-0">
<div class="list-group-item border-start-0 border-end-0 py-3"> <div class="list-group list-group-flush">
<div class="d-flex"> {% for activity in recent_activity %}
<div class="me-3"> <div class="list-group-item border-start-0 border-end-0 py-3">
<i class="bi {{ activity.icon }} fs-4"></i> <div class="d-flex">
</div> <div class="me-3">
<div> <i class="bi {{ activity.icon }} fs-4"></i>
<div class="d-flex justify-content-between align-items-center"> </div>
<strong>{{ activity.user }}</strong> <div>
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M") }}</small> <div class="d-flex justify-content-between align-items-center">
</div> <strong>{{ activity.user }}</strong>
<p class="mb-1">{{ activity.action }} on <a href="/governance/proposals/{{ activity.proposal_id }}">{{ activity.proposal_title }}</a></p> <small class="text-muted">{{ activity.created_at | date(format="%H:%M") }}</small>
{% if activity.type == "comment" and activity.comment is defined %}
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
{% endif %}
</div> </div>
<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 %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endfor %}
</div> </div>
<div class="card-footer text-center"> </div>
<a href="/governance/proposals" class="btn btn-sm btn-outline-info">View All Activity</a> <div class="card-footer text-center">
</div> <a href="/governance/activities" class="btn btn-sm btn-outline-info">View All Activities</a>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Recent Proposals Section --> <!-- Recent Proposals Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Active Proposals (Ending Soon)</h5> <h5 class="mb-0">Active Proposals (Ending Soon)</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
{% set count = 0 %} {% set count = 0 %}
{% for proposal in proposals %} {% for proposal in proposals %}
{% if count < 3 %} {% if count < 3 %} <div class="col-md-4 mb-3">
<div class="col-md-4 mb-3"> <div class="card h-100">
<div class="card h-100"> <div class="card-body">
<div class="card-body"> <h5 class="card-title">{{ proposal.title }}</h5>
<h5 class="card-title">{{ proposal.title }}</h5> <h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6>
<h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6> <p class="card-text">{{ proposal.description | truncate(length=100) }}</p>
<p class="card-text">{{ proposal.description | truncate(length=100) }}</p> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <span
<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 %}"> 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 }} {{ proposal.status }}
</span> </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 }}"
</div> class="btn btn-sm btn-outline-primary">View Details</a>
</div>
<div class="card-footer text-muted text-center">
<span>Voting ends: {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}</span>
</div>
</div>
</div> </div>
{% set count = count + 1 %} </div>
{% endif %} <div class="card-footer text-muted text-center">
{% endfor %} <span>Voting ends: {{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</span>
</div> </div>
</div>
</div> </div>
{% set count = count + 1 %}
{% endif %}
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% endblock %} </div>
</div>
{% endblock %}

View File

@ -3,133 +3,121 @@
{% block title %}My Votes - Governance Dashboard{% endblock %} {% block title %}My Votes - Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Navigation Tabs --> <!-- Header -->
<div class="row mb-4"> {% include "governance/_header.html" %}
<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>
</div>
<!-- My Votes List --> <!-- Navigation Tabs -->
<div class="row mb-4"> {% include "governance/_tabs.html" %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">My Voting History</h5>
</div>
<div class="card-body">
{% if votes | length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Proposal</th>
<th>My Vote</th>
<th>Status</th>
<th>Voted On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
<tr>
<td>{{ proposal.title }}</td>
<td>
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ vote.vote_type }}
</span>
</td>
<td>
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
<td>
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View Proposal</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>You haven't voted on any proposals yet</h5>
<p class="text-muted">When you vote on proposals, they will appear here.</p>
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Voting Stats --> <!-- Info Alert -->
{% if votes | length > 0 %} <div class="row">
<div class="row mb-4"> <div class="col-12">
<div class="col-md-4 mb-3"> <div class="alert alert-info alert-dismissible fade show">
<div class="card text-white bg-success h-100"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<div class="card-body text-center"> <h5><i class="bi bi-info-circle"></i> About Votes</h5>
<h5 class="card-title">Yes Votes</h5> <p>Voting is a fundamental right of all token holders in our governance system. Each vote carries weight
<p class="display-4"> proportional to your token holdings, ensuring fair representation. The voting statistics below show the
{% set yes_count = 0 %} community's collective decision-making across all proposals.</p>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %} <div class="mt-2">
{% if vote.vote_type == 'Yes' %} <a href="/governance/voting-guide" class="btn btn-sm btn-outline-primary"><i
{% set yes_count = yes_count + 1 %} class="bi bi-check2-square"></i> Voting Guide</a>
{% 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> </div>
</div> </div>
{% endif %} </div>
{% endblock %}
<!-- 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>
<!-- My Votes List -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">My Voting History</h5>
</div>
<div class="card-body">
{% if votes | length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Proposal</th>
<th>My Vote</th>
<th>Status</th>
<th>Voted On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
<tr>
<td>{{ proposal.title }}</td>
<td>
<span
class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ vote.vote_type }}
</span>
</td>
<td>
<span
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
<td>
<a href="/governance/proposals/{{ proposal.base_data.id }}"
class="btn btn-sm btn-primary">View Proposal</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>You haven't voted on any proposals yet</h5>
<p class="text-muted">When you vote on proposals, they will appear here.</p>
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -2,8 +2,45 @@
{% block title %}{{ proposal.title }} - Governance Proposal{% endblock %} {% block title %}{{ proposal.title }} - Governance Proposal{% endblock %}
{% block styles %}
<style>
.avatar-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.comment-text {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-text:hover {
white-space: normal;
overflow: visible;
}
.progress {
border-radius: 10px;
overflow: hidden;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
@ -30,160 +67,549 @@
<!-- Proposal Details --> <!-- Proposal Details -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8"> <div class="col-lg-8">
<div class="card"> <div class="card h-100 shadow-sm">
<div class="card-header"> <div class="card-header bg-light">
<h4 class="mb-0">{{ proposal.title }}</h4> <h4 class="mb-0">{{ proposal.title }}</h4>
</div> </div>
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between mb-3"> <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"> <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 }} {{ proposal.status }}
</span> </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>
<div class="flex-grow-1">
<h5><i class="bi bi-file-text me-2"></i>Description</h5>
<div class="p-3 bg-light rounded mb-4">{{ proposal.description }}</div>
</div>
<div class="mt-auto">
<h5><i class="bi bi-calendar-event me-2"></i>Voting Period</h5>
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
{% if proposal.vote_start_date and proposal.vote_end_date %}
<div>
<div class="text-muted mb-1">Start Date</div>
<div class="fw-bold">{{ proposal.vote_start_date | date(format="%Y-%m-%d") }}</div>
</div>
<div class="text-center">
<i class="bi bi-arrow-right fs-4 text-muted"></i>
</div>
<div>
<div class="text-muted mb-1">End Date</div>
<div class="fw-bold">{{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</div>
</div>
{% else %}
<div class="text-center w-100">Not set</div>
{% endif %}
</div>
</div> </div>
<h5>Description</h5>
<p class="mb-4">{{ proposal.description }}</p>
<h5>Voting Period</h5>
<p>
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
<strong>Start:</strong> {{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} <br>
<strong>End:</strong> {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-lg-4">
<div class="card mb-4"> <div class="card mb-4 shadow-sm h-100">
<div class="card-header"> <div class="card-header bg-primary text-white">
<h5 class="mb-0">Voting Results</h5> <h5 class="mb-0"><i class="bi bi-bar-chart-fill me-2"></i>Voting Dashboard</h5>
</div> </div>
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="mb-3"> <!-- Voting Results Section -->
<div class="mb-4">
<h6 class="border-bottom pb-2 mb-3">Results</h6>
{% set yes_percent = 0 %} {% set yes_percent = 0 %}
{% set no_percent = 0 %} {% set no_percent = 0 %}
{% set abstain_percent = 0 %} {% set abstain_percent = 0 %}
{% if results.total_votes > 0 %}
{% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
{% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
{% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
{% 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>
</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>
</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>
</div>
</div>
<p class="text-center"><strong>Total Votes:</strong> {{ results.total_votes }}</p>
</div>
</div>
<!-- Vote Form -->
{% if proposal.status == "Active" and user and user.id %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Cast Your Vote</h5>
</div>
<div class="card-body">
<form action="/governance/proposals/{{ proposal.id }}/vote" method="post">
<div class="mb-3">
<label class="form-label">Vote Type</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteYes" value="Yes" checked>
<label class="form-check-label" for="voteYes">
Yes - I support this proposal
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteNo" value="No">
<label class="form-check-label" for="voteNo">
No - I oppose this proposal
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain" value="Abstain">
<label class="form-check-label" for="voteAbstain">
Abstain - I choose not to vote
</label>
</div>
</div>
<div class="mb-3">
<label for="comment" class="form-label">Comment (Optional)</label>
<textarea class="form-control" id="comment" name="comment" rows="3" placeholder="Explain your vote..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100">Submit Vote</button>
</form>
</div>
</div>
{% elif not user or not user.id %}
<div class="card">
<div class="card-body text-center">
<p>You must be logged in to vote.</p>
<a href="/login" class="btn btn-primary">Login to Vote</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Votes List --> {% if results.total_votes > 0 %}
<div class="row mb-4"> {% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
<div class="col-12"> {% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
<div class="card"> {% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
<div class="card-header"> {% endif %}
<h5 class="mb-0">Votes ({{ votes | length }})</h5>
</div> <!-- Yes votes -->
<div class="card-body"> <div class="d-flex justify-content-between align-items-center mb-1">
{% if votes | length > 0 %} <span class="fw-bold text-success"><i class="bi bi-check-circle-fill me-1"></i> Yes</span>
<div class="table-responsive"> <span class="badge bg-success rounded-pill">{{ results.yes_count }}</span>
<table class="table"> </div>
<thead> <div class="progress mb-3" style="height: 12px;">
<tr> <div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
<th>Voter</th> aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100"
<th>Vote</th> title="{{ yes_percent }}% of votes"></div>
<th>Comment</th> </div>
<th>Date</th>
</tr> <!-- No votes -->
</thead> <div class="d-flex justify-content-between align-items-center mb-1">
<tbody> <span class="fw-bold text-danger"><i class="bi bi-x-circle-fill me-1"></i> No</span>
{% for vote in votes %} <span class="badge bg-danger rounded-pill">{{ results.no_count }}</span>
<tr> </div>
<td>{{ vote.voter_name }}</td> <div class="progress mb-3" style="height: 12px;">
<td> <div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}"> aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100"
{{ vote.vote_type }} title="{{ no_percent }}% of votes"></div>
</span> </div>
</td>
<td>{% if vote.comment %}{{ vote.comment }}{% else %}No comment{% endif %}</td> <!-- Abstain votes -->
<td>{{ vote.created_at | date(format="%Y-%m-%d %H:%M") }}</td> <div class="d-flex justify-content-between align-items-center mb-1">
</tr> <span class="fw-bold text-secondary"><i class="bi bi-dash-circle-fill me-1"></i>
{% endfor %} Abstain</span>
</tbody> <span class="badge bg-secondary rounded-pill">{{ results.abstain_count }}</span>
</table> </div>
<div class="progress mb-3" style="height: 12px;">
<div class="progress-bar bg-secondary" role="progressbar"
style="width: {{ abstain_percent }}%" aria-valuenow="{{ abstain_percent }}"
aria-valuemin="0" aria-valuemax="100" title="{{ abstain_percent }}% of votes"></div>
</div>
</div>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
<div class="text-center">
<h4 class="mb-0">{{ results.total_votes }}</h4>
<small class="text-muted">Total Votes</small>
</div>
{% if proposal.status == "Active" %}
<div class="text-center">
<div class="position-relative d-inline-block" style="width: 60px; height: 60px;">
<svg width="60" height="60">
<circle cx="30" cy="30" r="25" fill="none" stroke="#e9ecef" stroke-width="5">
</circle>
<circle cx="30" cy="30" r="25" fill="none" stroke="#0d6efd" stroke-width="5"
stroke-dasharray="157"
stroke-dashoffset="{{ 157 - (157 * yes_percent / 100) }}"
transform="rotate(-90 30 30)"></circle>
</svg>
<div
class="position-absolute top-50 start-50 translate-middle text-primary fw-bold">
{{ yes_percent }}%</div>
</div>
<small class="text-muted">Approval Rate</small>
</div>
{% endif %}
</div>
</div>
<!-- Vote Form Section -->
{% if proposal.status == "Active" and user and user.id %}
<div class="mt-auto">
<h6 class="border-bottom pb-2 mb-3"><i class="bi bi-check2-square me-2"></i>Cast Your Vote</h6>
<form action="/governance/proposals/{{ proposal.base_data.id }}/vote" method="post"
id="voteForm">
<div class="mb-3">
<div class="d-flex gap-2 mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteYes"
value="Yes" required>
<label class="form-check-label text-success" for="voteYes"><i
class="bi bi-check-circle-fill me-1"></i>Yes</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteNo"
value="No">
<label class="form-check-label text-danger" for="voteNo"><i
class="bi bi-x-circle-fill me-1"></i>No</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain"
value="Abstain">
<label class="form-check-label text-secondary" for="voteAbstain"><i
class="bi bi-dash-circle-fill me-1"></i>Abstain</label>
</div>
</div>
<textarea class="form-control" id="comment" name="comment" rows="2"
placeholder="Add your thoughts about this proposal (optional)..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-send me-2"></i>Submit
Vote</button>
</form>
</div>
{% elif proposal.status != "Active" %}
<div class="mt-auto text-center p-3 bg-light rounded">
<i class="bi bi-info-circle fs-4 text-muted"></i>
<p class="mb-0 mt-2">Voting is {{ proposal.status | lower }} for this proposal</p>
</div>
{% elif not user or not user.id %}
<div class="mt-auto text-center p-3 bg-light rounded">
<i class="bi bi-person-lock fs-4 text-muted"></i>
<p class="mb-0 mt-2">You must be logged in to vote</p>
<a href="/login" class="btn btn-primary btn-sm mt-2">Login to Vote</a>
</div> </div>
{% else %}
<p class="text-center">No votes have been cast yet.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Votes List -->
<div class="row mt-4">
<div class="col-12">
<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 p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Voter</th>
<th>Vote</th>
<th>Comment</th>
<th class="text-end pe-3">Date</th>
</tr>
</thead>
<tbody id="votesTableBody">
{% if votes | length == 0 %}
<tr>
<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 %} rounded-pill px-3 py-2">
{% if vote.vote_type == 'Yes' %}
<i class="bi bi-check-circle-fill me-1"></i>
{% elif vote.vote_type == 'No' %}
<i class="bi bi-x-circle-fill me-1"></i>
{% else %}
<i class="bi bi-dash-circle-fill me-1"></i>
{% endif %}
{{ vote.vote_type }}
</span>
</td>
<td>
{% if vote.comment %}
<div class="comment-text">{{ vote.comment }}</div>
{% else %}
<span class="text-muted fst-italic">No comment provided</span>
{% endif %}
</td>
<td class="text-end pe-3">
<div class="d-flex flex-column align-items-end">
<span>{{ vote.created_at | date(format="%Y-%m-%d") }}</span>
<small class="text-muted">{{ vote.created_at | date(format="%H:%M")
}}</small>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{% if votes | length > 10 %}
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<div class="d-flex align-items-center">
<label class="me-2 text-muted small">Rows per page:</label>
<select id="rowsPerPage" class="form-select form-select-sm" style="width: auto;">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div>
<nav aria-label="Votes pagination">
<ul class="pagination pagination-sm mb-0" id="paginationControls">
<li class="page-item disabled" id="prevPage">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</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">&raquo;</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>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Remove query parameters from URL without refreshing the page
if (window.location.search.includes('vote_success=true')) {
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
// Auto-hide the success alert after 5 seconds
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
setTimeout(function () {
successAlert.classList.remove('show');
setTimeout(function () {
successAlert.remove();
}, 500);
}, 5000);
}
}
// Pagination functionality
const rowsPerPageSelect = document.getElementById('rowsPerPage');
const paginationControls = document.getElementById('paginationControls');
const votesTableBody = document.getElementById('votesTableBody');
const startRowElement = document.getElementById('startRow');
const endRowElement = document.getElementById('endRow');
const totalRowsElement = document.getElementById('totalRows');
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
let currentPage = 1;
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
// Function to update pagination display
function updatePagination() {
if (!paginationControls) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
// Calculate total pages
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Update pagination controls
if (paginationControls) {
// Clear existing page links (except prev/next)
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
pageLinks.forEach(link => link.remove());
// Add new page links
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust if we're near the end
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Insert page links before the next button
const nextPageElement = document.getElementById('nextPage');
for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = i;
a.addEventListener('click', function (e) {
e.preventDefault();
currentPage = i;
updatePagination();
});
li.appendChild(a);
paginationControls.insertBefore(li, nextPageElement);
}
// Update prev/next buttons
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
}
// Show current page
showCurrentPage();
}
// Function to show current page
function showCurrentPage() {
if (!votesTableBody) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
// Hide all rows first
voteRows.forEach(row => row.style.display = 'none');
// Calculate pagination
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Show only rows for current page
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
filteredRows.slice(start, end).forEach(row => row.style.display = '');
// Update pagination info
if (startRowElement && endRowElement && totalRowsElement) {
startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
endRowElement.textContent = Math.min(end, totalRows);
totalRowsElement.textContent = totalRows;
}
}
// Event listeners for pagination
if (prevPageBtn) {
prevPageBtn.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', function (e) {
e.preventDefault();
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
}
if (rowsPerPageSelect) {
rowsPerPageSelect.addEventListener('change', function () {
rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page
updatePagination();
});
}
// Initialize pagination (but don't interfere with filtering)
if (paginationControls) {
// Only initialize pagination if there are many votes
// The filtering will handle showing/hiding rows
console.log('Pagination controls available but not interfering with filtering');
}
// Initialize tooltips for all elements with title attributes
const tooltipElements = document.querySelectorAll('[title]');
if (tooltipElements.length > 0) {
[].slice.call(tooltipElements).map(function (el) {
return new bootstrap.Tooltip(el);
});
}
// Add debugging for vote form
const voteForm = document.getElementById('voteForm');
if (voteForm) {
console.log('Vote form found:', voteForm);
voteForm.addEventListener('submit', function (e) {
console.log('Vote form submitted');
const formData = new FormData(voteForm);
console.log('Form data:', Object.fromEntries(formData));
});
} else {
console.log('Vote form not found');
}
// Debug logging
console.log('Filter buttons found:', filterButtons.length);
console.log('Vote rows found:', voteRows.length);
console.log('Search input found:', searchInput ? 'Yes' : 'No');
});
</script>
{% endblock %}

View File

@ -3,128 +3,140 @@
{% block title %}Proposals - Governance Dashboard{% endblock %} {% block title %}Proposals - Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Success message if present --> <!-- Header -->
{% if success %} {% include "governance/_header.html" %}
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
{% endif %}
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="row mb-3"> {% include "governance/_tabs.html" %}
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
<!-- Success message if present -->
{% if success %}
<div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="alert alert-info alert-dismissible fade show"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<h5><i class="bi bi-info-circle"></i> About Proposals</h5>
<p>Proposals are formal requests for changes to the platform that require community approval. Each proposal includes a detailed description, implementation plan, and voting period. Browse the list below to see all active and past proposals.</p>
<div class="mt-2">
<a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i class="bi bi-file-text"></i> Proposal Guidelines</a>
</div>
</div> </div>
</div> </div>
</div>
{% endif %}
<!-- Filter Controls --> <div class="col-12">
<div class="row mb-4"> <div class="alert alert-info alert-dismissible fade show">
<div class="col-12"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<div class="card"> <h5><i class="bi bi-info-circle"></i> About Proposals</h5>
<div class="card-body"> <p>Proposals are formal requests for changes to the platform that require community approval. Each proposal
<form action="/governance/proposals" method="get" class="row g-3"> includes a detailed description, implementation plan, and voting period. Browse the list below to see all
<div class="col-md-4"> active and past proposals.</p>
<label for="status" class="form-label">Status</label> <div class="mt-2">
<select class="form-select" id="status" name="status"> <a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i
<option value="">All Statuses</option> class="bi bi-file-text"></i> Proposal Guidelines</a>
<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>
</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">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- Proposals List --> <!-- Filter Controls -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-body">
<h5 class="mb-0">All Proposals</h5> <form action="/governance/proposals" method="get" class="row g-3">
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a> <div class="col-md-4">
</div> <label for="status" class="form-label">Status</label>
<div class="card-body"> <select class="form-select" id="status" name="status">
<div class="table-responsive"> <option value="" {% if not status_filter or status_filter=="" %}selected{% endif %}>All
<table class="table table-hover"> Statuses</option>
<thead> <option value="Draft" {% if status_filter=="Draft" %}selected{% endif %}>Draft</option>
<tr> <option value="Active" {% if status_filter=="Active" %}selected{% endif %}>Active</option>
<th>Title</th> <option value="Approved" {% if status_filter=="Approved" %}selected{% endif %}>Approved
<th>Creator</th> </option>
<th>Status</th> <option value="Rejected" {% if status_filter=="Rejected" %}selected{% endif %}>Rejected
<th>Created</th> </option>
<th>Voting Period</th> <option value="Cancelled" {% if status_filter=="Cancelled" %}selected{% endif %}>Cancelled
<th>Actions</th> </option>
</tr> </select>
</thead>
<tbody>
{% for proposal in proposals %}
<tr>
<td>{{ proposal.title }}</td>
<td>{{ proposal.creator_name }}</td>
<td>
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
<td>
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
{{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} to {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</td>
<td>
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<div class="col-md-6">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Search by title or description"
value="{% if search_filter %}{{ search_filter }}{% endif %}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Proposals List -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Proposals</h5>
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a>
</div>
<div class="card-body">
{% if proposals and proposals|length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Title</th>
<th>Creator</th>
<th>Status</th>
<th>Created</th>
<th>Voting Period</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for proposal in proposals %}
<tr>
<td>{{ proposal.title }}</td>
<td>{{ proposal.creator_name }}</td>
<td>
<span
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
<td>
{% if proposal.vote_start_date and proposal.vote_end_date %}
{{ proposal.vote_start_date | date(format="%Y-%m-%d") }} to {{
proposal.vote_end_date | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</td>
<td>
<a href="/governance/proposals/{{ proposal.base_data.id }}"
class="btn btn-sm btn-primary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info text-center py-5">
<i class="bi bi-info-circle fs-1 mb-3"></i>
<h5>No proposals found</h5>
{% if status_filter or search_filter %}
<p>No proposals match your current filter criteria. Try adjusting your filters or <a
href="/governance/proposals" class="alert-link">view all proposals</a>.</p>
{% else %}
<p>There are no proposals in the system yet.</p>
{% endif %}
<a href="/governance/create" class="btn btn-primary mt-3">Create New Proposal</a>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} </div>
{% endblock %}

View 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
);
}
}
}

View File

@ -49,4 +49,5 @@ cd actix_mvc_app
# Run the application on the specified port # Run the application on the specified port
echo "Starting application on port $PORT..." echo "Starting application on port $PORT..."
cargo run -- --port $PORT echo "[Running with PORT=$PORT]"
PORT=$PORT cargo watch -x run