prod #123
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,6 +2,8 @@ app/tmp/*
|
|||
*.tar.gz
|
||||
*.swp
|
||||
*.swo
|
||||
.env.prod
|
||||
.env.stg
|
||||
app/vendors/tcpdf/cache/*
|
||||
app/tests/*
|
||||
app/emails/*
|
||||
|
|
@ -11,3 +13,4 @@ app/vaultmsgs/*
|
|||
app/cake_eclipse_helper.php
|
||||
app/webroot/pdf/*
|
||||
app/webroot/attachments_files/*
|
||||
backups/*
|
||||
|
|
|
|||
|
|
@ -1,344 +0,0 @@
|
|||
# CMC Sales Deployment Guide with Caddy
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers deploying the CMC Sales application to a Debian 12 VM using Caddy as the reverse proxy with automatic HTTPS.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Production**: `https://cmc.springupsoftware.com`
|
||||
- **Staging**: `https://staging.cmc.springupsoftware.com`
|
||||
- **Reverse Proxy**: Caddy (running on host)
|
||||
- **Applications**: Docker containers
|
||||
- CakePHP legacy app
|
||||
- Go modern app
|
||||
- MariaDB database
|
||||
- **SSL**: Automatic via Caddy (Let's Encrypt)
|
||||
- **Authentication**: Basic auth configured in Caddy
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Server Setup (Debian 12)
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
sudo apt install -y docker.io docker-compose-plugin
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p /var/backups/cmc-sales
|
||||
sudo chown $USER:$USER /var/backups/cmc-sales
|
||||
```
|
||||
|
||||
### 2. Install Caddy
|
||||
|
||||
```bash
|
||||
# Run the installation script
|
||||
sudo ./scripts/install-caddy.sh
|
||||
|
||||
# Or manually install
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt update
|
||||
sudo apt install caddy
|
||||
```
|
||||
|
||||
### 3. DNS Configuration
|
||||
|
||||
Ensure DNS records point to your server:
|
||||
- `cmc.springupsoftware.com` → Server IP
|
||||
- `staging.cmc.springupsoftware.com` → Server IP
|
||||
|
||||
## Initial Deployment
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
cd /home/cmc
|
||||
git clone git@code.springupsoftware.com:cmc/cmc-sales.git cmc-sales
|
||||
sudo chown -R $USER:$USER cmc-sales
|
||||
cd cmc-sales
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
```bash
|
||||
# Copy environment files
|
||||
cp .env.staging go-app/.env.staging
|
||||
cp .env.production go-app/.env.production
|
||||
|
||||
# Edit with actual passwords
|
||||
nano .env.staging
|
||||
nano .env.production
|
||||
|
||||
# Create credential directories
|
||||
mkdir -p credentials/staging credentials/production
|
||||
```
|
||||
|
||||
### 3. Setup Basic Authentication
|
||||
|
||||
```bash
|
||||
# Generate password hashes for Caddy
|
||||
./scripts/setup-caddy-auth.sh
|
||||
|
||||
# Or manually
|
||||
caddy hash-password
|
||||
# Copy the hash and update Caddyfile
|
||||
```
|
||||
|
||||
### 4. Configure Caddy
|
||||
|
||||
```bash
|
||||
# Copy Caddyfile
|
||||
sudo cp Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
# Edit to update passwords and email
|
||||
sudo nano /etc/caddy/Caddyfile
|
||||
|
||||
# Validate configuration
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Start Caddy
|
||||
sudo systemctl start caddy
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
### 5. Gmail OAuth Setup
|
||||
|
||||
Same as before - set up OAuth credentials for each environment:
|
||||
- Staging: `credentials/staging/credentials.json`
|
||||
- Production: `credentials/production/credentials.json`
|
||||
|
||||
### 6. Database Initialization
|
||||
|
||||
```bash
|
||||
# Start database containers
|
||||
docker compose -f docker-compose.caddy-staging.yml up -d db-staging
|
||||
docker compose -f docker-compose.caddy-production.yml up -d db-production
|
||||
|
||||
# Wait for databases
|
||||
sleep 30
|
||||
|
||||
# Restore production database (if you have a backup)
|
||||
./scripts/restore-db.sh production /path/to/backup.sql.gz
|
||||
```
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Starting Services
|
||||
|
||||
```bash
|
||||
# Start staging environment
|
||||
docker compose -f docker-compose.caddy-staging.yml up -d
|
||||
|
||||
# Start production environment
|
||||
docker compose -f docker-compose.caddy-production.yml up -d
|
||||
|
||||
# Reload Caddy configuration
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
### Updating Applications
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Update staging
|
||||
docker compose -f docker-compose.caddy-staging.yml down
|
||||
docker compose -f docker-compose.caddy-staging.yml build --no-cache
|
||||
docker compose -f docker-compose.caddy-staging.yml up -d
|
||||
|
||||
# Test staging, then update production
|
||||
docker compose -f docker-compose.caddy-production.yml down
|
||||
docker compose -f docker-compose.caddy-production.yml build --no-cache
|
||||
docker compose -f docker-compose.caddy-production.yml up -d
|
||||
```
|
||||
|
||||
## Caddy Management
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Edit Caddyfile
|
||||
sudo nano /etc/caddy/Caddyfile
|
||||
|
||||
# Validate configuration
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Reload configuration (zero downtime)
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
```bash
|
||||
# Check Caddy status
|
||||
sudo systemctl status caddy
|
||||
|
||||
# View Caddy logs
|
||||
sudo journalctl -u caddy -f
|
||||
|
||||
# View access logs
|
||||
sudo tail -f /var/log/caddy/cmc-production.log
|
||||
sudo tail -f /var/log/caddy/cmc-staging.log
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
Caddy handles SSL automatically! To check certificates:
|
||||
|
||||
```bash
|
||||
# List certificates
|
||||
sudo ls -la /var/lib/caddy/.local/share/caddy/certificates/
|
||||
|
||||
# Force certificate renewal (rarely needed)
|
||||
sudo systemctl stop caddy
|
||||
sudo rm -rf /var/lib/caddy/.local/share/caddy/certificates/*
|
||||
sudo systemctl start caddy
|
||||
```
|
||||
|
||||
## Container Port Mapping
|
||||
|
||||
| Service | Container Port | Host Port | Access |
|
||||
|---------|---------------|-----------|---------|
|
||||
| cmc-php-staging | 80 | 8091 | localhost only |
|
||||
| cmc-go-staging | 8080 | 8092 | localhost only |
|
||||
| cmc-db-staging | 3306 | 3307 | localhost only |
|
||||
| cmc-php-production | 80 | 8093 | localhost only |
|
||||
| cmc-go-production | 8080 | 8094 | localhost only |
|
||||
| cmc-db-production | 3306 | - | internal only |
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check all containers
|
||||
docker ps
|
||||
|
||||
# Check application health
|
||||
curl -I https://cmc.springupsoftware.com
|
||||
curl -I https://staging.cmc.springupsoftware.com
|
||||
|
||||
# Internal health checks (from server)
|
||||
curl http://localhost:8094/api/v1/health # Production Go
|
||||
curl http://localhost:8092/api/v1/health # Staging Go
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
Same backup scripts work:
|
||||
|
||||
```bash
|
||||
# Manual backup
|
||||
./scripts/backup-db.sh production
|
||||
./scripts/backup-db.sh staging
|
||||
|
||||
# Automated backups
|
||||
sudo crontab -e
|
||||
# Add:
|
||||
# 0 2 * * * /home/cmc/cmc-sales/scripts/backup-db.sh production
|
||||
# 0 3 * * * /home/cmc/cmc-sales/scripts/backup-db.sh staging
|
||||
```
|
||||
|
||||
## Security Benefits with Caddy
|
||||
|
||||
1. **Automatic HTTPS**: No manual certificate management
|
||||
2. **Modern TLS**: Always up-to-date TLS configuration
|
||||
3. **OCSP Stapling**: Enabled by default
|
||||
4. **Security Headers**: Easy to configure
|
||||
5. **Rate Limiting**: Built-in support
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Caddy Issues
|
||||
|
||||
```bash
|
||||
# Check Caddy configuration
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Check Caddy service
|
||||
sudo systemctl status caddy
|
||||
sudo journalctl -u caddy -n 100
|
||||
|
||||
# Test reverse proxy
|
||||
curl -v http://localhost:8094/api/v1/health
|
||||
```
|
||||
|
||||
### Container Issues
|
||||
|
||||
```bash
|
||||
# Check container logs
|
||||
docker compose -f docker-compose.caddy-production.yml logs -f
|
||||
|
||||
# Restart specific service
|
||||
docker compose -f docker-compose.caddy-production.yml restart cmc-go-production
|
||||
```
|
||||
|
||||
### SSL Issues
|
||||
|
||||
```bash
|
||||
# Caddy automatically handles SSL, but if issues arise:
|
||||
# 1. Check DNS is resolving correctly
|
||||
dig cmc.springupsoftware.com
|
||||
|
||||
# 2. Check Caddy can reach Let's Encrypt
|
||||
sudo journalctl -u caddy | grep -i acme
|
||||
|
||||
# 3. Ensure ports 80 and 443 are open
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
## Advantages of Caddy Setup
|
||||
|
||||
1. **Simpler Configuration**: Caddyfile is more readable than nginx
|
||||
2. **Automatic HTTPS**: No certbot or lego needed
|
||||
3. **Zero-Downtime Reloads**: Config changes without dropping connections
|
||||
4. **Better Performance**: More efficient than nginx for this use case
|
||||
5. **Native Rate Limiting**: Built-in without additional modules
|
||||
6. **Automatic Certificate Renewal**: No cron jobs needed
|
||||
|
||||
## Migration from Nginx
|
||||
|
||||
If migrating from the nginx setup:
|
||||
|
||||
1. Stop nginx containers: `docker compose -f docker-compose.proxy.yml down`
|
||||
2. Install and configure Caddy
|
||||
3. Start new containers with caddy compose files
|
||||
4. Update DNS if needed
|
||||
5. Monitor logs during transition
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/home/cmc/cmc-sales/
|
||||
├── docker-compose.caddy-staging.yml
|
||||
├── docker-compose.caddy-production.yml
|
||||
├── Caddyfile
|
||||
├── credentials/
|
||||
│ ├── staging/
|
||||
│ └── production/
|
||||
├── scripts/
|
||||
│ ├── backup-db.sh
|
||||
│ ├── restore-db.sh
|
||||
│ ├── install-caddy.sh
|
||||
│ └── setup-caddy-auth.sh
|
||||
└── .env files
|
||||
|
||||
/etc/caddy/
|
||||
└── Caddyfile (deployed config)
|
||||
|
||||
/var/log/caddy/
|
||||
├── cmc-production.log
|
||||
└── cmc-staging.log
|
||||
```
|
||||
362
DEPLOYMENT.md
362
DEPLOYMENT.md
|
|
@ -1,362 +0,0 @@
|
|||
# CMC Sales Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers deploying the CMC Sales application to a Debian 12 VM at `cmc.springupsoftware.com` with both staging and production environments.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Production**: `https://cmc.springupsoftware.com`
|
||||
- **Staging**: `https://staging.cmc.springupsoftware.com`
|
||||
- **Components**: CakePHP legacy app, Go modern app, MariaDB, Nginx reverse proxy
|
||||
- **SSL**: Let's Encrypt certificates
|
||||
- **Authentication**: Basic auth for both environments
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Server Setup (Debian 12)
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker and Docker Compose
|
||||
sudo apt install -y docker.io docker-compose-plugin
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
|
||||
# Create backup directory
|
||||
sudo mkdir -p /var/backups/cmc-sales
|
||||
sudo chown $USER:$USER /var/backups/cmc-sales
|
||||
```
|
||||
|
||||
### DNS Configuration
|
||||
|
||||
Ensure these DNS records point to your server:
|
||||
- `cmc.springupsoftware.com` → Server IP
|
||||
- `staging.cmc.springupsoftware.com` → Server IP
|
||||
|
||||
## Initial Deployment
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
cd /home/cmc
|
||||
git clone git@code.springupsoftware.com:cmc/cmc-sales.git cmc-sales
|
||||
sudo chown -R $USER:$USER cmc-sales
|
||||
cd cmc-sales
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
```bash
|
||||
# Copy environment files
|
||||
cp .env.staging go-app/.env.staging
|
||||
cp .env.production go-app/.env.production
|
||||
|
||||
# Edit with actual passwords -- up to this.
|
||||
nano .env.staging
|
||||
nano .env.production
|
||||
|
||||
# Create credential directories
|
||||
mkdir -p credentials/staging credentials/production
|
||||
```
|
||||
|
||||
### 3. Gmail OAuth Setup
|
||||
|
||||
For each environment (staging/production):
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create/select project
|
||||
3. Enable Gmail API
|
||||
4. Create OAuth 2.0 credentials
|
||||
5. Download `credentials.json`
|
||||
6. Place in appropriate directory:
|
||||
- Staging: `credentials/staging/credentials.json`
|
||||
- Production: `credentials/production/credentials.json`
|
||||
|
||||
Generate tokens (run on local machine first):
|
||||
```bash
|
||||
cd go-app
|
||||
go run cmd/auth/main.go
|
||||
# Follow OAuth flow
|
||||
# Copy token.json to appropriate credential directory
|
||||
```
|
||||
|
||||
### 4. SSL Certificates
|
||||
|
||||
```bash
|
||||
# Start proxy services (includes Lego container)
|
||||
docker compose -f docker-compose.proxy.yml up -d
|
||||
|
||||
# Setup SSL certificates using Lego
|
||||
./scripts/setup-lego-certs.sh accounts@springupsoftware.com
|
||||
|
||||
# Verify certificates
|
||||
./scripts/lego-list-certs.sh
|
||||
```
|
||||
|
||||
### 5. Database Initialization
|
||||
|
||||
```bash
|
||||
# Start database containers first
|
||||
docker compose -f docker-compose.staging.yml up -d db-staging
|
||||
docker compose -f docker-compose.production.yml up -d db-production
|
||||
|
||||
# Wait for databases to be ready
|
||||
sleep 30
|
||||
|
||||
# Restore production database (if you have a backup)
|
||||
./scripts/restore-db.sh production /path/to/backup.sql.gz
|
||||
|
||||
# Or initialize empty database and run migrations
|
||||
# (implementation specific)
|
||||
```
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Starting Services
|
||||
|
||||
```bash
|
||||
# Start staging environment
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
|
||||
# Start production environment
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Wait for services to be ready
|
||||
sleep 10
|
||||
|
||||
# Start reverse proxy (after both environments are running)
|
||||
docker compose -f docker-compose.proxy.yml up -d
|
||||
|
||||
# Or use the make command for full stack deployment
|
||||
make full-stack
|
||||
```
|
||||
|
||||
### Updating Applications
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and restart staging
|
||||
docker compose -f docker-compose.staging.yml down
|
||||
docker compose -f docker-compose.staging.yml build --no-cache
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
|
||||
# Test staging thoroughly, then update production
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml build --no-cache
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check all containers
|
||||
docker ps
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Check application health
|
||||
curl https://cmc.springupsoftware.com/health
|
||||
curl https://staging.cmc.springupsoftware.com/health
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
```bash
|
||||
# Manual backup
|
||||
./scripts/backup-db.sh production
|
||||
./scripts/backup-db.sh staging
|
||||
|
||||
# Set up automated backups (cron)
|
||||
sudo crontab -e
|
||||
# Add: 0 2 * * * /opt/cmc-sales/scripts/backup-db.sh production
|
||||
# Add: 0 3 * * * /opt/cmc-sales/scripts/backup-db.sh staging
|
||||
```
|
||||
|
||||
### Log Management
|
||||
|
||||
```bash
|
||||
# View nginx logs
|
||||
sudo tail -f /var/log/nginx/access.log
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
|
||||
# View application logs
|
||||
docker compose -f docker-compose.production.yml logs -f cmc-go-production
|
||||
docker compose -f docker-compose.staging.yml logs -f cmc-go-staging
|
||||
```
|
||||
|
||||
### SSL Certificate Renewal
|
||||
|
||||
```bash
|
||||
# Manual renewal
|
||||
./scripts/lego-renew-cert.sh all
|
||||
|
||||
# Renew specific domain
|
||||
./scripts/lego-renew-cert.sh cmc.springupsoftware.com
|
||||
|
||||
# Set up auto-renewal (cron)
|
||||
sudo crontab -e
|
||||
# Add: 0 2 * * * /opt/cmc-sales/scripts/lego-renew-cert.sh all
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Basic Authentication
|
||||
|
||||
Update passwords in `userpasswd` file:
|
||||
```bash
|
||||
# Generate new password hash
|
||||
sudo apt install apache2-utils
|
||||
htpasswd -c userpasswd username
|
||||
|
||||
# Restart nginx containers
|
||||
docker compose -f docker-compose.proxy.yml restart nginx-proxy
|
||||
```
|
||||
|
||||
### Database Security
|
||||
|
||||
- Use strong passwords in environment files
|
||||
- Database containers are not exposed externally in production
|
||||
- Regular backups with encryption at rest
|
||||
|
||||
### Network Security
|
||||
|
||||
- All traffic encrypted with SSL/TLS
|
||||
- Rate limiting configured in nginx
|
||||
- Security headers enabled
|
||||
- Docker networks isolate environments
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Containers won't start**
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs container-name
|
||||
|
||||
# Check system resources
|
||||
df -h
|
||||
free -h
|
||||
```
|
||||
|
||||
2. **SSL issues**
|
||||
```bash
|
||||
# Check certificate status
|
||||
./scripts/lego-list-certs.sh
|
||||
|
||||
# Test SSL configuration
|
||||
curl -I https://cmc.springupsoftware.com
|
||||
|
||||
# Manually renew certificates
|
||||
./scripts/lego-renew-cert.sh all
|
||||
```
|
||||
|
||||
3. **Database connection issues**
|
||||
```bash
|
||||
# Test database connectivity
|
||||
docker exec -it cmc-db-production mysql -u cmc -p
|
||||
```
|
||||
|
||||
4. **Gmail API issues**
|
||||
```bash
|
||||
# Check credentials are mounted
|
||||
docker exec -it cmc-go-production ls -la /root/credentials/
|
||||
|
||||
# Check logs for OAuth errors
|
||||
docker compose logs cmc-go-production | grep -i gmail
|
||||
```
|
||||
|
||||
### Emergency Procedures
|
||||
|
||||
1. **Quick rollback**
|
||||
```bash
|
||||
# Stop current containers
|
||||
docker compose -f docker-compose.production.yml down
|
||||
|
||||
# Restore from backup
|
||||
./scripts/restore-db.sh production /var/backups/cmc-sales/latest_backup.sql.gz
|
||||
|
||||
# Start previous version
|
||||
git checkout previous-commit
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
2. **Database corruption**
|
||||
```bash
|
||||
# Stop application
|
||||
docker compose -f docker-compose.production.yml stop cmc-go-production cmc-php-production
|
||||
|
||||
# Restore from backup
|
||||
./scripts/restore-db.sh production /var/backups/cmc-sales/backup_production_YYYYMMDD-HHMMSS.sql.gz
|
||||
|
||||
# Restart application
|
||||
docker compose -f docker-compose.production.yml start cmc-go-production cmc-php-production
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/opt/cmc-sales/
|
||||
├── docker-compose.staging.yml
|
||||
├── docker-compose.production.yml
|
||||
├── docker-compose.proxy.yml
|
||||
├── conf/
|
||||
│ ├── nginx-staging.conf
|
||||
│ ├── nginx-production.conf
|
||||
│ └── nginx-proxy.conf
|
||||
├── credentials/
|
||||
│ ├── staging/
|
||||
│ │ ├── credentials.json
|
||||
│ │ └── token.json
|
||||
│ └── production/
|
||||
│ ├── credentials.json
|
||||
│ └── token.json
|
||||
├── scripts/
|
||||
│ ├── backup-db.sh
|
||||
│ ├── restore-db.sh
|
||||
│ ├── lego-obtain-cert.sh
|
||||
│ ├── lego-renew-cert.sh
|
||||
│ ├── lego-list-certs.sh
|
||||
│ └── setup-lego-certs.sh
|
||||
└── .env files
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Resource limits are configured in the Docker Compose files:
|
||||
- Production: 2 CPU cores, 2-4GB RAM per service
|
||||
- Staging: More relaxed limits for testing
|
||||
|
||||
### Database Optimization
|
||||
|
||||
```sql
|
||||
-- Monitor slow queries
|
||||
SHOW VARIABLES LIKE 'slow_query_log';
|
||||
SET GLOBAL slow_query_log = 'ON';
|
||||
SET GLOBAL long_query_time = 2;
|
||||
|
||||
-- Check database performance
|
||||
SHOW PROCESSLIST;
|
||||
SHOW ENGINE INNODB STATUS;
|
||||
```
|
||||
|
||||
### Nginx Optimization
|
||||
|
||||
- Gzip compression enabled
|
||||
- Static file caching
|
||||
- Connection keep-alive
|
||||
- Rate limiting configured
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go-app/go.mod go-app/go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY go-app/ .
|
||||
|
||||
# Install sqlc (compatible with Go 1.23+)
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
|
||||
# Generate sqlc code
|
||||
RUN sqlc generate
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder
|
||||
COPY --from=builder /app/server .
|
||||
|
||||
# Copy templates and static files
|
||||
COPY go-app/templates ./templates
|
||||
COPY go-app/static ./static
|
||||
|
||||
# Copy Gmail OAuth credentials if they exist
|
||||
# Note: These files contain sensitive data and should not be committed to version control
|
||||
# Consider using Docker secrets or environment variables for production
|
||||
# Using conditional copy pattern to handle missing files gracefully
|
||||
COPY go-app/credentials.jso[n] ./
|
||||
COPY go-app/token.jso[n] ./
|
||||
|
||||
# Copy .env file if needed
|
||||
COPY go-app/.env.example .env
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./server"]
|
||||
1
Dockerfile.prod.db
Normal file
1
Dockerfile.prod.db
Normal file
|
|
@ -0,0 +1 @@
|
|||
FROM mariadb:latest
|
||||
23
Dockerfile.prod.go
Normal file
23
Dockerfile.prod.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY go-app/go.mod go-app/go.sum ./
|
||||
RUN go mod download
|
||||
COPY go-app/ .
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
RUN sqlc generate
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/main.go
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/server .
|
||||
COPY --from=builder /app/vault .
|
||||
COPY go-app/templates ./templates
|
||||
COPY go-app/static ./static
|
||||
COPY go-app/.env.example .env
|
||||
EXPOSE 8082
|
||||
CMD ["./server"]
|
||||
47
Dockerfile.prod.php
Normal file
47
Dockerfile.prod.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||
FROM php:5.6-apache
|
||||
|
||||
# Install required system libraries and PHP extensions for CakePHP
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
apt-get update && \
|
||||
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Copy vhost config to Apache's sites-available
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime \
|
||||
&& a2ensite cmc-sales \
|
||||
&& a2dissite 000-default \
|
||||
&& a2enmod rewrite \
|
||||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
|
||||
# Ensure CakePHP tmp directory is writable by web server
|
||||
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
|
||||
# By default, simply start apache.
|
||||
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||
1
Dockerfile.stg.db
Normal file
1
Dockerfile.stg.db
Normal file
|
|
@ -0,0 +1 @@
|
|||
FROM mariadb:latest
|
||||
21
Dockerfile.stg.go
Normal file
21
Dockerfile.stg.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY go-app/go.mod go-app/go.sum ./
|
||||
RUN go mod download
|
||||
COPY go-app/ .
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
RUN sqlc generate
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/server .
|
||||
COPY go-app/templates ./templates
|
||||
COPY go-app/static ./static
|
||||
COPY go-app/.env.example .env
|
||||
EXPOSE 8082
|
||||
CMD ["./server"]
|
||||
67
Dockerfile.stg.php
Normal file
67
Dockerfile.stg.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
|
||||
|
||||
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||
FROM php:5.6-apache
|
||||
|
||||
# Install required system libraries and PHP extensions for CakePHP
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
apt-get update && \
|
||||
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Legacy apt compatibility and install steps for Ubuntu 16.04 (now commented out)
|
||||
# RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://old-releases.ubuntu.com/ubuntu/|g' /etc/apt/sources.list && \
|
||||
# sed -i 's|http://security.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' /etc/apt/sources.list
|
||||
# RUN apt-get update
|
||||
# RUN apt-get -y upgrade
|
||||
# RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure
|
||||
# RUN apt-get update -o Acquire::AllowInsecureRepositories=true --allow-unauthenticated
|
||||
# RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
|
||||
# RUN a2enmod php5
|
||||
# RUN php5enmod openssl
|
||||
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
|
||||
# RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
|
||||
# ADD conf/php.ini /etc/php5/apache2/php.ini
|
||||
|
||||
|
||||
# Copy vhost config to Apache's sites-available
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime \
|
||||
&& a2ensite cmc-sales \
|
||||
&& a2dissite 000-default \
|
||||
&& a2enmod rewrite \
|
||||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
|
||||
# Ensure CakePHP tmp directory is writable by web server
|
||||
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
|
||||
# No need to disable proxy_fcgi or remove PHP-FPM conf files in this image
|
||||
# By default, simply start apache.
|
||||
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# This is 99% the same as the prod one. I should do something smarter here.
|
||||
|
||||
FROM ubuntu:lucid
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
RUN sed -i 's/archive/old-releases/' /etc/apt/sources.list
|
||||
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get -y upgrade
|
||||
|
||||
# Install apache, PHP, and supplimentary programs. curl and lynx-cur are for debugging the container.
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
|
||||
|
||||
# Enable apache mods.
|
||||
#RUN php5enmod openssl
|
||||
RUN a2enmod php5
|
||||
RUN a2enmod rewrite
|
||||
RUN a2enmod headers
|
||||
|
||||
|
||||
# Update the PHP.ini file, enable <? ?> tags and quieten logging.
|
||||
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
|
||||
#RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
|
||||
|
||||
ADD conf/php.ini /etc/php5/apache2/php.ini
|
||||
|
||||
# Manually set up the apache environment variables
|
||||
ENV APACHE_RUN_USER www-data
|
||||
ENV APACHE_RUN_GROUP www-data
|
||||
ENV APACHE_LOG_DIR /var/log/apache2
|
||||
ENV APACHE_LOCK_DIR /var/lock/apache2
|
||||
ENV APACHE_PID_FILE /var/run/apache2.pid
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Update the default apache site with the config we created.
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime
|
||||
RUN a2dissite 000-default
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
RUN chmod +x /var/www/cmc-sales/run_vault.sh
|
||||
|
||||
RUN a2ensite cmc-sales
|
||||
|
||||
# By default, simply start apache.
|
||||
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||
18
MIGRATION.md
18
MIGRATION.md
|
|
@ -1,18 +0,0 @@
|
|||
# migration instructions
|
||||
|
||||
mysql -u cmc -p cmc < ~/migration/latest.sql
|
||||
|
||||
MariaDB [(none)]> CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'somepass';
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
MariaDB [(none)]> GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
|
||||
|
||||
|
||||
www-data@helios:~$ du -hcs vaultmsgs
|
||||
64G vaultmsgs
|
||||
64G total
|
||||
www-data@helios:~$ du -hcs emails
|
||||
192G emails
|
||||
192G total
|
||||
www-data@helios:~$
|
||||
|
||||
138
README.md
138
README.md
|
|
@ -1,8 +1,22 @@
|
|||
# cmc-sales
|
||||
|
||||
## Development Setup
|
||||
CMC Sales is a business management system with two applications:
|
||||
|
||||
CMC Sales now runs both legacy CakePHP and modern Go applications side by side.
|
||||
- **PHP Application**: CakePHP 1.2.5 (currently the primary application)
|
||||
- **Go Application**: Go + HTMX (used for select features, growing)
|
||||
|
||||
**Future development should be done in the Go application wherever possible.**
|
||||
|
||||
## Architecture
|
||||
|
||||
Both applications:
|
||||
- Share the same MariaDB database
|
||||
- Run behind a shared Caddy reverse proxy with basic authentication
|
||||
- Support staging and production environments on the same server
|
||||
|
||||
The PHP application currently handles most functionality, while the Go application is used for select screens and new features as they're developed.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Quick Start
|
||||
|
||||
|
|
@ -45,105 +59,53 @@ Both applications share the same database, allowing for gradual migration.
|
|||
- **Go Application**: Requires Go 1.23+ (for latest sqlc)
|
||||
- Alternative: Use `Dockerfile.go.legacy` with Go 1.21 and sqlc v1.26.0
|
||||
|
||||
## Deployment
|
||||
|
||||
## Install a new server
|
||||
### Prerequisites
|
||||
|
||||
(TODO this is all likely out of date)
|
||||
|
||||
### Requirements
|
||||
|
||||
Debian or Ubuntu OS. These instructions written for Debian 9.9
|
||||
|
||||
Assumed pre-work:
|
||||
|
||||
Create a new VM with hostname newserver.cmctechnologies.com.au
|
||||
Configure DNS appropriately. cmctechnologies.com.au zones is currently managed in Google Cloud DNS on Karl's account:
|
||||
https://console.cloud.google.com/net-services/dns/zones/cmctechnologies?project=cmc-technologies&authuser=1&folder&organizationId
|
||||
|
||||
Will need to migrate that to CMC's GSuite account at some point.
|
||||
|
||||
|
||||
1. Install ansible on your workstation
|
||||
```
|
||||
apt-get install ansible
|
||||
```
|
||||
2. Clone the playbooks
|
||||
```
|
||||
git clone git@gitlab.com:minimalist.software/cmc-playbooks.git
|
||||
```
|
||||
3. Execute the playbooks
|
||||
|
||||
The nginx config expects the site to be available at sales.cmctechnologies.com.au.
|
||||
|
||||
You'll need to add the hostname to config/nginx-site, if this isn't sales.cmctechnologies.com.au
|
||||
The deployment scripts use SSH to connect to the server. Configure your SSH config (`~/.ssh/config`) with a host entry named `cmc` pointing to the correct server:
|
||||
|
||||
```
|
||||
cd cmc-playbooks
|
||||
# Add the hostname of your new server to the inventory.txt
|
||||
ansible-playbook -i inventory.txt setup.yml
|
||||
```
|
||||
4. SSH to the new server and configure gitlab-runner
|
||||
```
|
||||
ssh newserver.cmctechnologies.com.au
|
||||
sudo gitlab-runner register
|
||||
```
|
||||
5. SSH to the new server as cmc user
|
||||
```
|
||||
ssh cmc@newserver.cmctechnologies.com.au
|
||||
Host cmc
|
||||
HostName node0.prd.springupsoftware.com
|
||||
User cmc
|
||||
IdentityFile ~/.ssh/cmc
|
||||
```
|
||||
|
||||
6. Add the SSH key to the cmc-sales repo on gitlab as a deploy key
|
||||
https://gitlab.com/minimalist.software/cmc-sales/-/settings/repository
|
||||
```
|
||||
cmc@cmc:~$ cat .ssh/id_rsa.pub
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFIdoWVp2pGDb46ubW6jkfIpREMa/veD6xZVAtnj3WG1sX7NEUlQYq3RKbZ5CThlw6GKMSYoIsIqk7p6zSoJHGlJSLxoJ0edKflciMUFMTQrdm4T1USXsK+gd0C4DUCyVkYFOs37sy+JtziymnBTm7iOeVI3aMxwfoCOs6mNiD0ettjJT6WtVyy0ZTb6yU4uz7CHj1IGsvwsoKJWPGwJrZ/MfByNl6aJ8R/8zDwbtP06owKD4b3ZPgakM3nYRRoKzHZ/SClz50SXMKC4/nmFY9wLuuMhCWK+9x4/4VPSnxXESOlENMfUoa1IY4osAnZCtaFrWDyenJ+spZrNfgcscD ansible-generated on cmc
|
||||
### Deployment Procedures
|
||||
|
||||
Deploy to staging or production using the scripts in the `deploy/` directory:
|
||||
|
||||
**Deploy to Staging:**
|
||||
```bash
|
||||
./deploy/deploy-stg.sh
|
||||
```
|
||||
|
||||
6. Clone the cmc-sales repo
|
||||
```
|
||||
git clone git@gitlab.com:minimalist.software/cmc-sales.git
|
||||
**Deploy to Production:**
|
||||
```bash
|
||||
./deploy/deploy-prod.sh
|
||||
```
|
||||
|
||||
7. As root on new server configure mySQL user cmc
|
||||
Note: get password from app/config/database.php
|
||||
(or set a new one and change it)
|
||||
```
|
||||
# mysql -u root
|
||||
CREATE USER 'cmc'@'localhost' IDENTIFIED BY 'password';
|
||||
CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'password';
|
||||
CREATE database cmc;
|
||||
GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
|
||||
**Rebuild without cache (useful after dependency changes):**
|
||||
```bash
|
||||
./deploy/deploy-prod.sh --no-cache
|
||||
./deploy/deploy-stg.sh --no-cache
|
||||
```
|
||||
|
||||
8. Get the latest backup from Google Drive
|
||||
### How Deployment Works
|
||||
|
||||
In the shared google drive:
|
||||
eg. backups/database/backup_20191217_21001.sql.gz
|
||||
1. The deploy script connects to the server via the `cmc` SSH host
|
||||
2. Clones or updates the appropriate git branch (`stg` or `prod`)
|
||||
3. Creates environment configuration for the Go application
|
||||
4. Builds and starts Docker containers using the appropriate compose file
|
||||
5. Applications are accessible through Caddy reverse proxy with basic auth
|
||||
|
||||
Copy up to the new server:
|
||||
```
|
||||
rsync backup_*.gz root@newserver:~/
|
||||
### Deployment Environments
|
||||
|
||||
```
|
||||
- **Staging**: Branch `stg` → https://stg.cmctechnologies.com.au
|
||||
- **Production**: Branch `prod` → https://sales.cmctechnologies.com.au or https://prod.cmctechnologies.com.au
|
||||
|
||||
9. Restore backup to cmc database
|
||||
```
|
||||
zcat backup_* | mysql -u cmc -p
|
||||
```
|
||||
|
||||
10. Redeploy from Gitlab
|
||||
https://gitlab.com/minimalist.software/cmc-sales/pipelines/new
|
||||
|
||||
11. You should have a new installation of cmc-sales.
|
||||
|
||||
12. Seems new Linux kernels break the docker
|
||||
https://github.com/moby/moby/issues/28705
|
||||
|
||||
|
||||
13. Mysql needs special args not to break
|
||||
|
||||
```
|
||||
# /etc/mysql/my.cnf
|
||||
sql_mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
|
||||
```
|
||||
Both environments run on the same server and share:
|
||||
- A single Caddy reverse proxy (handles HTTPS and basic authentication for both environments)
|
||||
- Separate Docker containers for each environment's PHP and Go applications
|
||||
- Separate MariaDB database instances
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
# Running CMC Django Tests in Docker
|
||||
|
||||
This guide explains how to run the comprehensive CMC Django test suite using Docker for consistent, isolated testing.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Setup test environment (one-time)
|
||||
./run-tests-docker.sh setup
|
||||
|
||||
# 2. Run all tests
|
||||
./run-tests-docker.sh run
|
||||
|
||||
# 3. Run tests with coverage
|
||||
./run-tests-docker.sh coverage
|
||||
```
|
||||
|
||||
## Test Environment Overview
|
||||
|
||||
The Docker test environment includes:
|
||||
- **Isolated test database** (MariaDB on port 3307)
|
||||
- **Django test container** with all dependencies
|
||||
- **Coverage reporting** with HTML and XML output
|
||||
- **PDF generation testing** with WeasyPrint/ReportLab
|
||||
- **Parallel test execution** support
|
||||
|
||||
## Available Commands
|
||||
|
||||
### Setup and Management
|
||||
|
||||
```bash
|
||||
# Build containers and setup test database
|
||||
./run-tests-docker.sh setup
|
||||
|
||||
# Clean up all test containers and data
|
||||
./run-tests-docker.sh clean
|
||||
|
||||
# View test container logs
|
||||
./run-tests-docker.sh logs
|
||||
|
||||
# Open shell in test container
|
||||
./run-tests-docker.sh shell
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./run-tests-docker.sh run
|
||||
|
||||
# Run specific test suites
|
||||
./run-tests-docker.sh run models # Model tests only
|
||||
./run-tests-docker.sh run services # Service layer tests
|
||||
./run-tests-docker.sh run auth # Authentication tests
|
||||
./run-tests-docker.sh run views # View and URL tests
|
||||
./run-tests-docker.sh run pdf # PDF generation tests
|
||||
./run-tests-docker.sh run integration # Integration tests
|
||||
|
||||
# Run quick tests (models + services)
|
||||
./run-tests-docker.sh quick
|
||||
|
||||
# Run tests with coverage reporting
|
||||
./run-tests-docker.sh coverage
|
||||
```
|
||||
|
||||
## Advanced Test Options
|
||||
|
||||
### Using Docker Compose Directly
|
||||
|
||||
```bash
|
||||
# Run specific test with custom options
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
python cmcsales/manage.py test cmc.tests.test_models --verbosity=2 --keepdb
|
||||
|
||||
# Run tests with coverage
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
coverage run --source='.' cmcsales/manage.py test cmc.tests
|
||||
|
||||
# Generate coverage report
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
coverage report --show-missing
|
||||
```
|
||||
|
||||
### Using the Test Script Directly
|
||||
|
||||
```bash
|
||||
# Inside the container, you can use the test script with advanced options
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
/app/scripts/run-tests.sh --coverage --keepdb --failfast models
|
||||
|
||||
# Script options:
|
||||
# -c, --coverage Enable coverage reporting
|
||||
# -k, --keepdb Keep test database between runs
|
||||
# -p, --parallel NUM Run tests in parallel
|
||||
# -f, --failfast Stop on first failure
|
||||
# -v, --verbosity NUM Verbosity level 0-3
|
||||
```
|
||||
|
||||
## Test Suite Structure
|
||||
|
||||
### 1. Model Tests (`test_models.py`)
|
||||
Tests all Django models including:
|
||||
- Customer, Enquiry, Job, Document models
|
||||
- Model validation and constraints
|
||||
- Relationships and cascade behavior
|
||||
- Financial calculations
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run models
|
||||
```
|
||||
|
||||
### 2. Service Tests (`test_services.py`)
|
||||
Tests business logic layer:
|
||||
- Number generation service
|
||||
- Financial calculation service
|
||||
- Document service workflows
|
||||
- Validation service
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run services
|
||||
```
|
||||
|
||||
### 3. Authentication Tests (`test_authentication.py`)
|
||||
Tests authentication system:
|
||||
- Multiple authentication backends
|
||||
- Permission decorators and middleware
|
||||
- User management workflows
|
||||
- Security features
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run auth
|
||||
```
|
||||
|
||||
### 4. View Tests (`test_views.py`)
|
||||
Tests web interface:
|
||||
- CRUD operations for all entities
|
||||
- AJAX endpoints
|
||||
- Permission enforcement
|
||||
- URL routing
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run views
|
||||
```
|
||||
|
||||
### 5. PDF Tests (`test_pdf.py`)
|
||||
Tests PDF generation:
|
||||
- WeasyPrint and ReportLab engines
|
||||
- Template rendering
|
||||
- Document formatting
|
||||
- Security and performance
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run pdf
|
||||
```
|
||||
|
||||
### 6. Integration Tests (`test_integration.py`)
|
||||
Tests complete workflows:
|
||||
- End-to-end business processes
|
||||
- Multi-user collaboration
|
||||
- System integration scenarios
|
||||
- Performance and security
|
||||
|
||||
```bash
|
||||
./run-tests-docker.sh run integration
|
||||
```
|
||||
|
||||
## Test Reports and Coverage
|
||||
|
||||
### Coverage Reports
|
||||
|
||||
After running tests with coverage, reports are available in:
|
||||
- **HTML Report**: `./coverage-reports/html/index.html`
|
||||
- **XML Report**: `./coverage-reports/coverage.xml`
|
||||
- **Console**: Displayed after test run
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
./run-tests-docker.sh coverage
|
||||
|
||||
# View HTML report
|
||||
open coverage-reports/html/index.html
|
||||
```
|
||||
|
||||
### Test Artifacts
|
||||
|
||||
Test outputs are saved to:
|
||||
- **Test Reports**: `./test-reports/`
|
||||
- **Coverage Reports**: `./coverage-reports/`
|
||||
- **Logs**: `./logs/`
|
||||
- **PDF Test Files**: `./test-reports/pdf/`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The test environment uses these key variables:
|
||||
|
||||
```yaml
|
||||
# Database configuration
|
||||
DATABASE_HOST: test-db
|
||||
DATABASE_NAME: test_cmc
|
||||
DATABASE_USER: test_cmc
|
||||
DATABASE_PASSWORD: testPassword123
|
||||
|
||||
# Django settings
|
||||
DJANGO_SETTINGS_MODULE: cmcsales.settings
|
||||
TESTING: 1
|
||||
DEBUG: 0
|
||||
|
||||
# PDF generation
|
||||
PDF_GENERATION_ENGINE: weasyprint
|
||||
PDF_SAVE_DIRECTORY: /app/test-reports/pdf
|
||||
```
|
||||
|
||||
### Test Database
|
||||
|
||||
- **Isolated database** separate from development/production
|
||||
- **Runs on port 3307** to avoid conflicts
|
||||
- **Optimized for testing** with reduced buffer sizes
|
||||
- **Automatically reset** between test runs (unless `--keepdb` used)
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Parallel Test Execution
|
||||
|
||||
```bash
|
||||
# Run tests in parallel (faster execution)
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
/app/scripts/run-tests.sh --parallel=4 all
|
||||
```
|
||||
|
||||
### Keeping Test Database
|
||||
|
||||
```bash
|
||||
# Keep database between runs for faster subsequent tests
|
||||
./run-tests-docker.sh run models --keepdb
|
||||
```
|
||||
|
||||
### Quick Test Suite
|
||||
|
||||
```bash
|
||||
# Run only essential tests for rapid feedback
|
||||
./run-tests-docker.sh quick
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Test CMC Django
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup test environment
|
||||
run: ./run-tests-docker.sh setup
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: ./run-tests-docker.sh coverage
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage-reports/coverage.xml
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Check database status
|
||||
docker-compose -f docker-compose.test.yml ps
|
||||
|
||||
# View database logs
|
||||
docker-compose -f docker-compose.test.yml logs test-db
|
||||
|
||||
# Restart database
|
||||
docker-compose -f docker-compose.test.yml restart test-db
|
||||
```
|
||||
|
||||
### Test Failures
|
||||
|
||||
```bash
|
||||
# Run with maximum verbosity for debugging
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
python cmcsales/manage.py test cmc.tests.test_models --verbosity=3
|
||||
|
||||
# Use failfast to stop on first error
|
||||
./run-tests-docker.sh run models --failfast
|
||||
|
||||
# Open shell to investigate
|
||||
./run-tests-docker.sh shell
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
|
||||
```bash
|
||||
# Fix file permissions
|
||||
sudo chown -R $USER:$USER test-reports coverage-reports logs
|
||||
|
||||
# Check Docker permissions
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test whoami
|
||||
```
|
||||
|
||||
### Memory Issues
|
||||
|
||||
```bash
|
||||
# Run tests with reduced parallel workers
|
||||
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
|
||||
/app/scripts/run-tests.sh --parallel=1 all
|
||||
|
||||
# Monitor resource usage
|
||||
docker stats
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Recommended Testing Workflow
|
||||
|
||||
1. **Initial Setup** (one-time):
|
||||
```bash
|
||||
./run-tests-docker.sh setup
|
||||
```
|
||||
|
||||
2. **During Development** (fast feedback):
|
||||
```bash
|
||||
./run-tests-docker.sh quick --keepdb
|
||||
```
|
||||
|
||||
3. **Before Commit** (comprehensive):
|
||||
```bash
|
||||
./run-tests-docker.sh coverage
|
||||
```
|
||||
|
||||
4. **Debugging Issues**:
|
||||
```bash
|
||||
./run-tests-docker.sh shell
|
||||
# Inside container:
|
||||
python cmcsales/manage.py test cmc.tests.test_models.CustomerModelTest.test_customer_creation --verbosity=3
|
||||
```
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. Create test file in appropriate module
|
||||
2. Follow existing test patterns and base classes
|
||||
3. Test locally:
|
||||
```bash
|
||||
./run-tests-docker.sh run models --keepdb
|
||||
```
|
||||
4. Run full suite before committing:
|
||||
```bash
|
||||
./run-tests-docker.sh coverage
|
||||
```
|
||||
|
||||
## Integration with IDE
|
||||
|
||||
### PyCharm/IntelliJ
|
||||
|
||||
Configure remote interpreter using Docker:
|
||||
1. Go to Settings → Project → Python Interpreter
|
||||
2. Add Docker Compose interpreter
|
||||
3. Use `docker-compose.test.yml` configuration
|
||||
4. Set service to `cmc-django-test`
|
||||
|
||||
### VS Code
|
||||
|
||||
Use Dev Containers extension:
|
||||
1. Create `.devcontainer/devcontainer.json`
|
||||
2. Configure to use test Docker environment
|
||||
3. Run tests directly in integrated terminal
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always run tests in Docker** for consistency
|
||||
2. **Use `--keepdb` during development** for speed
|
||||
3. **Run coverage reports before commits**
|
||||
4. **Clean up regularly** to free disk space
|
||||
5. **Monitor test performance** and optimize slow tests
|
||||
6. **Use parallel execution** for large test suites
|
||||
7. **Keep test data realistic** but minimal
|
||||
8. **Test error conditions** as well as happy paths
|
||||
|
||||
## Resources
|
||||
|
||||
- **Django Testing Documentation**: https://docs.djangoproject.com/en/5.1/topics/testing/
|
||||
- **Coverage.py Documentation**: https://coverage.readthedocs.io/
|
||||
- **Docker Compose Reference**: https://docs.docker.com/compose/
|
||||
- **CMC Test Suite Documentation**: See individual test modules for detailed information
|
||||
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -47,28 +47,13 @@ Configure::write('version', '1.0.1');
|
|||
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
|
||||
/*Configure::write('smtp_settings', array(
|
||||
'port' => '587',
|
||||
'timeout' => '60',
|
||||
'host' => 'smtp-relay.gmail.com',
|
||||
'username' => 'sales',
|
||||
'password' => 'S%s\'mMZ})MGsg$k!5N|mPSQ>}'
|
||||
));
|
||||
*/
|
||||
|
||||
|
||||
// SMTP settings
|
||||
Configure::write('smtp_settings', array(
|
||||
'port' => '25',
|
||||
'timeout' => '30',
|
||||
'host' => '172.17.0.1'));
|
||||
|
||||
|
||||
// Mailhog SMTP settings for local development
|
||||
// Configure::write('smtp_settings', array(
|
||||
// 'port' => '1025',
|
||||
// 'timeout' => '30',
|
||||
// 'host' => 'host.docker.internal'
|
||||
// ));
|
||||
'port' => '25',
|
||||
'timeout' => '30',
|
||||
'host' => 'postfix'
|
||||
));
|
||||
|
||||
//Production/Staging Config
|
||||
|
||||
|
|
@ -79,13 +64,13 @@ $production_hosts = array('cmc.lan', '192.168.0.7', 'cmcbeta.lan', 'office.cmcte
|
|||
|
||||
$basedir = '/var/www/cmc-sales/app/';
|
||||
Cache::config('default', array(
|
||||
'engine' => 'File', //[required]
|
||||
'duration'=> 3600, //[optional]
|
||||
'probability'=> 100, //[optional]
|
||||
'path' => '/home/cmc/cmc-sales/app/tmp/', //[optional] use system tmp directory - remember to use absolute path
|
||||
'prefix' => 'cake_', //[optional] prefix every cache file with this string
|
||||
'lock' => false, //[optional] use file locking
|
||||
'serialize' => true,
|
||||
'engine' => 'File', //[required]
|
||||
'duration'=> 3600, //[optional]
|
||||
'probability'=> 100, //[optional]
|
||||
'path' => '/home/cmc/cmc-sales/app/tmp/', //[optional] use system tmp directory - remember to use absolute path
|
||||
'prefix' => 'cake_', //[optional] prefix every cache file with this string
|
||||
'lock' => false, //[optional] use file locking
|
||||
'serialize' => true,
|
||||
));
|
||||
|
||||
Configure::write('email_directory', '/var/www/emails');
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
class DATABASE_CONFIG {
|
||||
|
||||
var $default = array(
|
||||
'driver' => 'mysql',
|
||||
'driver' => 'mysqli',
|
||||
'persistent' => false,
|
||||
'host' => '172.17.0.1',
|
||||
'host' => 'cmc-prod-db',
|
||||
'login' => 'cmc',
|
||||
'password' => 'xVRQI&cA?7AU=hqJ!%au',
|
||||
'database' => 'cmc',
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
class DATABASE_CONFIG {
|
||||
|
||||
var $default = array(
|
||||
'driver' => 'mysql',
|
||||
'driver' => 'mysqli',
|
||||
'persistent' => false,
|
||||
'host' => '172.17.0.1',
|
||||
'login' => 'staging',
|
||||
'password' => 'stagingmoopwoopVerySecure',
|
||||
'database' => 'staging',
|
||||
'host' => 'cmc-stg-db',
|
||||
'login' => 'cmc',
|
||||
'password' => 'xVRQI&cA?7AU=hqJ!%au',
|
||||
'database' => 'cmc',
|
||||
'prefix' => '',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1205,13 +1205,13 @@ EOT;
|
|||
|
||||
}
|
||||
|
||||
function format_email($email) {
|
||||
$email = trim($email);
|
||||
// Basic RFC 5322 email validation
|
||||
if (!preg_match('/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $email)) {
|
||||
return '';
|
||||
}
|
||||
return "$email <$email>";
|
||||
function format_email($email) {
|
||||
$email = trim($email);
|
||||
// Basic RFC 5322 email validation
|
||||
if (!preg_match('/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $email)) {
|
||||
return '';
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
function parse_email_to_array($input) {
|
||||
|
|
@ -1325,18 +1325,21 @@ function email_pdf_with_custom_recipients($id = null, $to = null, $cc = null, $b
|
|||
$msg = 'Invalid recipient email address.';
|
||||
echo json_encode(array('success' => false, 'message' => $msg));
|
||||
return;
|
||||
} else {
|
||||
$this->Email->to = implode(', ', $toArray);
|
||||
}
|
||||
} else {
|
||||
// Pass as array - EmailComponent now properly handles arrays for TO field
|
||||
$this->Email->to = $toArray;
|
||||
}
|
||||
$ccArray = $this->parse_email_to_array($cc);
|
||||
if (!empty($ccArray)) {
|
||||
$this->Email->cc = $ccArray;
|
||||
}
|
||||
$bccArray = $this->parse_email_to_array($bcc);
|
||||
$defaultBccs = array('<carpis@cmctechnologies.com.au>', '<mcarpis@cmctechnologies.com.au>');
|
||||
foreach ($defaultBccs as $defaultBcc) {
|
||||
if (!in_array($defaultBcc, $bccArray)) {
|
||||
$bccArray[] = $defaultBcc;
|
||||
// Add always BCC recipients
|
||||
// These emails will always be included in the BCC list, regardless of user input
|
||||
$alwaysBcc = array('<carpis@cmctechnologies.com.au>', '<mcarpis@cmctechnologies.com.au>');
|
||||
foreach ($alwaysBcc as $bccEmail) {
|
||||
if (!in_array($bccEmail, $bccArray)) {
|
||||
$bccArray[] = $bccEmail;
|
||||
}
|
||||
}
|
||||
if (!empty($bccArray)) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ echo $scripts_for_layout;
|
|||
<li><?php echo $html->link('Enquiries', '/enquiries/index'); ?>
|
||||
<ul>
|
||||
<li class="last"><?php echo $html->link('Enquiry Register', '/enquiries/index'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Quotes Expiring', '/go/quotes'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
|
|
|||
BIN
app/webroot/.DS_Store
vendored
Normal file
BIN
app/webroot/.DS_Store
vendored
Normal file
Binary file not shown.
23
archive/ci/Dockerfile.local.go
Normal file
23
archive/ci/Dockerfile.local.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Dev Dockerfile for Go hot reload with Air and sqlc
|
||||
FROM golang:1.24.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Air for hot reload
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
# Install sqlc for SQL code generation
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
|
||||
# Copy source code
|
||||
COPY go-app/ .
|
||||
|
||||
# Generate sqlc code
|
||||
RUN sqlc generate
|
||||
|
||||
# Copy Air config
|
||||
COPY go-app/.air.toml .air.toml
|
||||
COPY go-app/.env.example .env
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["air", "-c", ".air.toml"]
|
||||
40
archive/scripts/refresh_data.sh
Executable file
40
archive/scripts/refresh_data.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Config
|
||||
SSH_CONFIG="$HOME/.ssh/config"
|
||||
SSH_IDENTITY="$HOME/.ssh/cmc" #Path to your SSH identity file
|
||||
REMOTE_USER="cmc"
|
||||
REMOTE_HOST="sales.cmctechnologies.com.au"
|
||||
REMOTE_PATH="~/backups/"
|
||||
LOCAL_BACKUP_DIR="backups"
|
||||
DB_HOST="127.0.0.1"
|
||||
DB_USER="cmc"
|
||||
DB_NAME="cmc"
|
||||
|
||||
# Ensure backups dir exists
|
||||
if [ ! -d "$LOCAL_BACKUP_DIR" ]; then
|
||||
echo "Creating $LOCAL_BACKUP_DIR directory..."
|
||||
mkdir -p "$LOCAL_BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# Step 1: Rsync backups (flatten output)
|
||||
echo "Starting rsync..."
|
||||
rsync -avz --progress -e "ssh -F $SSH_CONFIG -i $SSH_IDENTITY" --no-relative --include='backup_*.sql.gz' --exclude='*' "$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH" "$LOCAL_BACKUP_DIR/" || { echo "Rsync failed"; exit 1; }
|
||||
echo "Rsync complete."
|
||||
|
||||
# Step 2: Find latest backup
|
||||
LATEST_BACKUP=$(ls -t $LOCAL_BACKUP_DIR/backup_*.sql.gz | head -n1)
|
||||
if [[ -z "$LATEST_BACKUP" ]]; then
|
||||
echo "No backup file found!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest backup: $LATEST_BACKUP"
|
||||
|
||||
# Step 3: Import to MariaDB
|
||||
read -s -p "Enter DB password for $DB_USER: " DB_PASS
|
||||
echo
|
||||
|
||||
echo "Importing backup to MariaDB..."
|
||||
gunzip -c "$LATEST_BACKUP" | mariadb -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" || { echo "Database import failed"; exit 1; }
|
||||
echo "Database import complete."
|
||||
38
archive/scripts/restore_db_from_backup.sh
Normal file
38
archive/scripts/restore_db_from_backup.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
# Default to staging
|
||||
TARGET="stg"
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "-target" ]]; then
|
||||
NEXT_IS_TARGET=1
|
||||
continue
|
||||
fi
|
||||
if [[ $NEXT_IS_TARGET == 1 ]]; then
|
||||
TARGET="$arg"
|
||||
NEXT_IS_TARGET=0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$TARGET" == "prod" ]]; then
|
||||
DB_CONTAINER="cmc-prod-db"
|
||||
DB_USER="cmc"
|
||||
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
|
||||
DB_NAME="cmc"
|
||||
else
|
||||
DB_CONTAINER="cmc-db"
|
||||
DB_USER="cmc"
|
||||
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
|
||||
DB_NAME="cmc"
|
||||
fi
|
||||
|
||||
# Sync latest backup from production
|
||||
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:~/backups /home/cmc/
|
||||
LATEST_BACKUP=$(ls -t /home/cmc/backups/backup_*.sql.gz | head -n1)
|
||||
echo "Restoring database from latest backup: $LATEST_BACKUP to $TARGET ($DB_CONTAINER)"
|
||||
if [ -f "$LATEST_BACKUP" ]; then
|
||||
docker cp "$LATEST_BACKUP" "$DB_CONTAINER":/tmp/backup.sql.gz
|
||||
docker exec "$DB_CONTAINER" sh -c "gunzip < /tmp/backup.sql.gz | mariadb -u $DB_USER -p'$DB_PASS' $DB_NAME"
|
||||
echo "Database restore complete."
|
||||
else
|
||||
echo "No backup file found in /home/cmc/backups. Skipping database restore."
|
||||
fi
|
||||
33
archive/scripts/sync_docs.sh
Normal file
33
archive/scripts/sync_docs.sh
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Default target directory for docs
|
||||
TARGET_DIR="/mnt/cmc-docs"
|
||||
|
||||
# Parse optional target dir
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "-target" ]]; then
|
||||
NEXT_IS_TARGET=1
|
||||
continue
|
||||
fi
|
||||
if [[ $NEXT_IS_TARGET == 1 ]]; then
|
||||
TARGET_DIR="$arg"
|
||||
NEXT_IS_TARGET=0
|
||||
fi
|
||||
done
|
||||
|
||||
PDF_SRC="/mnt/vault/pdf"
|
||||
ATTACH_SRC="/mnt/vault/attachments_files"
|
||||
PDF_DEST="$TARGET_DIR/pdf"
|
||||
ATTACH_DEST="$TARGET_DIR/attachments_files"
|
||||
|
||||
mkdir -p "$PDF_DEST" "$ATTACH_DEST"
|
||||
|
||||
|
||||
# Sync PDF files from remote host
|
||||
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:"$PDF_SRC/" "$PDF_DEST/"
|
||||
|
||||
# Sync attachment files from remote host
|
||||
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:"$ATTACH_SRC/" "$ATTACH_DEST/"
|
||||
|
||||
echo "Sync complete. Files are in $TARGET_DIR (pdf/ and attachments_files/)"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
FILENAME=backups/backup_$(date +'%Y%m%d-%H%M%S').sql.gz
|
||||
mysqldump cmc | gzip > $FILENAME
|
||||
rclone copy $FILENAME gdrivebackups:database/
|
||||
rclone sync cmc-sales/app/webroot/pdf gdrivebackups:pdf/
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ID=$(docker ps -f ancestor=cmc:latest -q)
|
||||
docker kill $ID
|
||||
docker build . -t "cmc:latest" --platform linux/amd64
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ID=$(docker ps -f ancestor=cmc:stg -q)
|
||||
docker kill $ID
|
||||
docker build -f Dockerfile_stg . -t "cmc:stg"
|
||||
|
|
@ -446,7 +446,19 @@ class EmailComponent extends Object{
|
|||
*/
|
||||
function __createHeader() {
|
||||
if ($this->delivery == 'smtp') {
|
||||
$this->__header[] = 'To: ' . $this->__formatAddress($this->to);
|
||||
// Handle both string and array for TO field
|
||||
error_log("[EmailComponent] Creating TO header. \$this->to = " . print_r($this->to, true));
|
||||
error_log("[EmailComponent] is_array(\$this->to) = " . (is_array($this->to) ? 'true' : 'false'));
|
||||
if (is_array($this->to)) {
|
||||
$formatted = implode(', ', array_map(array($this, '__formatAddress'), $this->to));
|
||||
error_log("[EmailComponent] Formatted TO addresses: " . $formatted);
|
||||
$this->__header[] = 'To: ' . $formatted;
|
||||
} else {
|
||||
$formatted = $this->__formatAddress($this->to);
|
||||
error_log("[EmailComponent] Formatted TO address (single): " . $formatted);
|
||||
$this->__header[] = 'To: ' . $formatted;
|
||||
}
|
||||
error_log("[EmailComponent] TO header added: " . end($this->__header));
|
||||
}
|
||||
$this->__header[] = 'From: ' . $this->__formatAddress($this->from);
|
||||
|
||||
|
|
@ -712,8 +724,12 @@ class EmailComponent extends Object{
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($this->to, true))) {
|
||||
return false;
|
||||
// Handle both single email (string) and multiple emails (array) for TO field
|
||||
$toRecipients = is_array($this->to) ? $this->to : array($this->to);
|
||||
foreach ($toRecipients as $to) {
|
||||
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($to, true))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->cc as $cc) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
NameVirtualHost *:80
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/www/cmc-sales/app/webroot
|
||||
|
||||
<Directory /var/www/cmc-sales/app/webroot>
|
||||
Options FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Send Apache logs to stdout/stderr for Docker
|
||||
ErrorLog /dev/stderr
|
||||
CustomLog /dev/stdout combined
|
||||
|
|
@ -9,4 +14,4 @@ CustomLog /dev/stdout combined
|
|||
# Ensure PHP errors are also logged
|
||||
php_flag log_errors on
|
||||
php_value error_log /dev/stderr
|
||||
</VirtualHost>
|
||||
</VirtualHost>
|
||||
|
|
@ -2,6 +2,14 @@ server {
|
|||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-go:8080;
|
||||
proxy_read_timeout 300s;
|
||||
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 / {
|
||||
proxy_pass http://cmc-php:80;
|
||||
proxy_read_timeout 300s;
|
||||
|
|
|
|||
26
conf/nginx-site.prod.conf
Normal file
26
conf/nginx-site.prod.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
server {
|
||||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-prod-go:8082;
|
||||
proxy_read_timeout 300s;
|
||||
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 / {
|
||||
proxy_pass http://cmc-prod-php:80;
|
||||
proxy_read_timeout 300s;
|
||||
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;
|
||||
}
|
||||
|
||||
listen 0.0.0.0:80;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
26
conf/nginx-site.stg.conf
Normal file
26
conf/nginx-site.stg.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
server {
|
||||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-stg-go:8082;
|
||||
proxy_read_timeout 300s;
|
||||
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 / {
|
||||
proxy_pass http://cmc-stg-php:80;
|
||||
proxy_read_timeout 300s;
|
||||
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;
|
||||
}
|
||||
|
||||
listen 0.0.0.0:80;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
55
deploy/Caddyfile
Normal file
55
deploy/Caddyfile
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
stg.cmctechnologies.com.au {
|
||||
reverse_proxy localhost:8081
|
||||
encode gzip
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
log {
|
||||
output file /var/log/caddy/stg-access.log
|
||||
format console
|
||||
}
|
||||
}
|
||||
|
||||
mail.stg.cmctechnologies.com.au {
|
||||
basic_auth {
|
||||
mailpit $2a$14$yTNicvMBIwF5cBNGnM3Ya.EIagOkP1Y0..qvMfdwUzUoN6Okw.nUG
|
||||
}
|
||||
reverse_proxy localhost:8025
|
||||
}
|
||||
|
||||
localhost:2019 {
|
||||
log_skip
|
||||
}
|
||||
|
||||
prod.cmctechnologies.com.au {
|
||||
reverse_proxy localhost:8080
|
||||
encode gzip
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
log {
|
||||
output file /var/log/caddy/prod-access.log
|
||||
format console
|
||||
}
|
||||
}
|
||||
|
||||
sales.cmctechnologies.com.au {
|
||||
reverse_proxy localhost:8080
|
||||
encode gzip
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
log {
|
||||
output file /var/log/caddy/prod-access.log
|
||||
format console
|
||||
}
|
||||
}
|
||||
87
deploy/deploy-prod.sh
Executable file
87
deploy/deploy-prod.sh
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
#!/bin/bash
|
||||
# Deploy production environment for cmc-sales
|
||||
|
||||
# Usage: ./deploy-prod.sh [--no-cache]
|
||||
|
||||
USE_CACHE=true
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--no-cache" ]]; then
|
||||
USE_CACHE=false
|
||||
echo "No cache flag detected: will rebuild images without cache."
|
||||
fi
|
||||
done
|
||||
if [[ "$USE_CACHE" == "true" ]]; then
|
||||
echo "Using cached layers for docker build."
|
||||
fi
|
||||
|
||||
echo "Starting production deployment for cmc-sales..."
|
||||
echo "Setting variables..."
|
||||
SERVER="cmc"
|
||||
REPO="git@code.springupsoftware.com:cmc/cmc-sales.git"
|
||||
BRANCH="master"
|
||||
PROD_DIR="cmc-sales-prod"
|
||||
|
||||
echo "Connecting to server $SERVER via SSH..."
|
||||
# Pass variables into SSH session
|
||||
ssh $SERVER \
|
||||
"SERVER=$SERVER REPO='$REPO' BRANCH='$BRANCH' PROD_DIR='$PROD_DIR' USE_CACHE='$USE_CACHE' bash -s" << 'ENDSSH'
|
||||
set -e
|
||||
echo "Connected to $SERVER."
|
||||
cd /home/cmc
|
||||
# Clone or update production branch
|
||||
if [ -d "$PROD_DIR" ]; then
|
||||
echo "Updating existing production directory $PROD_DIR..."
|
||||
cd "$PROD_DIR"
|
||||
git fetch origin
|
||||
git checkout $BRANCH
|
||||
git reset --hard origin/$BRANCH
|
||||
else
|
||||
echo "Cloning repository $REPO to $PROD_DIR..."
|
||||
git clone -b $BRANCH $REPO $PROD_DIR
|
||||
cd "$PROD_DIR"
|
||||
fi
|
||||
|
||||
# Create .env.prod file for docker-compose
|
||||
COMPOSE_ENV_PATH="/home/cmc/$PROD_DIR/.env.prod"
|
||||
echo "(Re)creating .env.prod file for docker-compose..."
|
||||
cat > "$COMPOSE_ENV_PATH" <<'COMPOSEENVEOF'
|
||||
# SMTP Configuration for postfix relay
|
||||
SMTP_USERNAME=sales
|
||||
SMTP_PASSWORD=S%s'mMZ})MGsg$k!5N|mPSQ>
|
||||
COMPOSEENVEOF
|
||||
|
||||
# Create .env file for go-app if it doesn't exist
|
||||
ENV_PATH="/home/cmc/$PROD_DIR/go-app/.env"
|
||||
echo "(Re)creating .env file for go-app..."
|
||||
cat > "$ENV_PATH" <<'ENVEOF'
|
||||
# Database configuration
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_USER=cmc
|
||||
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME=cmc
|
||||
|
||||
# Root database password (for dbshell-root)
|
||||
DB_ROOT_PASSWORD=secureRootPassword
|
||||
|
||||
# Environment variables for Go app mail configuration
|
||||
SMTP_HOST=postfix
|
||||
SMTP_PORT=25
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="CMC Sales <sales@cmctechnologies.com.au>"
|
||||
ENVEOF
|
||||
|
||||
if [[ "$USE_CACHE" == "false" ]]; then
|
||||
echo "Building and starting docker compose for production (no cache)..."
|
||||
docker compose --env-file .env.prod -f docker-compose.prod.yml build --no-cache
|
||||
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --remove-orphans
|
||||
else
|
||||
echo "Building and starting docker compose for production (using cache)..."
|
||||
docker compose --env-file .env.prod -f docker-compose.prod.yml build
|
||||
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --remove-orphans
|
||||
fi
|
||||
|
||||
echo "Checking running containers..."
|
||||
echo "Production deployment complete."
|
||||
ENDSSH
|
||||
78
deploy/deploy-stg.sh
Executable file
78
deploy/deploy-stg.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
#!/bin/bash
|
||||
# Deploy staging environment for cmc-sales
|
||||
|
||||
# Usage: ./deploy-stg.sh [--no-cache]
|
||||
|
||||
USE_CACHE=true
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--no-cache" ]]; then
|
||||
USE_CACHE=false
|
||||
echo "No cache flag detected: will rebuild images without cache."
|
||||
fi
|
||||
done
|
||||
if [[ "$USE_CACHE" == "true" ]]; then
|
||||
echo "Using cached layers for docker build."
|
||||
fi
|
||||
|
||||
echo "Starting staging deployment for cmc-sales..."
|
||||
echo "Setting variables..."
|
||||
SERVER="cmc"
|
||||
REPO="git@code.springupsoftware.com:cmc/cmc-sales.git"
|
||||
BRANCH="stg"
|
||||
STAGING_DIR="cmc-sales-staging"
|
||||
|
||||
echo "Connecting to server $SERVER via SSH..."
|
||||
# Pass variables into SSH session
|
||||
ssh $SERVER \
|
||||
"SERVER=$SERVER REPO='$REPO' BRANCH='$BRANCH' STAGING_DIR='$STAGING_DIR' USE_CACHE='$USE_CACHE' bash -s" << 'ENDSSH'
|
||||
set -e
|
||||
echo "Connected to $SERVER."
|
||||
cd /home/cmc
|
||||
# Clone or update staging branch
|
||||
if [ -d "$STAGING_DIR" ]; then
|
||||
echo "Updating existing staging directory $STAGING_DIR..."
|
||||
cd "$STAGING_DIR"
|
||||
git fetch origin
|
||||
git checkout $BRANCH
|
||||
git reset --hard origin/$BRANCH
|
||||
else
|
||||
echo "Cloning repository $REPO to $STAGING_DIR..."
|
||||
git clone -b $BRANCH $REPO $STAGING_DIR
|
||||
cd "$STAGING_DIR"
|
||||
fi
|
||||
|
||||
# Create .env file for go-app if it doesn't exist
|
||||
ENV_PATH="/home/cmc/$STAGING_DIR/go-app/.env"
|
||||
echo "(Re)creating .env file for go-app..."
|
||||
cat > "$ENV_PATH" <<'ENVEOF'
|
||||
# Database configuration
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_USER=cmc
|
||||
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME=cmc
|
||||
|
||||
# Root database password (for dbshell-root)
|
||||
DB_ROOT_PASSWORD=secureRootPassword
|
||||
|
||||
# Environment variables for Go app mail configuration
|
||||
SMTP_HOST=postfix
|
||||
SMTP_PORT=25
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="CMC Sales <sales@cmctechnologies.com.au>"
|
||||
ENVEOF
|
||||
|
||||
if [[ "$USE_CACHE" == "false" ]]; then
|
||||
echo "Building and starting docker compose for staging (no cache)..."
|
||||
docker compose -f docker-compose.stg.yml build --no-cache
|
||||
docker compose -f docker-compose.stg.yml up -d --remove-orphans
|
||||
else
|
||||
echo "Building and starting docker compose for staging (using cache)..."
|
||||
docker compose -f docker-compose.stg.yml build
|
||||
docker compose -f docker-compose.stg.yml up -d --remove-orphans
|
||||
fi
|
||||
|
||||
echo "Checking running containers..."
|
||||
echo "Deployment complete."
|
||||
ENDSSH
|
||||
16
deploy/scripts/backup_db.sh
Normal file
16
deploy/scripts/backup_db.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
BACKUP_DIR="/home/cmc/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
FILENAME="$BACKUP_DIR/backup_$(date +'%Y%m%d-%H%M%S').sql.gz"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting MariaDB backup to $FILENAME"
|
||||
if docker exec cmc-prod-db sh -c 'mariadb-dump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' | gzip > "$FILENAME"; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup successful: $FILENAME"
|
||||
else
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rclone copy "$FILENAME" gdrivebackups:database/
|
||||
# rclone sync /home/cmc/cmc-sales/app/webroot/pdf gdrivebackups:pdf/
|
||||
40
deploy/scripts/run_all_migrations.sh
Normal file
40
deploy/scripts/run_all_migrations.sh
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
# Default to staging
|
||||
TARGET="stg"
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "-target" ]]; then
|
||||
NEXT_IS_TARGET=1
|
||||
continue
|
||||
fi
|
||||
if [[ $NEXT_IS_TARGET == 1 ]]; then
|
||||
TARGET="$arg"
|
||||
NEXT_IS_TARGET=0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$TARGET" == "prod" ]]; then
|
||||
DB_CONTAINER="cmc-prod-db"
|
||||
DB_USER="cmc"
|
||||
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
|
||||
DB_NAME="cmc"
|
||||
SQL_DIR="/home/cmc/cmc-sales-prod/go-app/sql/schema"
|
||||
else
|
||||
DB_CONTAINER="cmc-db"
|
||||
DB_USER="cmc"
|
||||
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
|
||||
DB_NAME="cmc"
|
||||
SQL_DIR="/home/cmc/cmc-sales-staging/go-app/sql/schema"
|
||||
fi
|
||||
|
||||
for sqlfile in "$SQL_DIR"/*.sql; do
|
||||
# Skip files starting with ignore_
|
||||
if [[ $(basename "$sqlfile") == ignore_* ]]; then
|
||||
echo "Skipping ignored migration: $sqlfile"
|
||||
continue
|
||||
fi
|
||||
echo "Running migration: $sqlfile on $TARGET ($DB_CONTAINER)"
|
||||
docker cp "$sqlfile" "$DB_CONTAINER":/tmp/migration.sql
|
||||
docker exec "$DB_CONTAINER" sh -c "mariadb -u $DB_USER -p'$DB_PASS' $DB_NAME < /tmp/migration.sql"
|
||||
done
|
||||
echo "All migrations applied."
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
cmc-php-production:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php-production
|
||||
depends_on:
|
||||
- db-production
|
||||
ports:
|
||||
- "127.0.0.1:8093:80" # Only accessible from localhost
|
||||
volumes:
|
||||
- production_pdf_data:/var/www/cmc-sales/app/webroot/pdf
|
||||
- production_attachments_data:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
db-production:
|
||||
image: mariadb:latest
|
||||
container_name: cmc-db-production
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD_PRODUCTION}
|
||||
MYSQL_DATABASE: cmc
|
||||
MYSQL_USER: cmc
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD_PRODUCTION}
|
||||
volumes:
|
||||
- production_db_data:/var/lib/mysql
|
||||
- ./backups:/backups:ro
|
||||
restart: unless-stopped
|
||||
# No external port exposure for security
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 4G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 1G
|
||||
|
||||
cmc-go-production:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go.production
|
||||
container_name: cmc-go-production
|
||||
environment:
|
||||
DB_HOST: db-production
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc
|
||||
DB_PASSWORD: ${DB_PASSWORD_PRODUCTION}
|
||||
DB_NAME: cmc
|
||||
PORT: 8080
|
||||
APP_ENV: production
|
||||
depends_on:
|
||||
db-production:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "127.0.0.1:8094:8080" # Only accessible from localhost
|
||||
volumes:
|
||||
- production_pdf_data:/root/webroot/pdf
|
||||
- ./credentials/production:/root/credentials:ro
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
production_db_data:
|
||||
production_pdf_data:
|
||||
production_attachments_data:
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
cmc-php-staging:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.ubuntu-php
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php-staging
|
||||
depends_on:
|
||||
- db-staging
|
||||
ports:
|
||||
- "127.0.0.1:8091:80"
|
||||
volumes:
|
||||
- ./app:/var/www/cmc-sales/app
|
||||
- staging_pdf_data:/var/www/cmc-sales/app/webroot/pdf
|
||||
- staging_attachments_data:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_ENV=staging
|
||||
- DB_HOST=db-staging
|
||||
- DB_NAME=cmc_staging
|
||||
- DB_USER=cmc_staging
|
||||
- DB_PASSWORD=${DB_PASSWORD_STAGING:-staging_password}
|
||||
|
||||
db-staging:
|
||||
image: mariadb:10.11
|
||||
container_name: cmc-db-staging
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD_STAGING:-root_password}
|
||||
MYSQL_DATABASE: cmc_staging
|
||||
MYSQL_USER: cmc_staging
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD_STAGING:-staging_password}
|
||||
volumes:
|
||||
- staging_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3307:3306"
|
||||
|
||||
cmc-go-staging:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go.staging
|
||||
container_name: cmc-go-staging
|
||||
environment:
|
||||
DB_HOST: db-staging
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc_staging
|
||||
DB_PASSWORD: ${DB_PASSWORD_STAGING:-staging_password}
|
||||
DB_NAME: cmc_staging
|
||||
PORT: 8080
|
||||
APP_ENV: staging
|
||||
depends_on:
|
||||
- db-staging
|
||||
ports:
|
||||
- "127.0.0.1:8092:8080"
|
||||
volumes:
|
||||
- staging_pdf_data:/root/webroot/pdf
|
||||
- ./credentials/staging:/root/credentials:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
staging_db_data:
|
||||
staging_pdf_data:
|
||||
staging_attachments_data:
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
cmc-php-staging:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php-staging
|
||||
depends_on:
|
||||
- db-staging
|
||||
ports:
|
||||
- "127.0.0.1:8091:80" # Only accessible from localhost
|
||||
volumes:
|
||||
- staging_pdf_data:/var/www/cmc-sales/app/webroot/pdf
|
||||
- staging_attachments_data:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_ENV=staging
|
||||
|
||||
db-staging:
|
||||
image: mariadb:latest
|
||||
container_name: cmc-db-staging
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD_STAGING}
|
||||
MYSQL_DATABASE: cmc_staging
|
||||
MYSQL_USER: cmc_staging
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD_STAGING}
|
||||
volumes:
|
||||
- staging_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3307:3306" # Only accessible from localhost
|
||||
|
||||
cmc-go-staging:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go.staging
|
||||
container_name: cmc-go-staging
|
||||
environment:
|
||||
DB_HOST: db-staging
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc_staging
|
||||
DB_PASSWORD: ${DB_PASSWORD_STAGING}
|
||||
DB_NAME: cmc_staging
|
||||
PORT: 8080
|
||||
APP_ENV: staging
|
||||
depends_on:
|
||||
db-staging:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "127.0.0.1:8092:8080" # Only accessible from localhost
|
||||
volumes:
|
||||
- staging_pdf_data:/root/webroot/pdf
|
||||
- ./credentials/staging:/root/credentials:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
staging_db_data:
|
||||
staging_pdf_data:
|
||||
staging_attachments_data:
|
||||
116
docker-compose.prod.yml
Normal file
116
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
services:
|
||||
postfix:
|
||||
image: boky/postfix
|
||||
restart: unless-stopped
|
||||
container_name: cmc-prod-postfix
|
||||
env_file:
|
||||
- .env.prod
|
||||
# Production: relay to Gmail SMTP
|
||||
environment:
|
||||
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
|
||||
# Gmail SMTP relay settings
|
||||
- RELAYHOST=smtp-relay.gmail.com
|
||||
- RELAYHOST_PORT=587
|
||||
# SMTP_USERNAME and SMTP_PASSWORD are loaded from .env.prod via env_file
|
||||
- SMTP_TLS_SECURITY_LEVEL=encrypt
|
||||
- SMTP_USE_TLS=yes
|
||||
- SMTP_USE_STARTTLS=yes
|
||||
# --- Mailpit relay (for testing only) ---
|
||||
# - RELAYHOST=mailpit:1025
|
||||
networks:
|
||||
- cmc-prod-network
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: cmc-prod-nginx
|
||||
hostname: nginx-prod
|
||||
ports:
|
||||
- "8080:80" # Production nginx on port 8080 to avoid conflict
|
||||
volumes:
|
||||
- ./conf/nginx-site.prod.conf:/etc/nginx/conf.d/cmc.conf
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
depends_on:
|
||||
- cmc-prod-php
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cmc-prod-network
|
||||
|
||||
cmc-prod-php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod.php
|
||||
container_name: cmc-prod-php
|
||||
environment:
|
||||
MAIL_HOST: postfix
|
||||
MAIL_PORT: 25
|
||||
DB_HOST: cmc-prod-db
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
- /home/cmc/files/emails:/var/www/emails
|
||||
- /home/cmc/files/vault:/var/www/vault
|
||||
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
networks:
|
||||
- cmc-prod-network
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cmc-prod-db
|
||||
|
||||
cmc-prod-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod.go
|
||||
container_name: cmc-prod-go
|
||||
environment:
|
||||
DB_HOST: cmc-prod-db
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
PORT: 8082
|
||||
SMTP_HOST: postfix
|
||||
SMTP_PORT: 25
|
||||
SMTP_USER: ""
|
||||
SMTP_PASS: ""
|
||||
SMTP_FROM: "sales@cmctechnologies.com.au"
|
||||
ports:
|
||||
- "8083:8082"
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/root/webroot/pdf:ro
|
||||
- /home/cmc/files/emails:/var/www/emails
|
||||
- /home/cmc/files/vault:/var/www/vault
|
||||
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
|
||||
networks:
|
||||
- cmc-prod-network
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cmc-prod-db
|
||||
|
||||
cmc-prod-db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod.db
|
||||
container_name: cmc-prod-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: secureRootPassword
|
||||
MYSQL_DATABASE: cmc
|
||||
MYSQL_USER: cmc
|
||||
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3307:3306"
|
||||
networks:
|
||||
- cmc-prod-network
|
||||
|
||||
networks:
|
||||
cmc-stg-network:
|
||||
|
||||
cmc-prod-network:
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
124
docker-compose.stg.yml
Normal file
124
docker-compose.stg.yml
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
services:
|
||||
postfix:
|
||||
image: boky/postfix
|
||||
restart: unless-stopped
|
||||
container_name: cmc-stg-postfix
|
||||
# Staging: relay to Mailpit (no authentication required)
|
||||
environment:
|
||||
- RELAYHOST=mailpit:1025
|
||||
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
|
||||
# --- Gmail SMTP relay settings (uncomment for production) ---
|
||||
# - RELAYHOST=smtp-relay.gmail.com
|
||||
# - RELAYHOST_PORT=587
|
||||
# - SMTP_USERNAME=sales
|
||||
# - SMTP_PASSWORD=S%s'mMZ})MGsg$k!5N|mPSQ>
|
||||
# - SMTP_TLS_SECURITY_LEVEL=encrypt
|
||||
# - SMTP_USE_TLS=yes
|
||||
# - SMTP_USE_STARTTLS=yes
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: cmc-stg-nginx
|
||||
hostname: nginx-stg
|
||||
ports:
|
||||
- "8081:80" # Staging nginx on different port
|
||||
volumes:
|
||||
- ./conf/nginx-site.stg.conf:/etc/nginx/conf.d/cmc.conf
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
depends_on:
|
||||
- cmc-stg-php
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
|
||||
cmc-stg-php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.stg.php
|
||||
container_name: cmc-stg-php
|
||||
environment:
|
||||
MAIL_HOST: postfix
|
||||
MAIL_PORT: 25
|
||||
DB_HOST: cmc-stg-db
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
- /home/cmc/files/emails:/var/www/emails
|
||||
- /home/cmc/files/vault:/var/www/vault
|
||||
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cmc-stg-db
|
||||
|
||||
cmc-stg-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.stg.go
|
||||
container_name: cmc-stg-go
|
||||
environment:
|
||||
DB_HOST: cmc-stg-db
|
||||
DB_PORT: 3306
|
||||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
PORT: 8082
|
||||
SMTP_HOST: postfix
|
||||
SMTP_PORT: 25
|
||||
SMTP_USER: ""
|
||||
SMTP_PASS: ""
|
||||
SMTP_FROM: "sales@cmctechnologies.com.au"
|
||||
ports:
|
||||
- "8082:8082"
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/root/webroot/pdf:ro
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cmc-stg-db
|
||||
|
||||
cmc-stg-db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.stg.db
|
||||
container_name: cmc-stg-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: secureRootPassword
|
||||
MYSQL_DATABASE: cmc
|
||||
MYSQL_USER: cmc
|
||||
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3308:3306"
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: mailpit
|
||||
ports:
|
||||
- "8025:8025" # Mailpit web UI
|
||||
- "1025:1025" # SMTP
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
- cmc-sales-prod_cmc-prod-network
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
networks:
|
||||
cmc-stg-network:
|
||||
|
||||
cmc-sales-prod_cmc-prod-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
|
@ -16,7 +16,7 @@ services:
|
|||
cmc-php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.stg.php
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php
|
||||
depends_on:
|
||||
|
|
@ -60,7 +60,7 @@ services:
|
|||
cmc-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go
|
||||
dockerfile: Dockerfile.local.go
|
||||
container_name: cmc-go
|
||||
environment:
|
||||
DB_HOST: db
|
||||
|
|
@ -75,25 +75,12 @@ services:
|
|||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./app/webroot/pdf:/root/webroot/pdf
|
||||
- ./go-app:/app
|
||||
- ./go-app/.air.toml:/root/.air.toml
|
||||
- ./go-app/.env.example:/root/.env
|
||||
networks:
|
||||
- cmc-network
|
||||
restart: unless-stopped
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./go-app
|
||||
ignore:
|
||||
- ./go-app/bin
|
||||
- ./go-app/.env
|
||||
- ./go-app/tmp
|
||||
- "**/.*" # Ignore hidden files
|
||||
- action: sync
|
||||
path: ./go-app/templates
|
||||
target: /app/templates
|
||||
- action: sync
|
||||
path: ./go-app/static
|
||||
target: /app/static
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
|
|
|||
25
go-app/.air.toml
Normal file
25
go-app/.air.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Air configuration for Go hot reload
|
||||
root = "./"
|
||||
cmd = ["air"]
|
||||
|
||||
[build]
|
||||
cmd = "go build -o server cmd/server/main.go"
|
||||
bin = "server"
|
||||
include = ["cmd", "internal", "go.mod", "go.sum"]
|
||||
exclude = ["bin", "tmp", ".env"]
|
||||
delay = 1000
|
||||
log = "stdout"
|
||||
kill_on_error = true
|
||||
|
||||
color = true
|
||||
|
||||
[[watch]]
|
||||
path = "templates"
|
||||
reload = true
|
||||
[[watch]]
|
||||
path = "static"
|
||||
reload = true
|
||||
[[watch]]
|
||||
path = "tmp"
|
||||
reload = false
|
||||
mkdir = true
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Database configuration
|
||||
DB_HOST=localhost
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_USER=cmc
|
||||
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
||||
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
"github.com/go-co-op/gocron"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
|
|
@ -52,242 +55,223 @@ func main() {
|
|||
log.Fatal("Failed to initialize templates:", err)
|
||||
}
|
||||
|
||||
// Create handlers
|
||||
customerHandler := handlers.NewCustomerHandler(queries)
|
||||
productHandler := handlers.NewProductHandler(queries)
|
||||
purchaseOrderHandler := handlers.NewPurchaseOrderHandler(queries)
|
||||
enquiryHandler := handlers.NewEnquiryHandler(queries)
|
||||
documentHandler := handlers.NewDocumentHandler(queries)
|
||||
pageHandler := handlers.NewPageHandler(queries, tmpl, database)
|
||||
addressHandler := handlers.NewAddressHandler(queries)
|
||||
attachmentHandler := handlers.NewAttachmentHandler(queries)
|
||||
countryHandler := handlers.NewCountryHandler(queries)
|
||||
statusHandler := handlers.NewStatusHandler(queries)
|
||||
lineItemHandler := handlers.NewLineItemHandler(queries)
|
||||
emailHandler := handlers.NewEmailHandler(queries, database)
|
||||
// Initialize email service
|
||||
emailService := email.GetEmailService()
|
||||
|
||||
// Load handlers
|
||||
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
|
||||
|
||||
// Setup routes
|
||||
r := mux.NewRouter()
|
||||
goRouter := r.PathPrefix("/go").Subrouter()
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
goRouter.PathPrefix("/static/").Handler(http.StripPrefix("/go/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// PDF files (matching CakePHP structure)
|
||||
r.PathPrefix("/pdf/").Handler(http.StripPrefix("/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
|
||||
// PDF files
|
||||
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
|
||||
|
||||
// API routes
|
||||
api := r.PathPrefix("/api/v1").Subrouter()
|
||||
// Quote routes
|
||||
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
|
||||
|
||||
// Customer routes
|
||||
api.HandleFunc("/customers", customerHandler.List).Methods("GET")
|
||||
api.HandleFunc("/customers", customerHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/customers/search", customerHandler.Search).Methods("GET")
|
||||
// The following routes are currently disabled:
|
||||
/*
|
||||
// API routes
|
||||
api := r.PathPrefix("/api/v1").Subrouter()
|
||||
api.HandleFunc("/customers", customerHandler.List).Methods("GET")
|
||||
api.HandleFunc("/customers", customerHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/customers/search", customerHandler.Search).Methods("GET")
|
||||
// Product routes
|
||||
api.HandleFunc("/products", productHandler.List).Methods("GET")
|
||||
api.HandleFunc("/products", productHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/products/{id}", productHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/products/search", productHandler.Search).Methods("GET")
|
||||
// Purchase Order routes
|
||||
api.HandleFunc("/purchase-orders", purchaseOrderHandler.List).Methods("GET")
|
||||
api.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/purchase-orders/search", purchaseOrderHandler.Search).Methods("GET")
|
||||
// Enquiry routes
|
||||
api.HandleFunc("/enquiries", enquiryHandler.List).Methods("GET")
|
||||
api.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
|
||||
// Document routes
|
||||
api.HandleFunc("/documents", documentHandler.List).Methods("GET")
|
||||
api.HandleFunc("/documents", documentHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
|
||||
api.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
|
||||
api.HandleFunc("/documents/search", documentHandler.Search).Methods("GET")
|
||||
// Address routes
|
||||
api.HandleFunc("/addresses", addressHandler.List).Methods("GET")
|
||||
api.HandleFunc("/addresses", addressHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/addresses/customer/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
|
||||
// Attachment routes
|
||||
api.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
|
||||
api.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
|
||||
api.HandleFunc("/attachments", attachmentHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
||||
// Country routes
|
||||
api.HandleFunc("/countries", countryHandler.List).Methods("GET")
|
||||
api.HandleFunc("/countries", countryHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/countries/complete", countryHandler.CompleteCountry).Methods("GET")
|
||||
// Status routes
|
||||
api.HandleFunc("/statuses", statusHandler.List).Methods("GET")
|
||||
api.HandleFunc("/statuses", statusHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/statuses/json/{selectedId}", statusHandler.JsonList).Methods("GET")
|
||||
// Line Item routes
|
||||
api.HandleFunc("/line-items", lineItemHandler.List).Methods("GET")
|
||||
api.HandleFunc("/line-items", lineItemHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/line-items/document/{documentID}/table", lineItemHandler.GetTable).Methods("GET")
|
||||
// Health check
|
||||
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}).Methods("GET")
|
||||
// Recent activity endpoint
|
||||
r.HandleFunc("/api/recent-activity", documentHandler.GetRecentActivity).Methods("GET")
|
||||
// Page routes
|
||||
r.HandleFunc("/", pageHandler.Home).Methods("GET")
|
||||
// Customer pages
|
||||
r.HandleFunc("/customers", pageHandler.CustomersIndex).Methods("GET")
|
||||
r.HandleFunc("/customers/new", pageHandler.CustomersNew).Methods("GET")
|
||||
r.HandleFunc("/customers/search", pageHandler.CustomersSearch).Methods("GET")
|
||||
r.HandleFunc("/customers/{id}", pageHandler.CustomersShow).Methods("GET")
|
||||
r.HandleFunc("/customers/{id}/edit", pageHandler.CustomersEdit).Methods("GET")
|
||||
// Product pages
|
||||
r.HandleFunc("/products", pageHandler.ProductsIndex).Methods("GET")
|
||||
r.HandleFunc("/products/new", pageHandler.ProductsNew).Methods("GET")
|
||||
r.HandleFunc("/products/search", pageHandler.ProductsSearch).Methods("GET")
|
||||
r.HandleFunc("/products/{id}", pageHandler.ProductsShow).Methods("GET")
|
||||
r.HandleFunc("/products/{id}/edit", pageHandler.ProductsEdit).Methods("GET")
|
||||
// Purchase Order pages
|
||||
r.HandleFunc("/purchase-orders", pageHandler.PurchaseOrdersIndex).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/new", pageHandler.PurchaseOrdersNew).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/search", pageHandler.PurchaseOrdersSearch).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/{id}", pageHandler.PurchaseOrdersShow).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/{id}/edit", pageHandler.PurchaseOrdersEdit).Methods("GET")
|
||||
// Enquiry pages
|
||||
r.HandleFunc("/enquiries", pageHandler.EnquiriesIndex).Methods("GET")
|
||||
r.HandleFunc("/enquiries/new", pageHandler.EnquiriesNew).Methods("GET")
|
||||
r.HandleFunc("/enquiries/search", pageHandler.EnquiriesSearch).Methods("GET")
|
||||
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
|
||||
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
|
||||
// Document pages
|
||||
r.HandleFunc("/documents", pageHandler.DocumentsIndex).Methods("GET")
|
||||
r.HandleFunc("/documents/search", pageHandler.DocumentsSearch).Methods("GET")
|
||||
r.HandleFunc("/documents/view/{id}", pageHandler.DocumentsView).Methods("GET")
|
||||
r.HandleFunc("/documents/{id}", pageHandler.DocumentsShow).Methods("GET")
|
||||
r.HandleFunc("/documents/pdf/{id}", documentHandler.GeneratePDF).Methods("GET")
|
||||
// Address routes (matching CakePHP)
|
||||
r.HandleFunc("/addresses", addressHandler.List).Methods("GET")
|
||||
r.HandleFunc("/addresses/view/{id}", addressHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/addresses/add/{customerid}", addressHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/addresses/add_another/{increment}", addressHandler.AddAnother).Methods("GET")
|
||||
r.HandleFunc("/addresses/remove_another/{increment}", addressHandler.RemoveAnother).Methods("GET")
|
||||
r.HandleFunc("/addresses/edit/{id}", addressHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/addresses/customer_addresses/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
|
||||
// Attachment routes (matching CakePHP)
|
||||
r.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
|
||||
r.HandleFunc("/attachments/view/{id}", attachmentHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
|
||||
r.HandleFunc("/attachments/add", attachmentHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/attachments/edit/{id}", attachmentHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/attachments/delete/{id}", attachmentHandler.Delete).Methods("POST")
|
||||
// Country routes (matching CakePHP)
|
||||
r.HandleFunc("/countries", countryHandler.List).Methods("GET")
|
||||
r.HandleFunc("/countries/view/{id}", countryHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/countries/add", countryHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/countries/edit/{id}", countryHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/countries/delete/{id}", countryHandler.Delete).Methods("POST")
|
||||
r.HandleFunc("/countries/complete_country", countryHandler.CompleteCountry).Methods("GET")
|
||||
// Status routes (matching CakePHP)
|
||||
r.HandleFunc("/statuses", statusHandler.List).Methods("GET")
|
||||
r.HandleFunc("/statuses/view/{id}", statusHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/statuses/add", statusHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/statuses/edit/{id}", statusHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/statuses/delete/{id}", statusHandler.Delete).Methods("POST")
|
||||
r.HandleFunc("/statuses/json_list/{selectedId}", statusHandler.JsonList).Methods("GET")
|
||||
// Line Item routes (matching CakePHP)
|
||||
r.HandleFunc("/line_items/ajax_add", lineItemHandler.AjaxAdd).Methods("POST")
|
||||
r.HandleFunc("/line_items/ajax_edit", lineItemHandler.AjaxEdit).Methods("POST")
|
||||
r.HandleFunc("/line_items/ajax_delete/{id}", lineItemHandler.AjaxDelete).Methods("POST")
|
||||
r.HandleFunc("/line_items/get_table/{documentID}", lineItemHandler.GetTable).Methods("GET")
|
||||
r.HandleFunc("/line_items/edit/{id}", lineItemHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/line_items/add/{documentID}", lineItemHandler.Create).Methods("GET", "POST")
|
||||
// HTMX endpoints
|
||||
r.HandleFunc("/customers", customerHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
|
||||
r.HandleFunc("/products", productHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
|
||||
r.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
|
||||
r.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
|
||||
r.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
|
||||
r.HandleFunc("/documents", documentHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
|
||||
r.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
|
||||
*/
|
||||
|
||||
// Product routes
|
||||
api.HandleFunc("/products", productHandler.List).Methods("GET")
|
||||
api.HandleFunc("/products", productHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/products/{id}", productHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/products/search", productHandler.Search).Methods("GET")
|
||||
// Catch-all for everything else
|
||||
r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404 page not found"))
|
||||
})
|
||||
|
||||
// Purchase Order routes
|
||||
api.HandleFunc("/purchase-orders", purchaseOrderHandler.List).Methods("GET")
|
||||
api.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/purchase-orders/search", purchaseOrderHandler.Search).Methods("GET")
|
||||
|
||||
// Enquiry routes
|
||||
api.HandleFunc("/enquiries", enquiryHandler.List).Methods("GET")
|
||||
api.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
|
||||
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
|
||||
|
||||
// Document routes
|
||||
api.HandleFunc("/documents", documentHandler.List).Methods("GET")
|
||||
api.HandleFunc("/documents", documentHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
|
||||
api.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
|
||||
api.HandleFunc("/documents/search", documentHandler.Search).Methods("GET")
|
||||
|
||||
// Address routes
|
||||
api.HandleFunc("/addresses", addressHandler.List).Methods("GET")
|
||||
api.HandleFunc("/addresses", addressHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/addresses/{id}", addressHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/addresses/customer/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
|
||||
|
||||
// Attachment routes
|
||||
api.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
|
||||
api.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
|
||||
api.HandleFunc("/attachments", attachmentHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
||||
|
||||
// Country routes
|
||||
api.HandleFunc("/countries", countryHandler.List).Methods("GET")
|
||||
api.HandleFunc("/countries", countryHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/countries/{id}", countryHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/countries/complete", countryHandler.CompleteCountry).Methods("GET")
|
||||
|
||||
// Status routes
|
||||
api.HandleFunc("/statuses", statusHandler.List).Methods("GET")
|
||||
api.HandleFunc("/statuses", statusHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/statuses/{id}", statusHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/statuses/json/{selectedId}", statusHandler.JsonList).Methods("GET")
|
||||
|
||||
// Line Item routes
|
||||
api.HandleFunc("/line-items", lineItemHandler.List).Methods("GET")
|
||||
api.HandleFunc("/line-items", lineItemHandler.Create).Methods("POST")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Update).Methods("PUT")
|
||||
api.HandleFunc("/line-items/{id}", lineItemHandler.Delete).Methods("DELETE")
|
||||
api.HandleFunc("/line-items/document/{documentID}/table", lineItemHandler.GetTable).Methods("GET")
|
||||
|
||||
// Email routes
|
||||
api.HandleFunc("/emails", emailHandler.List).Methods("GET")
|
||||
api.HandleFunc("/emails/{id}", emailHandler.Get).Methods("GET")
|
||||
api.HandleFunc("/emails/{id}/content", emailHandler.StreamContent).Methods("GET")
|
||||
api.HandleFunc("/emails/{id}/attachments", emailHandler.ListAttachments).Methods("GET")
|
||||
api.HandleFunc("/emails/{id}/attachments/{attachmentId}/stream", emailHandler.StreamAttachment).Methods("GET")
|
||||
api.HandleFunc("/emails/search", emailHandler.Search).Methods("GET")
|
||||
|
||||
// Health check
|
||||
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}).Methods("GET")
|
||||
|
||||
// Recent activity endpoint
|
||||
r.HandleFunc("/api/recent-activity", documentHandler.GetRecentActivity).Methods("GET")
|
||||
|
||||
// Page routes
|
||||
r.HandleFunc("/", pageHandler.Home).Methods("GET")
|
||||
|
||||
// Customer pages
|
||||
r.HandleFunc("/customers", pageHandler.CustomersIndex).Methods("GET")
|
||||
r.HandleFunc("/customers/new", pageHandler.CustomersNew).Methods("GET")
|
||||
r.HandleFunc("/customers/search", pageHandler.CustomersSearch).Methods("GET")
|
||||
r.HandleFunc("/customers/{id}", pageHandler.CustomersShow).Methods("GET")
|
||||
r.HandleFunc("/customers/{id}/edit", pageHandler.CustomersEdit).Methods("GET")
|
||||
|
||||
// Product pages
|
||||
r.HandleFunc("/products", pageHandler.ProductsIndex).Methods("GET")
|
||||
r.HandleFunc("/products/new", pageHandler.ProductsNew).Methods("GET")
|
||||
r.HandleFunc("/products/search", pageHandler.ProductsSearch).Methods("GET")
|
||||
r.HandleFunc("/products/{id}", pageHandler.ProductsShow).Methods("GET")
|
||||
r.HandleFunc("/products/{id}/edit", pageHandler.ProductsEdit).Methods("GET")
|
||||
|
||||
// Purchase Order pages
|
||||
r.HandleFunc("/purchase-orders", pageHandler.PurchaseOrdersIndex).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/new", pageHandler.PurchaseOrdersNew).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/search", pageHandler.PurchaseOrdersSearch).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/{id}", pageHandler.PurchaseOrdersShow).Methods("GET")
|
||||
r.HandleFunc("/purchase-orders/{id}/edit", pageHandler.PurchaseOrdersEdit).Methods("GET")
|
||||
|
||||
// Enquiry pages
|
||||
r.HandleFunc("/enquiries", pageHandler.EnquiriesIndex).Methods("GET")
|
||||
r.HandleFunc("/enquiries/new", pageHandler.EnquiriesNew).Methods("GET")
|
||||
r.HandleFunc("/enquiries/search", pageHandler.EnquiriesSearch).Methods("GET")
|
||||
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
|
||||
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
|
||||
|
||||
// Document pages
|
||||
r.HandleFunc("/documents", pageHandler.DocumentsIndex).Methods("GET")
|
||||
r.HandleFunc("/documents/search", pageHandler.DocumentsSearch).Methods("GET")
|
||||
r.HandleFunc("/documents/view/{id}", pageHandler.DocumentsView).Methods("GET")
|
||||
r.HandleFunc("/documents/{id}", pageHandler.DocumentsShow).Methods("GET")
|
||||
r.HandleFunc("/documents/pdf/{id}", documentHandler.GeneratePDF).Methods("GET")
|
||||
|
||||
// Email pages
|
||||
r.HandleFunc("/emails", pageHandler.EmailsIndex).Methods("GET")
|
||||
r.HandleFunc("/emails/search", pageHandler.EmailsSearch).Methods("GET")
|
||||
r.HandleFunc("/emails/{id}", pageHandler.EmailsShow).Methods("GET")
|
||||
r.HandleFunc("/emails/{id}/attachments", pageHandler.EmailsAttachments).Methods("GET")
|
||||
|
||||
// Address routes (matching CakePHP)
|
||||
r.HandleFunc("/addresses", addressHandler.List).Methods("GET")
|
||||
r.HandleFunc("/addresses/view/{id}", addressHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/addresses/add/{customerid}", addressHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/addresses/add_another/{increment}", addressHandler.AddAnother).Methods("GET")
|
||||
r.HandleFunc("/addresses/remove_another/{increment}", addressHandler.RemoveAnother).Methods("GET")
|
||||
r.HandleFunc("/addresses/edit/{id}", addressHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/addresses/customer_addresses/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
|
||||
|
||||
// Attachment routes (matching CakePHP)
|
||||
r.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
|
||||
r.HandleFunc("/attachments/view/{id}", attachmentHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
|
||||
r.HandleFunc("/attachments/add", attachmentHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/attachments/edit/{id}", attachmentHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/attachments/delete/{id}", attachmentHandler.Delete).Methods("POST")
|
||||
|
||||
// Country routes (matching CakePHP)
|
||||
r.HandleFunc("/countries", countryHandler.List).Methods("GET")
|
||||
r.HandleFunc("/countries/view/{id}", countryHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/countries/add", countryHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/countries/edit/{id}", countryHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/countries/delete/{id}", countryHandler.Delete).Methods("POST")
|
||||
r.HandleFunc("/countries/complete_country", countryHandler.CompleteCountry).Methods("GET")
|
||||
|
||||
// Status routes (matching CakePHP)
|
||||
r.HandleFunc("/statuses", statusHandler.List).Methods("GET")
|
||||
r.HandleFunc("/statuses/view/{id}", statusHandler.Get).Methods("GET")
|
||||
r.HandleFunc("/statuses/add", statusHandler.Create).Methods("GET", "POST")
|
||||
r.HandleFunc("/statuses/edit/{id}", statusHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/statuses/delete/{id}", statusHandler.Delete).Methods("POST")
|
||||
r.HandleFunc("/statuses/json_list/{selectedId}", statusHandler.JsonList).Methods("GET")
|
||||
|
||||
// Line Item routes (matching CakePHP)
|
||||
r.HandleFunc("/line_items/ajax_add", lineItemHandler.AjaxAdd).Methods("POST")
|
||||
r.HandleFunc("/line_items/ajax_edit", lineItemHandler.AjaxEdit).Methods("POST")
|
||||
r.HandleFunc("/line_items/ajax_delete/{id}", lineItemHandler.AjaxDelete).Methods("POST")
|
||||
r.HandleFunc("/line_items/get_table/{documentID}", lineItemHandler.GetTable).Methods("GET")
|
||||
r.HandleFunc("/line_items/edit/{id}", lineItemHandler.Update).Methods("GET", "POST")
|
||||
r.HandleFunc("/line_items/add/{documentID}", lineItemHandler.Create).Methods("GET", "POST")
|
||||
|
||||
// HTMX endpoints
|
||||
r.HandleFunc("/customers", customerHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
|
||||
|
||||
r.HandleFunc("/products", productHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
|
||||
|
||||
r.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
|
||||
|
||||
r.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
|
||||
r.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
|
||||
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
|
||||
|
||||
r.HandleFunc("/documents", documentHandler.Create).Methods("POST")
|
||||
r.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
|
||||
r.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
|
||||
r.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
|
||||
/* Cron Jobs */
|
||||
go func() {
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
log.Printf("Failed to load Sydney timezone: %v", err)
|
||||
loc = time.UTC // fallback to UTC
|
||||
}
|
||||
s := gocron.NewScheduler(loc)
|
||||
s.Every(1).Day().At("08:00").Do(func() {
|
||||
// Checks quotes for reminders and expiry notices
|
||||
quoteHandler.DailyQuoteExpirationCheck()
|
||||
})
|
||||
s.Every(1).Minute().Do(func() {
|
||||
// Checks quotes for reminders and expiry notices
|
||||
quoteHandler.DailyQuoteExpirationCheck()
|
||||
})
|
||||
s.StartAsync()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
port := getEnv("PORT", "8080")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ module code.springupsoftware.com/cmc/cmc-sales
|
|||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.4
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
|
|
@ -11,38 +11,12 @@ require (
|
|||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
google.golang.org/api v0.244.0
|
||||
golang.org/x/text v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.16.3 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
github.com/go-co-op/gocron v1.37.0 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,36 +5,15 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3R
|
|||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
|
||||
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
|
|
@ -46,42 +25,34 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
|||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
|
|
@ -94,17 +65,9 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
|
||||
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -333,6 +333,38 @@ type PurchaseOrder struct {
|
|||
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
Created time.Time `json:"created"`
|
||||
Modified time.Time `json:"modified"`
|
||||
ID int32 `json:"id"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
CurrencyID int32 `json:"currency_id"`
|
||||
// limited at 5 digits. Really, you're not going to have more revisions of a single quote than that
|
||||
Revision int32 `json:"revision"`
|
||||
// estimated delivery time for quote
|
||||
DeliveryTime string `json:"delivery_time"`
|
||||
DeliveryTimeFrame string `json:"delivery_time_frame"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
DaysValid int32 `json:"days_valid"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
DeliveryPoint string `json:"delivery_point"`
|
||||
ExchangeRate string `json:"exchange_rate"`
|
||||
CustomsDuty string `json:"customs_duty"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
CommercialComments sql.NullString `json:"commercial_comments"`
|
||||
}
|
||||
|
||||
type QuoteReminder struct {
|
||||
ID int32 `json:"id"`
|
||||
QuoteID int32 `json:"quote_id"`
|
||||
// 1=1st, 2=2nd, 3=3rd reminder
|
||||
ReminderType int32 `json:"reminder_type"`
|
||||
DateSent time.Time `json:"date_sent"`
|
||||
// User who manually (re)sent the reminder
|
||||
Username sql.NullString `json:"username"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ type Querier interface {
|
|||
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
|
||||
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
|
||||
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
|
||||
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
|
||||
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error)
|
||||
GetLineItem(ctx context.Context, id int32) (LineItem, error)
|
||||
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
|
||||
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
|
||||
|
|
@ -73,11 +75,15 @@ type Querier interface {
|
|||
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
|
||||
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
|
||||
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
|
||||
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
|
||||
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
|
||||
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
|
||||
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)
|
||||
GetState(ctx context.Context, id int32) (State, error)
|
||||
GetStatus(ctx context.Context, id int32) (Status, error)
|
||||
GetUser(ctx context.Context, id int32) (GetUserRow, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||
InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error)
|
||||
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
|
||||
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
|
||||
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
|
||||
|
|
|
|||
474
go-app/internal/cmc/db/quotes.sql.go
Normal file
474
go-app/internal/cmc/db/quotes.sql.go
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: quotes.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotes, dateADD)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetExpiringSoonQuotesRow{}
|
||||
for rows.Next() {
|
||||
var i GetExpiringSoonQuotesRow
|
||||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
&i.CustomerEmail,
|
||||
&i.DateIssued,
|
||||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetExpiringSoonQuotesOnDayRow{}
|
||||
for rows.Next() {
|
||||
var i GetExpiringSoonQuotesOnDayRow
|
||||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
&i.CustomerEmail,
|
||||
&i.DateIssued,
|
||||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getQuoteRemindersByType = `-- name: GetQuoteRemindersByType :many
|
||||
SELECT id, quote_id, reminder_type, date_sent, username
|
||||
FROM quote_reminders
|
||||
WHERE quote_id = ? AND reminder_type = ?
|
||||
ORDER BY date_sent
|
||||
`
|
||||
|
||||
type GetQuoteRemindersByTypeParams struct {
|
||||
QuoteID int32 `json:"quote_id"`
|
||||
ReminderType int32 `json:"reminder_type"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getQuoteRemindersByType, arg.QuoteID, arg.ReminderType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []QuoteReminder{}
|
||||
for rows.Next() {
|
||||
var i QuoteReminder
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.QuoteID,
|
||||
&i.ReminderType,
|
||||
&i.DateSent,
|
||||
&i.Username,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotes, dateSUB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetRecentlyExpiredQuotesRow{}
|
||||
for rows.Next() {
|
||||
var i GetRecentlyExpiredQuotesRow
|
||||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
&i.CustomerEmail,
|
||||
&i.DateIssued,
|
||||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotesOnDay, dateSUB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetRecentlyExpiredQuotesOnDayRow{}
|
||||
for rows.Next() {
|
||||
var i GetRecentlyExpiredQuotesOnDayRow
|
||||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
&i.CustomerEmail,
|
||||
&i.DateIssued,
|
||||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertQuoteReminder = `-- name: InsertQuoteReminder :execresult
|
||||
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type InsertQuoteReminderParams struct {
|
||||
QuoteID int32 `json:"quote_id"`
|
||||
ReminderType int32 `json:"reminder_type"`
|
||||
DateSent time.Time `json:"date_sent"`
|
||||
Username sql.NullString `json:"username"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, insertQuoteReminder,
|
||||
arg.QuoteID,
|
||||
arg.ReminderType,
|
||||
arg.DateSent,
|
||||
arg.Username,
|
||||
)
|
||||
}
|
||||
177
go-app/internal/cmc/email/email.go
Normal file
177
go-app/internal/cmc/email/email.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
emailServiceInstance *EmailService
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// EmailService provides methods to send templated emails via SMTP.
|
||||
type EmailService struct {
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
Username string
|
||||
Password string
|
||||
FromAddress string
|
||||
}
|
||||
|
||||
// GetEmailService returns a singleton EmailService loaded from environment variables
|
||||
func GetEmailService() *EmailService {
|
||||
once.Do(func() {
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
portStr := os.Getenv("SMTP_PORT")
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
port = 25 // default SMTP port
|
||||
}
|
||||
username := os.Getenv("SMTP_USER")
|
||||
password := os.Getenv("SMTP_PASS")
|
||||
from := os.Getenv("SMTP_FROM")
|
||||
emailServiceInstance = &EmailService{
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
FromAddress: from,
|
||||
}
|
||||
})
|
||||
return emailServiceInstance
|
||||
}
|
||||
|
||||
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
|
||||
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
|
||||
defaultBccs := []string{"carpis@cmctechnologies.com.au", "mcarpis@cmctechnologies.com.au"}
|
||||
bccs = append(defaultBccs, bccs...)
|
||||
|
||||
const templateDir = "templates/quotes"
|
||||
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
|
||||
tmpl, err := template.ParseFiles(tmplPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
if err := tmpl.Execute(&body, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = es.FromAddress
|
||||
headers["To"] = to
|
||||
if len(ccs) > 0 {
|
||||
headers["Cc"] = joinAddresses(ccs)
|
||||
}
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
|
||||
|
||||
var msg bytes.Buffer
|
||||
for k, v := range headers {
|
||||
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
|
||||
recipients := []string{to}
|
||||
recipients = append(recipients, ccs...)
|
||||
recipients = append(recipients, bccs...)
|
||||
|
||||
smtpAddr := fmt.Sprintf("%s:%d", es.SMTPHost, es.SMTPPort)
|
||||
|
||||
// If no username/password, assume no auth or TLS (e.g., MailHog)
|
||||
if es.Username == "" && es.Password == "" {
|
||||
c, err := smtp.Dial(smtpAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err = c.Mail(es.FromAddress); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
_, err = w.Write(msg.Bytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close writer: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost)
|
||||
|
||||
// Establish connection to SMTP server
|
||||
c, err := smtp.Dial(smtpAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Upgrade to TLS if supported (STARTTLS)
|
||||
tlsconfig := &tls.Config{
|
||||
ServerName: es.SMTPHost,
|
||||
}
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err = c.StartTLS(tlsconfig); err != nil {
|
||||
return fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
if err = c.Mail(es.FromAddress); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
_, err = w.Write(msg.Bytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close writer: %w", err)
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// joinAddresses joins email addresses with a comma and space.
|
||||
func joinAddresses(addrs []string) string {
|
||||
return fmt.Sprintf("%s", bytes.Join([][]byte(func() [][]byte {
|
||||
b := make([][]byte, len(addrs))
|
||||
for i, a := range addrs {
|
||||
b[i] = []byte(a)
|
||||
}
|
||||
return b
|
||||
}()), []byte(", ")))
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type PageHandler struct {
|
||||
|
|
@ -26,10 +28,21 @@ func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager, databa
|
|||
}
|
||||
}
|
||||
|
||||
// Helper function to get the username from the request
|
||||
func getUsername(r *http.Request) string {
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok && username != "" {
|
||||
caser := cases.Title(language.English)
|
||||
return caser.String(username) // Capitalise the username for display
|
||||
}
|
||||
return "Guest"
|
||||
}
|
||||
|
||||
// Home page
|
||||
func (h *PageHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Title": "Dashboard",
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "index.html", data); err != nil {
|
||||
|
|
@ -69,6 +82,7 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
|
|||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
|
|
@ -87,6 +101,7 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *PageHandler) CustomersNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Customer": db.Customer{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
|
||||
|
|
@ -110,6 +125,7 @@ func (h *PageHandler) CustomersEdit(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Customer": customer,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
|
||||
|
|
@ -133,6 +149,7 @@ func (h *PageHandler) CustomersShow(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Customer": customer,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/show.html", data); err != nil {
|
||||
|
|
@ -185,6 +202,7 @@ func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
|
|||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -198,6 +216,7 @@ func (h *PageHandler) ProductsIndex(w http.ResponseWriter, r *http.Request) {
|
|||
// Similar implementation to CustomersIndex but for products
|
||||
data := map[string]interface{}{
|
||||
"Products": []db.Product{}, // Placeholder
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/index.html", data); err != nil {
|
||||
|
|
@ -208,6 +227,7 @@ func (h *PageHandler) ProductsIndex(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *PageHandler) ProductsNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Product": db.Product{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
|
||||
|
|
@ -231,6 +251,7 @@ func (h *PageHandler) ProductsShow(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Product": product,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/show.html", data); err != nil {
|
||||
|
|
@ -254,6 +275,7 @@ func (h *PageHandler) ProductsEdit(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Product": product,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
|
||||
|
|
@ -265,6 +287,7 @@ func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
|
|||
// Similar to CustomersSearch but for products
|
||||
data := map[string]interface{}{
|
||||
"Products": []db.Product{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -277,6 +300,7 @@ func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *PageHandler) PurchaseOrdersIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrders": []db.PurchaseOrder{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/index.html", data); err != nil {
|
||||
|
|
@ -287,6 +311,7 @@ func (h *PageHandler) PurchaseOrdersIndex(w http.ResponseWriter, r *http.Request
|
|||
func (h *PageHandler) PurchaseOrdersNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": db.PurchaseOrder{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
|
||||
|
|
@ -310,6 +335,7 @@ func (h *PageHandler) PurchaseOrdersShow(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": purchaseOrder,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/show.html", data); err != nil {
|
||||
|
|
@ -333,6 +359,7 @@ func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": purchaseOrder,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
|
||||
|
|
@ -343,6 +370,7 @@ func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request)
|
|||
func (h *PageHandler) PurchaseOrdersSearch(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrders": []db.PurchaseOrder{},
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -413,6 +441,7 @@ func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
|
|||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
|
|
@ -460,6 +489,7 @@ func (h *PageHandler) EnquiriesNew(w http.ResponseWriter, r *http.Request) {
|
|||
"Principles": principles,
|
||||
"States": states,
|
||||
"Countries": countries,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
|
||||
|
|
@ -483,6 +513,7 @@ func (h *PageHandler) EnquiriesShow(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Enquiry": enquiry,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/show.html", data); err != nil {
|
||||
|
|
@ -535,6 +566,7 @@ func (h *PageHandler) EnquiriesEdit(w http.ResponseWriter, r *http.Request) {
|
|||
"Principles": principles,
|
||||
"States": states,
|
||||
"Countries": countries,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
|
||||
|
|
@ -599,6 +631,7 @@ func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
|
|||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -655,6 +688,7 @@ func (h *PageHandler) DocumentsIndex(w http.ResponseWriter, r *http.Request) {
|
|||
"Users": users,
|
||||
"Page": page,
|
||||
"DocType": docType,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
|
|
@ -687,6 +721,7 @@ func (h *PageHandler) DocumentsShow(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Document": document,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "documents/show.html", data); err != nil {
|
||||
|
|
@ -703,7 +738,7 @@ func (h *PageHandler) DocumentsSearch(w http.ResponseWriter, r *http.Request) {
|
|||
// For now, just return all documents until search is implemented
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
|
||||
documents, err = h.queries.ListDocuments(r.Context(), db.ListDocumentsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
|
|
@ -715,6 +750,7 @@ func (h *PageHandler) DocumentsSearch(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data := map[string]interface{}{
|
||||
"Documents": documents,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -750,6 +786,7 @@ func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) {
|
|||
"Document": document,
|
||||
"DocType": string(document.Type),
|
||||
"LineItems": lineItems,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
|
||||
// Add document type specific data
|
||||
|
|
|
|||
440
go-app/internal/cmc/handlers/quotes/quotes.go
Normal file
440
go-app/internal/cmc/handlers/quotes/quotes.go
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
)
|
||||
|
||||
// Import getUsername from pages.go
|
||||
// If you move getUsername to a shared utils package, update the import accordingly.
|
||||
func getUsername(r *http.Request) string {
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok && username != "" {
|
||||
// Capitalise the username for display
|
||||
return strings.Title(username)
|
||||
}
|
||||
return "Guest"
|
||||
}
|
||||
|
||||
// Helper: returns date string or empty if zero
|
||||
func formatDate(t time.Time) string {
|
||||
if t.IsZero() || t.Year() == 1970 {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
|
||||
func isValidDBTime(t time.Time) bool {
|
||||
return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// calcExpiryInfo is a helper to calculate expiry info for a quote
|
||||
func calcExpiryInfo(validUntil time.Time) (string, int, int) {
|
||||
now := time.Now()
|
||||
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
validUntilDate := time.Date(validUntil.Year(), validUntil.Month(), validUntil.Day(), 0, 0, 0, 0, validUntil.Location())
|
||||
daysUntil := int(validUntilDate.Sub(nowDate).Hours() / 24)
|
||||
daysSince := int(nowDate.Sub(validUntilDate).Hours() / 24)
|
||||
var relative string
|
||||
if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) {
|
||||
switch daysUntil {
|
||||
case 0:
|
||||
relative = "expires today"
|
||||
case 1:
|
||||
relative = "expires tomorrow"
|
||||
default:
|
||||
relative = "expires in " + strconv.Itoa(daysUntil) + " days"
|
||||
}
|
||||
} else {
|
||||
switch daysSince {
|
||||
case 0:
|
||||
relative = "expired today"
|
||||
case 1:
|
||||
relative = "expired yesterday"
|
||||
default:
|
||||
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
|
||||
}
|
||||
}
|
||||
return relative, daysUntil, daysSince
|
||||
}
|
||||
|
||||
// QuoteRow interface for all quote row types
|
||||
// (We use wrapper types since sqlc structs can't be modified directly)
|
||||
type QuoteRow interface {
|
||||
GetID() int32
|
||||
GetUsername() string
|
||||
GetEnquiryID() int32
|
||||
GetEnquiryRef() string
|
||||
GetDateIssued() time.Time
|
||||
GetValidUntil() time.Time
|
||||
GetReminderType() int32
|
||||
GetReminderSent() time.Time
|
||||
GetCustomerName() string
|
||||
GetCustomerEmail() string
|
||||
}
|
||||
|
||||
// Wrapper types for each DB row struct
|
||||
|
||||
type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow }
|
||||
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetUsername() string { return q.Username }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||
|
||||
type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow }
|
||||
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string { return q.Username }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||
|
||||
type ExpiringSoonQuoteOnDayRowWrapper struct {
|
||||
db.GetExpiringSoonQuotesOnDayRow
|
||||
}
|
||||
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string { return q.Username }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time {
|
||||
return q.LatestReminderSentTime
|
||||
}
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||
|
||||
type RecentlyExpiredQuoteOnDayRowWrapper struct {
|
||||
db.GetRecentlyExpiredQuotesOnDayRow
|
||||
}
|
||||
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string { return q.Username }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time {
|
||||
return q.LatestReminderSentTime
|
||||
}
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||
|
||||
// Helper: formats a quote row for output (generic)
|
||||
func formatQuoteRow(q QuoteRow) map[string]interface{} {
|
||||
relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil())
|
||||
return map[string]interface{}{
|
||||
"ID": q.GetID(),
|
||||
"Username": strings.Title(q.GetUsername()),
|
||||
"EnquiryID": q.GetEnquiryID(),
|
||||
"EnquiryRef": q.GetEnquiryRef(),
|
||||
"CustomerName": strings.TrimSpace(q.GetCustomerName()),
|
||||
"CustomerEmail": q.GetCustomerEmail(),
|
||||
"DateIssued": formatDate(q.GetDateIssued()),
|
||||
"ValidUntil": formatDate(q.GetValidUntil()),
|
||||
"ValidUntilRelative": relative,
|
||||
"DaysUntilExpiry": daysUntil,
|
||||
"DaysSinceExpiry": daysSince,
|
||||
"LatestReminderSent": formatDate(q.GetReminderSent()),
|
||||
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
|
||||
}
|
||||
}
|
||||
|
||||
type QuoteQueries interface {
|
||||
GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error)
|
||||
InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error)
|
||||
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error)
|
||||
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error)
|
||||
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error)
|
||||
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
|
||||
}
|
||||
|
||||
type QuotesHandler struct {
|
||||
queries QuoteQueries
|
||||
tmpl *templates.TemplateManager
|
||||
emailService EmailSender
|
||||
}
|
||||
|
||||
func NewQuotesHandler(queries QuoteQueries, tmpl *templates.TemplateManager, emailService EmailSender) *QuotesHandler {
|
||||
return &QuotesHandler{
|
||||
queries: queries,
|
||||
tmpl: tmpl,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
|
||||
// Days to look ahead and behind for expiring quotes
|
||||
days := 7
|
||||
|
||||
// Show all quotes that are expiring in the next 7 days
|
||||
expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Show all quotes that have expired in the last 60 days
|
||||
recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
|
||||
"ExpiringSoonQuotes": expiringSoonQuotes,
|
||||
"User": getUsername(r),
|
||||
}
|
||||
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// GetOutstandingQuotes returns outstanding quotes based on daysUntilExpiry.
|
||||
func (h *QuotesHandler) GetOutstandingQuotes(r *http.Request, daysUntilExpiry int) ([]map[string]interface{}, error) {
|
||||
var rows []map[string]interface{}
|
||||
ctx := r.Context()
|
||||
// If daysUntilExpiry is positive, get quotes expiring soon; if negative, get recently expired quotes
|
||||
if daysUntilExpiry >= 0 {
|
||||
quotes, err := h.queries.GetExpiringSoonQuotes(ctx, daysUntilExpiry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, q := range quotes {
|
||||
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteRowWrapper{q}))
|
||||
}
|
||||
} else {
|
||||
days := -daysUntilExpiry
|
||||
quotes, err := h.queries.GetRecentlyExpiredQuotes(ctx, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, q := range quotes {
|
||||
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteRowWrapper{q}))
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetOutstandingQuotesOnDay returns quotes expiring exactly N days from today (if day >= 0), or exactly N days ago (if day < 0).
|
||||
func (h *QuotesHandler) GetOutstandingQuotesOnDay(r *http.Request, day int) ([]map[string]interface{}, error) {
|
||||
var rows []map[string]interface{}
|
||||
ctx := r.Context()
|
||||
// If day is positive, get quotes expiring on that day; if negative, get recently expired quotes on that day in the past
|
||||
if day >= 0 {
|
||||
quotes, err := h.queries.GetExpiringSoonQuotesOnDay(ctx, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, q := range quotes {
|
||||
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteOnDayRowWrapper{q}))
|
||||
}
|
||||
} else {
|
||||
days := -day
|
||||
quotes, err := h.queries.GetRecentlyExpiredQuotesOnDay(ctx, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, q := range quotes {
|
||||
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteOnDayRowWrapper{q}))
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
type QuoteReminderType int
|
||||
|
||||
const (
|
||||
FirstReminder QuoteReminderType = 1
|
||||
SecondReminder QuoteReminderType = 2
|
||||
ThirdReminder QuoteReminderType = 3
|
||||
)
|
||||
|
||||
func (t QuoteReminderType) String() string {
|
||||
switch t {
|
||||
case FirstReminder:
|
||||
return "FirstReminder"
|
||||
case SecondReminder:
|
||||
return "SecondReminder"
|
||||
case ThirdReminder:
|
||||
return "ThirdReminder"
|
||||
default:
|
||||
return "UnknownReminder"
|
||||
}
|
||||
}
|
||||
|
||||
type quoteReminderJob struct {
|
||||
DayOffset int
|
||||
ReminderType QuoteReminderType
|
||||
Subject string
|
||||
Template string
|
||||
}
|
||||
|
||||
// DailyQuoteExpirationCheck checks quotes for reminders and expiry notices (callable as a cron job from main)
|
||||
func (h *QuotesHandler) DailyQuoteExpirationCheck() {
|
||||
fmt.Println("Running DailyQuoteExpirationCheck...")
|
||||
|
||||
reminderJobs := []quoteReminderJob{
|
||||
{7, FirstReminder, "Reminder: Quote %s Expires Soon", "first_reminder.html"},
|
||||
{-7, SecondReminder, "Follow-Up: Quote %s Expired", "second_reminder.html"},
|
||||
{-60, ThirdReminder, "Final Reminder: Quote %s Closed", "final_reminder.html"},
|
||||
}
|
||||
|
||||
for _, job := range reminderJobs {
|
||||
quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(quotes) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, q := range quotes {
|
||||
// Format dates as DD/MM/YYYY
|
||||
var submissionDate, expiryDate string
|
||||
if dateIssued, ok := q["DateIssued"].(string); ok && dateIssued != "" {
|
||||
t, err := time.Parse(time.RFC3339, dateIssued)
|
||||
if err == nil {
|
||||
submissionDate = t.Format("02/01/2006")
|
||||
} else {
|
||||
submissionDate = dateIssued
|
||||
}
|
||||
}
|
||||
if validUntil, ok := q["ValidUntil"].(string); ok && validUntil != "" {
|
||||
t, err := time.Parse(time.RFC3339, validUntil)
|
||||
if err == nil {
|
||||
expiryDate = t.Format("02/01/2006")
|
||||
} else {
|
||||
expiryDate = validUntil
|
||||
}
|
||||
}
|
||||
templateData := map[string]interface{}{
|
||||
"CustomerName": q["CustomerName"],
|
||||
"SubmissionDate": submissionDate,
|
||||
"ExpiryDate": expiryDate,
|
||||
"QuoteRef": q["EnquiryRef"],
|
||||
}
|
||||
err := h.SendQuoteReminderEmail(
|
||||
context.Background(),
|
||||
q["ID"].(int32),
|
||||
job.ReminderType,
|
||||
q["CustomerEmail"].(string),
|
||||
fmt.Sprintf(job.Subject, q["EnquiryRef"]),
|
||||
job.Template,
|
||||
templateData,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err)
|
||||
} else {
|
||||
fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
|
||||
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, username *string) error {
|
||||
// Safeguard: check for valid recipient
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
return fmt.Errorf("recipient email is required")
|
||||
}
|
||||
// Safeguard: check for valid template data
|
||||
if templateData == nil {
|
||||
return fmt.Errorf("template data is required")
|
||||
}
|
||||
// Safeguard: check for valid reminder type
|
||||
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
|
||||
return fmt.Errorf("invalid reminder type: %v", reminderType)
|
||||
}
|
||||
|
||||
// Check if reminder already sent
|
||||
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
|
||||
QuoteID: quoteID,
|
||||
ReminderType: int32(reminderType),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing reminders: %w", err)
|
||||
}
|
||||
|
||||
// Exit if the email has already been sent
|
||||
if len(reminders) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send the email
|
||||
err = h.emailService.SendTemplateEmail(
|
||||
recipient,
|
||||
subject,
|
||||
templateName,
|
||||
templateData,
|
||||
nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
// Record the reminder
|
||||
var user sql.NullString
|
||||
if username != nil {
|
||||
user = sql.NullString{String: *username, Valid: true}
|
||||
} else {
|
||||
user = sql.NullString{Valid: false}
|
||||
}
|
||||
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
|
||||
QuoteID: quoteID,
|
||||
ReminderType: int32(reminderType),
|
||||
DateSent: time.Now().UTC(),
|
||||
Username: user,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record reminder: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper: get reminder type as string
|
||||
func reminderTypeString(reminderType int) string {
|
||||
switch reminderType {
|
||||
case 0:
|
||||
return "No Reminder"
|
||||
case 1:
|
||||
return "First Reminder"
|
||||
case 2:
|
||||
return "Second Reminder"
|
||||
case 3:
|
||||
return "Final Reminder"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
359
go-app/internal/cmc/handlers/quotes/quotes_test.go
Normal file
359
go-app/internal/cmc/handlers/quotes/quotes_test.go
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
)
|
||||
|
||||
// Mocks
|
||||
|
||||
type mockQuoteRow struct {
|
||||
reminderType int32
|
||||
reminderSent time.Time
|
||||
designatedDay time.Weekday
|
||||
emailSent bool
|
||||
}
|
||||
|
||||
func (m *mockQuoteRow) GetReminderType() int32 { return m.reminderType }
|
||||
func (m *mockQuoteRow) GetReminderSent() time.Time { return m.reminderSent }
|
||||
func (m *mockQuoteRow) GetCustomerEmail() string { return "test@example.com" }
|
||||
|
||||
// Realistic mock for db.Queries
|
||||
|
||||
type mockQueries struct {
|
||||
reminders []db.QuoteReminder
|
||||
insertCalled bool
|
||||
}
|
||||
|
||||
func (m *mockQueries) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
|
||||
var filtered []db.QuoteReminder
|
||||
for _, r := range m.reminders {
|
||||
if r.QuoteID == params.QuoteID && r.ReminderType == params.ReminderType {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *mockQueries) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
|
||||
m.insertCalled = true
|
||||
m.reminders = append(m.reminders, db.QuoteReminder{
|
||||
QuoteID: params.QuoteID,
|
||||
ReminderType: params.ReminderType,
|
||||
DateSent: params.DateSent,
|
||||
Username: params.Username,
|
||||
})
|
||||
return &mockResult{}, nil
|
||||
}
|
||||
|
||||
func (m *mockQueries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Mock sql.Result for InsertQuoteReminder
|
||||
|
||||
type mockResult struct{}
|
||||
|
||||
func (m *mockResult) LastInsertId() (int64, error) { return 1, nil }
|
||||
func (m *mockResult) RowsAffected() (int64, error) { return 1, nil }
|
||||
|
||||
// Realistic mock for email.EmailService
|
||||
|
||||
type mockEmailService struct {
|
||||
sent bool
|
||||
sentReminders map[int32]map[int32]bool // quoteID -> reminderType -> sent
|
||||
}
|
||||
|
||||
func (m *mockEmailService) SendTemplateEmail(to, subject, templateName string, data interface{}, ccs, bccs []string) error {
|
||||
m.sent = true
|
||||
if m.sentReminders == nil {
|
||||
m.sentReminders = make(map[int32]map[int32]bool)
|
||||
}
|
||||
var quoteID int32
|
||||
var reminderType int32
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if id, ok := dataMap["QuoteID"].(int32); ok {
|
||||
quoteID = id
|
||||
} else if id, ok := dataMap["QuoteID"].(int); ok {
|
||||
quoteID = int32(id)
|
||||
}
|
||||
if rt, ok := dataMap["ReminderType"].(int32); ok {
|
||||
reminderType = rt
|
||||
} else if rt, ok := dataMap["ReminderType"].(int); ok {
|
||||
reminderType = int32(rt)
|
||||
}
|
||||
}
|
||||
if quoteID == 0 {
|
||||
quoteID = 123
|
||||
}
|
||||
if reminderType == 0 {
|
||||
reminderType = 1
|
||||
}
|
||||
if m.sentReminders[quoteID] == nil {
|
||||
m.sentReminders[quoteID] = make(map[int32]bool)
|
||||
}
|
||||
m.sentReminders[quoteID][reminderType] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mock for db.Queries with error simulation
|
||||
|
||||
type mockQueriesError struct{}
|
||||
|
||||
func (m *mockQueriesError) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
|
||||
return nil, errors.New("db error")
|
||||
}
|
||||
func (m *mockQueriesError) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
|
||||
return &mockResult{}, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Test: Should send email on designated day if not already sent
|
||||
// Description: Verifies that a reminder email is sent and recorded when no previous reminder exists for the quote. Should pass unless the handler logic is broken.
|
||||
func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
// Simulate designated day logic by calling SendQuoteReminderEmail
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !me.sent {
|
||||
t.Error("Expected email to be sent on designated day")
|
||||
}
|
||||
if !mq.insertCalled {
|
||||
t.Error("Expected reminder to be recorded on designated day")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Should NOT send email if reminder already sent
|
||||
// Description: Verifies that if a reminder for the quote and type already exists, the handler does not send another email or record a duplicate. Should fail if duplicate reminders are allowed.
|
||||
func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for already sent reminder")
|
||||
}
|
||||
if me.sent {
|
||||
t.Error("Expected email NOT to be sent if already reminded")
|
||||
}
|
||||
if mq.insertCalled {
|
||||
t.Error("Expected reminder NOT to be recorded if already reminded")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Should only send reminder once, second call should fail
|
||||
// Description: Sends a reminder, then tries to send the same reminder again. The first should succeed, the second should fail and not send or record a duplicate. Should fail if duplicates are allowed.
|
||||
func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
// First call should succeed
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected first call to succeed, got %v", err1)
|
||||
}
|
||||
if !me.sentReminders[123][1] {
|
||||
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
|
||||
}
|
||||
if len(mq.reminders) != 1 {
|
||||
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
|
||||
}
|
||||
// Second call should fail
|
||||
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err2 == nil {
|
||||
t.Error("Expected error for already sent reminder on second call")
|
||||
}
|
||||
if len(mq.reminders) != 1 {
|
||||
t.Errorf("Expected no additional reminder recorded in DB, got %d", len(mq.reminders))
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Should send and record reminder if not already sent
|
||||
// Description: Verifies that a reminder is sent and recorded when no previous reminder exists. Should pass unless the handler logic is broken.
|
||||
func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !me.sent {
|
||||
t.Error("Expected email to be sent")
|
||||
}
|
||||
if !mq.insertCalled {
|
||||
t.Error("Expected reminder to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Should ignore already sent reminder
|
||||
// Description: Verifies that the handler does not send or record a reminder if one already exists for the quote/type. Should fail if duplicates are allowed.
|
||||
func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for already sent reminder")
|
||||
}
|
||||
if me.sent {
|
||||
t.Error("Expected email NOT to be sent")
|
||||
}
|
||||
if mq.insertCalled {
|
||||
t.Error("Expected reminder NOT to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Should fail if DB returns error
|
||||
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled.
|
||||
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
|
||||
h := &QuotesHandler{queries: &mockQueriesError{}}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error when DB fails, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case: nil recipient
|
||||
// Test: Should fail if recipient is empty
|
||||
// Description: Verifies that the handler returns an error if the recipient email is missing. Should fail if emails are sent to empty recipients.
|
||||
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil recipient, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case: missing template data
|
||||
// Test: Should fail if template data is missing
|
||||
// Description: Verifies that the handler returns an error if template data is nil. Should fail if emails are sent without template data.
|
||||
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing template data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary: invalid reminder type
|
||||
// Test: Should fail if reminder type is invalid
|
||||
// Description: Verifies that the handler returns an error for an invalid reminder type. Should fail if invalid types are allowed.
|
||||
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid reminder type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Multiple reminders of different types allowed for same quote
|
||||
// Description: Verifies that reminders of different types for the same quote can be sent and recorded independently. Should fail if only one reminder per quote is allowed.
|
||||
func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{queries: mq, emailService: me}
|
||||
|
||||
// First reminder type
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected first reminder to be sent, got %v", err1)
|
||||
}
|
||||
if !me.sentReminders[123][1] {
|
||||
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
|
||||
}
|
||||
if len(mq.reminders) != 1 {
|
||||
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
|
||||
}
|
||||
|
||||
// Second reminder type
|
||||
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil)
|
||||
if err2 != nil {
|
||||
t.Fatalf("Expected second reminder to be sent, got %v", err2)
|
||||
}
|
||||
if !me.sentReminders[123][2] {
|
||||
t.Error("Expected email to be sent and recorded for quote 123, reminder 2")
|
||||
}
|
||||
if len(mq.reminders) != 2 {
|
||||
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Reminders for different quotes are independent
|
||||
// Description: Verifies that reminders for different quotes do not block each other and are recorded independently. Should fail if reminders for one quote affect another.
|
||||
func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{queries: mq, emailService: me}
|
||||
|
||||
// Send reminder for quote 123
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
|
||||
}
|
||||
if !me.sentReminders[123][1] {
|
||||
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
|
||||
}
|
||||
if len(mq.reminders) != 1 {
|
||||
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
|
||||
}
|
||||
|
||||
// Send reminder for quote 456
|
||||
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil)
|
||||
if err2 != nil {
|
||||
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
|
||||
}
|
||||
if !me.sentReminders[456][1] {
|
||||
t.Error("Expected email to be sent and recorded for quote 456, reminder 1")
|
||||
}
|
||||
if len(mq.reminders) != 2 {
|
||||
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,10 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
|||
|
||||
// Define template functions
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": formatDate,
|
||||
"truncate": truncate,
|
||||
"currency": formatCurrency,
|
||||
"formatDate": formatDate,
|
||||
"truncate": truncate,
|
||||
"currency": formatCurrency,
|
||||
"currentYear": func() int { return time.Now().Year() },
|
||||
}
|
||||
|
||||
// Load all templates
|
||||
|
|
@ -38,6 +39,7 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
|||
|
||||
// Load page templates
|
||||
pages := []string{
|
||||
"index.html",
|
||||
"customers/index.html",
|
||||
"customers/show.html",
|
||||
"customers/form.html",
|
||||
|
|
@ -67,14 +69,14 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
|||
"documents/purchase-order-view.html",
|
||||
"documents/orderack-view.html",
|
||||
"documents/packinglist-view.html",
|
||||
"index.html",
|
||||
"quotes/index.html",
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
pagePath := filepath.Join(templatesDir, page)
|
||||
files := append(layouts, partials...)
|
||||
files = append(files, pagePath)
|
||||
|
||||
|
||||
// For index pages, also include the corresponding table template
|
||||
if filepath.Base(page) == "index.html" {
|
||||
dir := filepath.Dir(page)
|
||||
|
|
@ -84,7 +86,7 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
|||
files = append(files, tablePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For documents view page, include all document type elements
|
||||
if page == "documents/view.html" {
|
||||
docElements := []string{
|
||||
|
|
@ -101,12 +103,12 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tmpl, err := template.New(filepath.Base(page)).Funcs(funcMap).ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
tm.templates[page] = tmpl
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +120,7 @@ func (tm *TemplateManager) Render(w io.Writer, name string, data interface{}) er
|
|||
if !ok {
|
||||
return template.New("error").Execute(w, "Template not found")
|
||||
}
|
||||
|
||||
|
||||
return tmpl.ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +129,7 @@ func (tm *TemplateManager) RenderPartial(w io.Writer, templateFile, templateName
|
|||
if !ok {
|
||||
return template.New("error").Execute(w, "Template not found")
|
||||
}
|
||||
|
||||
|
||||
return tmpl.ExecuteTemplate(w, templateName, data)
|
||||
}
|
||||
|
||||
|
|
@ -155,4 +157,4 @@ func truncate(s string, n int) string {
|
|||
|
||||
func formatCurrency(amount float64) string {
|
||||
return fmt.Sprintf("$%.2f", amount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
go-app/server
BIN
go-app/server
Binary file not shown.
217
go-app/sql/queries/quotes.sql
Normal file
217
go-app/sql/queries/quotes.sql
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
-- name: GetExpiringSoonQuotes :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until;
|
||||
|
||||
-- name: GetExpiringSoonQuotesOnDay :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until;
|
||||
|
||||
-- name: GetRecentlyExpiredQuotes :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until DESC;
|
||||
|
||||
-- name: GetRecentlyExpiredQuotesOnDay :many
|
||||
WITH ranked_reminders AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY quote_id
|
||||
ORDER BY reminder_type DESC, date_sent DESC
|
||||
) AS rn
|
||||
FROM quote_reminders
|
||||
),
|
||||
latest_quote_reminder AS (
|
||||
SELECT
|
||||
id,
|
||||
quote_id,
|
||||
reminder_type,
|
||||
date_sent,
|
||||
username
|
||||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
uu.email as customer_email,
|
||||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
JOIN enquiries e ON e.id = q.enquiry_id
|
||||
JOIN users uu ON uu.id = e.contact_user_id
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
|
||||
ORDER BY q.valid_until DESC;
|
||||
|
||||
-- name: GetQuoteRemindersByType :many
|
||||
SELECT id, quote_id, reminder_type, date_sent, username
|
||||
FROM quote_reminders
|
||||
WHERE quote_id = ? AND reminder_type = ?
|
||||
ORDER BY date_sent;
|
||||
|
||||
-- name: InsertQuoteReminder :execresult
|
||||
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
|
||||
VALUES (?, ?, ?, ?);
|
||||
34
go-app/sql/schema/014_quotes.sql
Normal file
34
go-app/sql/schema/014_quotes.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- cmc.quotes definition (matches existing database)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `quotes` (
|
||||
`created` datetime NOT NULL,
|
||||
`modified` datetime NOT NULL,
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`enquiry_id` int(50) NOT NULL,
|
||||
`currency_id` int(11) NOT NULL,
|
||||
`revision` int(5) NOT NULL COMMENT 'limited at 5 digits. Really, you''re not going to have more revisions of a single quote than that',
|
||||
`delivery_time` varchar(400) NOT NULL COMMENT 'estimated delivery time for quote',
|
||||
`delivery_time_frame` varchar(100) NOT NULL,
|
||||
`payment_terms` varchar(400) NOT NULL,
|
||||
`days_valid` int(3) NOT NULL,
|
||||
`date_issued` date NOT NULL,
|
||||
`valid_until` date NOT NULL,
|
||||
`delivery_point` varchar(400) NOT NULL,
|
||||
`exchange_rate` varchar(255) NOT NULL,
|
||||
`customs_duty` varchar(255) NOT NULL,
|
||||
`document_id` int(11) NOT NULL,
|
||||
`commercial_comments` text DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=MyISAM AUTO_INCREMENT=18245 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||
|
||||
-- cmc.quote_reminders definition
|
||||
CREATE TABLE IF NOT EXISTS `quote_reminders` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`quote_id` int(11) NOT NULL,
|
||||
`reminder_type` int(3) NOT NULL COMMENT '1=1st, 2=2nd, 3=3rd reminder',
|
||||
`date_sent` datetime NOT NULL,
|
||||
`username` varchar(100) DEFAULT NULL COMMENT 'User who manually (re)sent the reminder',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `quote_id` (`quote_id`),
|
||||
CONSTRAINT `quote_reminders_ibfk_1` FOREIGN KEY (`quote_id`) REFERENCES `quotes` (`id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||
|
|
@ -6,8 +6,32 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}CMC Sales{{end}}</title>
|
||||
|
||||
<!-- Bulma CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
cmcblue: '#4686c3',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Additional CSS to assist with more complicated transitions for the nav bar items */
|
||||
.navbar-dropdown {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 80ms ease, visibility 80ms ease;
|
||||
}
|
||||
.group:hover .navbar-dropdown,
|
||||
.group:focus-within .navbar-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
|
@ -20,70 +44,153 @@
|
|||
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<strong>CMC Sales</strong>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- Cute Light Blue Navigation -->
|
||||
<nav class="bg-cmcblue shadow flex items-center justify-between px-4 py-2" role="navigation" aria-label="main navigation">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a class="flex items-center space-x-2 text-white font-bold text-lg" href="/">
|
||||
<span><i class="fas fa-flask text-cmcblue bg-white rounded p-1"></i></span>
|
||||
<span>CMC Sales</span>
|
||||
</a>
|
||||
<span class="mx-4 h-8 w-px bg-white opacity-40"></span>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center">
|
||||
<ul class="flex space-x-2">
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/enquiries/index">
|
||||
<span>Enquiries</span>
|
||||
<span><i class="ml-1 fas fa-envelope text-white"></i></span>
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/enquiries/index">Enquiry Register</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/go/quotes">Quotes Expiring</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/documents/index">
|
||||
<span>Documents</span>
|
||||
<span><i class="ml-1 fas fa-file-alt text-white"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbarMain" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/">
|
||||
<span class="icon"><i class="fas fa-home"></i></span>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/customers">
|
||||
<span class="icon"><i class="fas fa-users"></i></span>
|
||||
<span>Customers</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/products">
|
||||
<span class="icon"><i class="fas fa-box"></i></span>
|
||||
<span>Products</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/purchase-orders">
|
||||
<span class="icon"><i class="fas fa-file-invoice"></i></span>
|
||||
<span>Purchase Orders</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/enquiries">
|
||||
<span class="icon"><i class="fas fa-envelope"></i></span>
|
||||
<span>Enquiries</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/emails">
|
||||
<span class="icon"><i class="fas fa-mail-bulk"></i></span>
|
||||
<span>Emails</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/documents/index">Documents Index</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/jobs/index">
|
||||
<span>Jobs</span>
|
||||
<span><i class="ml-1 fas fa-briefcase text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/jobs/reports">Reports</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/jobs/index">Job List</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/shipments/index">
|
||||
<span>Shipments</span>
|
||||
<span><i class="ml-1 fas fa-truck text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-64 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index">All Shipments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/import">Import Shipments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/direct">Direct Shipments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/export">Export Shipments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/local">Local Shipments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/reports">Monthly Deferred GST</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/freight_forwarders">Freight Forwarders</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/customers/index">
|
||||
<span>Customers</span>
|
||||
<span><i class="ml-1 fas fa-users text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/customers/index">Customer Index</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/customers/add">Add Customer</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/industries/index">Industries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/purchase_orders/index">
|
||||
<span>POs</span>
|
||||
<span><i class="ml-1 fas fa-file-invoice text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/purchase_orders/index">PO Index</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/invoices/index">
|
||||
<span>Invoices</span>
|
||||
<span><i class="ml-1 fas fa-receipt text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/invoices/index">Invoices Index</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/invoices/printView">Print View</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/products/index">
|
||||
<span>Products</span>
|
||||
<span><i class="ml-1 fas fa-box text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/products/index">Product Index</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/products/add">Add Product</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/principles/index">
|
||||
<span>Principles</span>
|
||||
<span><i class="ml-1 fas fa-user-tie text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/principles/index">Principle Index</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/attachments/index">Attachments</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/principles/add">Add Principle</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/enquiries/search" id="searchLink">
|
||||
<span>Search</span>
|
||||
<span><i class="ml-1 fas fa-search text-white"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative group">
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/pages/about">
|
||||
<span>Help</span>
|
||||
<span><i class="ml-1 fas fa-question-circle text-white"></i></span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/pages/bug">Raise a bug</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="https://gitlab.com/minimalist.software/cmc-sales/issues">Issue tracker</a></li>
|
||||
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/pages/about">About</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/admin">
|
||||
<span>Admin</span>
|
||||
<span><i class="ml-1 fas fa-cog text-white"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span><i class="fas fa-user text-cmcblue bg-white rounded p-1"></i></span>
|
||||
<span class="text-white">{{if .User}}{{.User}}{{else}}Guest{{end}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<section class="section flex-1">
|
||||
<div class="container p-4">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="content has-text-centered">
|
||||
<p>
|
||||
<strong>CMC Sales</strong> © 2024 CMC Technologies
|
||||
</p>
|
||||
</div>
|
||||
<footer class="bg-slate-500 text-white text-center py-2 text-sm mt-8 w-full">
|
||||
<strong>CMC Sales</strong> © {{ currentYear }}
|
||||
</footer>
|
||||
|
||||
<!-- Custom JS -->
|
||||
|
|
|
|||
12
go-app/templates/quotes/final_reminder.html
Normal file
12
go-app/templates/quotes/final_reminder.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<p>Dear {{.CustomerName}},</p>
|
||||
|
||||
<p>We’re getting in touch regarding the quotation issued on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As of today, more than <strong>60 days</strong> have passed without a response.</p>
|
||||
|
||||
<p>This quotation is no longer valid, and we’ll consider the proposal closed unless we hear otherwise. If you’re still interested in moving forward, we’d be happy to provide a new quote tailored to your current requirements.</p>
|
||||
|
||||
<p>If you have any outstanding questions or would like to initiate a new quote, simply reply to this email — we’re here to help.</p>
|
||||
|
||||
<p>Thank you again for your time and consideration.</p>
|
||||
|
||||
<p>Warm regards,</p>
|
||||
<p>CMC Technologies</p>
|
||||
15
go-app/templates/quotes/first_reminder.html
Normal file
15
go-app/templates/quotes/first_reminder.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<html>
|
||||
<body>
|
||||
<p>Dear {{.CustomerName}},</p>
|
||||
|
||||
<p>We’d like to remind you that the quote we provided on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, remains valid for another <strong>7 days</strong>. After this period, the quote will expire.</p>
|
||||
|
||||
<p>If you require more time, we’re happy to help — simply reply to this email to request an extension or a refreshed version of the quote.</p>
|
||||
|
||||
<p>Should you have any questions or need further clarification, please don’t hesitate to get in touch. We’re here to support you and ensure everything is in order.</p>
|
||||
|
||||
<p>Thank you for time and consideration. We look forward to the opportunity to work with you.</p>
|
||||
<p>Warm regards,</p>
|
||||
<p>CMC Technologies</p>
|
||||
</body>
|
||||
</html>
|
||||
127
go-app/templates/quotes/index.html
Normal file
127
go-app/templates/quotes/index.html
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{{define "content"}}
|
||||
<h1 class="text-3xl font-bold mb-4 text-gray-800">Quotes Expiring</h1>
|
||||
<div class="pl-4">
|
||||
<!-- Expiring Soon Section -->
|
||||
<h2 class="text-xl font-semibold mt-6 mb-2">Expiring Soon</h2>
|
||||
<table class="min-w-full border text-center align-middle mt-1 ml-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued At</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Expires</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ExpiringSoonQuotes}}
|
||||
<tr class="hover:bg-slate-50 transition">
|
||||
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
|
||||
<td class="px-4 py-2 border align-middle"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryRef}}</a></td>
|
||||
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
|
||||
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
|
||||
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
|
||||
<td class="px-4 py-2 border align-middle">
|
||||
{{if .LatestReminderType}}
|
||||
{{if or (eq .LatestReminderType "First Reminder") (eq .LatestReminderType "First Reminder Sent")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{.LatestReminderType}}</span>
|
||||
{{else if or (eq .LatestReminderType "Second Reminder") (eq .LatestReminderType "Second Reminder Sent")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700 border border-yellow-200">{{.LatestReminderType}}</span>
|
||||
{{else if or (eq .LatestReminderType "Final Reminder") (eq .LatestReminderType "Final Reminder Sent")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700 border border-red-200">{{.LatestReminderType}}</span>
|
||||
{{else}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">{{.LatestReminderType}}</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">No Reminder Sent</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-2 border align-middle">
|
||||
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No quotes expiring soon.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Recently Expired Quotes Section -->
|
||||
<h2 class="text-xl font-semibold mt-6 mb-2">Recently Expired</h2>
|
||||
<table class="min-w-full border mb-6 text-center align-middle mt-1 ml-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued At</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Expires</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
|
||||
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentlyExpiredQuotes}}
|
||||
<tr class="hover:bg-slate-50 transition">
|
||||
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
|
||||
<td class="px-4 py-2 border align-middle"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryRef}}</a></td>
|
||||
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
|
||||
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
|
||||
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
|
||||
<td class="px-4 py-2 border align-middle">
|
||||
{{if .LatestReminderType}}
|
||||
{{if or (eq .LatestReminderType "First Reminder Sent") (eq .LatestReminderType "First Reminder")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{.LatestReminderType}}</span>
|
||||
{{else if or (eq .LatestReminderType "Second Reminder Sent") (eq .LatestReminderType "Second Reminder")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700 border border-yellow-200">{{.LatestReminderType}}</span>
|
||||
{{else if or (eq .LatestReminderType "Final Reminder Sent") (eq .LatestReminderType "Final Reminder")}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700 border border-red-200">{{.LatestReminderType}}</span>
|
||||
{{else}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">{{.LatestReminderType}}</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">No Reminder Sent</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-2 border align-middle">
|
||||
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No recently expired quotes.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
// Convert .localdate to browser local date, .localdatetime to browser local date+time (no offset)
|
||||
function formatLocalDate(isoString) {
|
||||
if (!isoString) return '';
|
||||
var d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
function formatLocalDateTime(isoString) {
|
||||
if (!isoString) return '';
|
||||
var d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
// Show date and time in local time, no offset
|
||||
return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.localdate').forEach(function(el) {
|
||||
var iso = el.textContent.trim();
|
||||
if (iso) {
|
||||
el.textContent = formatLocalDate(iso);
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.localdatetime').forEach(function(el) {
|
||||
var iso = el.textContent.trim();
|
||||
if (iso) {
|
||||
el.textContent = formatLocalDateTime(iso);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
12
go-app/templates/quotes/second_reminder.html
Normal file
12
go-app/templates/quotes/second_reminder.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<p>Dear {{.CustomerName}},</p>
|
||||
|
||||
<p>We’re following up on the quotation submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we haven’t heard back, we wanted to check whether you’re still considering this proposal or if your requirements have changed.</p>
|
||||
|
||||
<p>If you’d like an updated or revised quote tailored to your current needs, simply reply to this email — we’re more than happy to assist.</p>
|
||||
|
||||
<p>If you have any questions or feedback regarding the original quotation, please don’t hesitate to let us know.</p>
|
||||
|
||||
<p>Thank you again for your time and consideration. We’d be glad to support you whenever you're ready.</p>
|
||||
|
||||
<p>Warm regards,</p>
|
||||
<p>CMC Technologies</p>
|
||||
1
go-app/tmp/stdout
Normal file
1
go-app/tmp/stdout
Normal file
|
|
@ -0,0 +1 @@
|
|||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue