diff --git a/.gitignore b/.gitignore index 706e6f17..302af075 100755 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/DEPLOYMENT-CADDY.md b/DEPLOYMENT-CADDY.md deleted file mode 100644 index 2b9baf7b..00000000 --- a/DEPLOYMENT-CADDY.md +++ /dev/null @@ -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 -``` \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 959b64a3..00000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/Dockerfile.go b/Dockerfile.go deleted file mode 100644 index 57c7d546..00000000 --- a/Dockerfile.go +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/Dockerfile.prod.db b/Dockerfile.prod.db new file mode 100644 index 00000000..3775d476 --- /dev/null +++ b/Dockerfile.prod.db @@ -0,0 +1 @@ +FROM mariadb:latest diff --git a/Dockerfile.prod.go b/Dockerfile.prod.go new file mode 100644 index 00000000..14d8d174 --- /dev/null +++ b/Dockerfile.prod.go @@ -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"] diff --git a/Dockerfile.prod.php b/Dockerfile.prod.php new file mode 100644 index 00000000..e69e5640 --- /dev/null +++ b/Dockerfile.prod.php @@ -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 diff --git a/Dockerfile.stg.db b/Dockerfile.stg.db new file mode 100644 index 00000000..3775d476 --- /dev/null +++ b/Dockerfile.stg.db @@ -0,0 +1 @@ +FROM mariadb:latest diff --git a/Dockerfile.stg.go b/Dockerfile.stg.go new file mode 100644 index 00000000..02727e00 --- /dev/null +++ b/Dockerfile.stg.go @@ -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"] diff --git a/Dockerfile.stg.php b/Dockerfile.stg.php new file mode 100644 index 00000000..e3d3eab1 --- /dev/null +++ b/Dockerfile.stg.php @@ -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 diff --git a/Dockerfile_stg b/Dockerfile_stg deleted file mode 100644 index c06b1c45..00000000 --- a/Dockerfile_stg +++ /dev/null @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 594a9c98..00000000 --- a/MIGRATION.md +++ /dev/null @@ -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:~$ - diff --git a/README.md b/README.md index b9c72315..0b879e2c 100644 --- a/README.md +++ b/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 \ No newline at end of file diff --git a/TESTING_DOCKER.md b/TESTING_DOCKER.md deleted file mode 100644 index 17256fcb..00000000 --- a/TESTING_DOCKER.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 00000000..1fed6122 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/config/core.php b/app/config/core.php index 6cd40776..2d86441b 100644 --- a/app/config/core.php +++ b/app/config/core.php @@ -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'); diff --git a/app/config/database.php b/app/config/database.php index 56058f83..d13969e6 100644 --- a/app/config/database.php +++ b/app/config/database.php @@ -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', diff --git a/app/config/database_stg.php b/app/config/database_stg.php index 1ee8c813..b17e988a 100644 --- a/app/config/database_stg.php +++ b/app/config/database_stg.php @@ -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' => '', ); } diff --git a/app/controllers/documents_controller.php b/app/controllers/documents_controller.php index 6aaa42c2..951d1e01 100755 --- a/app/controllers/documents_controller.php +++ b/app/controllers/documents_controller.php @@ -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('', ''); - 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('', ''); + foreach ($alwaysBcc as $bccEmail) { + if (!in_array($bccEmail, $bccArray)) { + $bccArray[] = $bccEmail; } } if (!empty($bccArray)) { diff --git a/app/views/layouts/default.ctp b/app/views/layouts/default.ctp index 40d5a2fe..139f88df 100755 --- a/app/views/layouts/default.ctp +++ b/app/views/layouts/default.ctp @@ -53,6 +53,7 @@ echo $scripts_for_layout;
  • link('Enquiries', '/enquiries/index'); ?>
    • link('Enquiry Register', '/enquiries/index'); ?>
    • +
    • link('Quotes Expiring', '/go/quotes'); ?>
  • diff --git a/app/webroot/.DS_Store b/app/webroot/.DS_Store new file mode 100644 index 00000000..ccfdfd56 Binary files /dev/null and b/app/webroot/.DS_Store differ diff --git a/.gitlab-ci.yml b/archive/ci/.gitlab-ci.yml similarity index 100% rename from .gitlab-ci.yml rename to archive/ci/.gitlab-ci.yml diff --git a/Dockerfile b/archive/ci/Dockerfile similarity index 100% rename from Dockerfile rename to archive/ci/Dockerfile diff --git a/Dockerfile.go.production b/archive/ci/Dockerfile.go.production similarity index 100% rename from Dockerfile.go.production rename to archive/ci/Dockerfile.go.production diff --git a/Dockerfile.go.staging b/archive/ci/Dockerfile.go.staging similarity index 100% rename from Dockerfile.go.staging rename to archive/ci/Dockerfile.go.staging diff --git a/archive/ci/Dockerfile.local.go b/archive/ci/Dockerfile.local.go new file mode 100644 index 00000000..9a787c48 --- /dev/null +++ b/archive/ci/Dockerfile.local.go @@ -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"] \ No newline at end of file diff --git a/Dockerfile.ubuntu-php b/archive/ci/Dockerfile.ubuntu-php similarity index 100% rename from Dockerfile.ubuntu-php rename to archive/ci/Dockerfile.ubuntu-php diff --git a/docker-compose.production.yml b/archive/ci/docker-compose.production.yml similarity index 100% rename from docker-compose.production.yml rename to archive/ci/docker-compose.production.yml diff --git a/docker-compose.proxy.yml b/archive/ci/docker-compose.proxy.yml similarity index 100% rename from docker-compose.proxy.yml rename to archive/ci/docker-compose.proxy.yml diff --git a/docker-compose.staging.yml b/archive/ci/docker-compose.staging.yml similarity index 100% rename from docker-compose.staging.yml rename to archive/ci/docker-compose.staging.yml diff --git a/Makefile b/archive/scripts/Makefile similarity index 100% rename from Makefile rename to archive/scripts/Makefile diff --git a/deploy-production.sh b/archive/scripts/deploy-production.sh similarity index 100% rename from deploy-production.sh rename to archive/scripts/deploy-production.sh diff --git a/deploy.sh b/archive/scripts/deploy.sh similarity index 100% rename from deploy.sh rename to archive/scripts/deploy.sh diff --git a/enter_docker.sh b/archive/scripts/enter_docker.sh similarity index 100% rename from enter_docker.sh rename to archive/scripts/enter_docker.sh diff --git a/fetch_latest_sql.sh b/archive/scripts/fetch_latest_sql.sh similarity index 100% rename from fetch_latest_sql.sh rename to archive/scripts/fetch_latest_sql.sh diff --git a/archive/scripts/refresh_data.sh b/archive/scripts/refresh_data.sh new file mode 100755 index 00000000..f659b047 --- /dev/null +++ b/archive/scripts/refresh_data.sh @@ -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." \ No newline at end of file diff --git a/archive/scripts/restore_db_from_backup.sh b/archive/scripts/restore_db_from_backup.sh new file mode 100644 index 00000000..67fa941e --- /dev/null +++ b/archive/scripts/restore_db_from_backup.sh @@ -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 diff --git a/run_docker_local.sh b/archive/scripts/run_docker_local.sh similarity index 100% rename from run_docker_local.sh rename to archive/scripts/run_docker_local.sh diff --git a/run_docker_prd.sh b/archive/scripts/run_docker_prd.sh similarity index 100% rename from run_docker_prd.sh rename to archive/scripts/run_docker_prd.sh diff --git a/run_docker_stg.sh b/archive/scripts/run_docker_stg.sh similarity index 100% rename from run_docker_stg.sh rename to archive/scripts/run_docker_stg.sh diff --git a/run_update_invoices.sh b/archive/scripts/run_update_invoices.sh similarity index 100% rename from run_update_invoices.sh rename to archive/scripts/run_update_invoices.sh diff --git a/run_vault.sh b/archive/scripts/run_vault.sh similarity index 100% rename from run_vault.sh rename to archive/scripts/run_vault.sh diff --git a/archive/scripts/sync_docs.sh b/archive/scripts/sync_docs.sh new file mode 100644 index 00000000..74ab241f --- /dev/null +++ b/archive/scripts/sync_docs.sh @@ -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/)" diff --git a/vault_cron.sh b/archive/scripts/vault_cron.sh similarity index 100% rename from vault_cron.sh rename to archive/scripts/vault_cron.sh diff --git a/backup.sh b/backup.sh deleted file mode 100644 index e9a83b9b..00000000 --- a/backup.sh +++ /dev/null @@ -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/ diff --git a/build_docker.sh b/build_docker.sh deleted file mode 100644 index 5e91e293..00000000 --- a/build_docker.sh +++ /dev/null @@ -1,3 +0,0 @@ -ID=$(docker ps -f ancestor=cmc:latest -q) -docker kill $ID -docker build . -t "cmc:latest" --platform linux/amd64 diff --git a/build_docker_stg.sh b/build_docker_stg.sh deleted file mode 100644 index 15285f3f..00000000 --- a/build_docker_stg.sh +++ /dev/null @@ -1,3 +0,0 @@ -ID=$(docker ps -f ancestor=cmc:stg -q) -docker kill $ID -docker build -f Dockerfile_stg . -t "cmc:stg" diff --git a/cake/libs/controller/components/email.php b/cake/libs/controller/components/email.php index b2e2ea42..e6377feb 100755 --- a/cake/libs/controller/components/email.php +++ b/cake/libs/controller/components/email.php @@ -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) { diff --git a/conf/apache-vhost.conf b/conf/apache-vhost.conf index e1820448..bae03d7a 100644 --- a/conf/apache-vhost.conf +++ b/conf/apache-vhost.conf @@ -1,7 +1,12 @@ -NameVirtualHost *:80 DocumentRoot /var/www/cmc-sales/app/webroot + + Options FollowSymLinks + AllowOverride All + Require all granted + + # 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 - + \ No newline at end of file diff --git a/conf/nginx-site.conf b/conf/nginx-site.conf index 4804ec30..0940883b 100644 --- a/conf/nginx-site.conf +++ b/conf/nginx-site.conf @@ -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; diff --git a/conf/nginx-site.prod.conf b/conf/nginx-site.prod.conf new file mode 100644 index 00000000..8c1a129e --- /dev/null +++ b/conf/nginx-site.prod.conf @@ -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 + +} diff --git a/conf/nginx-site.stg.conf b/conf/nginx-site.stg.conf new file mode 100644 index 00000000..52f0d990 --- /dev/null +++ b/conf/nginx-site.stg.conf @@ -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 + +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 00000000..7d5c46f7 --- /dev/null +++ b/deploy/Caddyfile @@ -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 + } +} diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh new file mode 100755 index 00000000..87a23bb3 --- /dev/null +++ b/deploy/deploy-prod.sh @@ -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 " +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 diff --git a/deploy/deploy-stg.sh b/deploy/deploy-stg.sh new file mode 100755 index 00000000..8efdb796 --- /dev/null +++ b/deploy/deploy-stg.sh @@ -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 " +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 diff --git a/deploy/scripts/backup_db.sh b/deploy/scripts/backup_db.sh new file mode 100644 index 00000000..921e6e82 --- /dev/null +++ b/deploy/scripts/backup_db.sh @@ -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/ diff --git a/deploy/scripts/run_all_migrations.sh b/deploy/scripts/run_all_migrations.sh new file mode 100644 index 00000000..b00f79a3 --- /dev/null +++ b/deploy/scripts/run_all_migrations.sh @@ -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." diff --git a/docker-compose.caddy-production.yml b/docker-compose.caddy-production.yml deleted file mode 100644 index 882d517a..00000000 --- a/docker-compose.caddy-production.yml +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docker-compose.caddy-staging-ubuntu.yml b/docker-compose.caddy-staging-ubuntu.yml deleted file mode 100644 index 08635661..00000000 --- a/docker-compose.caddy-staging-ubuntu.yml +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docker-compose.caddy-staging.yml b/docker-compose.caddy-staging.yml deleted file mode 100644 index 2a4856d2..00000000 --- a/docker-compose.caddy-staging.yml +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..aab24865 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.stg.yml b/docker-compose.stg.yml new file mode 100644 index 00000000..f0cf1437 --- /dev/null +++ b/docker-compose.stg.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 696b7a40..a2ae4abd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/go-app/.air.toml b/go-app/.air.toml new file mode 100644 index 00000000..b8689a6b --- /dev/null +++ b/go-app/.air.toml @@ -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 diff --git a/go-app/.env.example b/go-app/.env.example index 30589799..097f7288 100644 --- a/go-app/.env.example +++ b/go-app/.env.example @@ -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 diff --git a/go-app/cmd/server/main.go b/go-app/cmd/server/main.go index bc361765..3786409a 100644 --- a/go-app/cmd/server/main.go +++ b/go-app/cmd/server/main.go @@ -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") diff --git a/go-app/go.mod b/go-app/go.mod index b5904ead..e563a825 100644 --- a/go-app/go.mod +++ b/go-app/go.mod @@ -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 ) diff --git a/go-app/go.sum b/go-app/go.sum index 8366941a..cffeb998 100644 --- a/go-app/go.sum +++ b/go-app/go.sum @@ -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= diff --git a/go-app/internal/cmc/db/models.go b/go-app/internal/cmc/db/models.go index 7da8b1b9..8a1c73a9 100644 --- a/go-app/internal/cmc/db/models.go +++ b/go-app/internal/cmc/db/models.go @@ -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"` diff --git a/go-app/internal/cmc/db/querier.go b/go-app/internal/cmc/db/querier.go index 5079f60b..eae28a35 100644 --- a/go-app/internal/cmc/db/querier.go +++ b/go-app/internal/cmc/db/querier.go @@ -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) diff --git a/go-app/internal/cmc/db/quotes.sql.go b/go-app/internal/cmc/db/quotes.sql.go new file mode 100644 index 00000000..0bfccf38 --- /dev/null +++ b/go-app/internal/cmc/db/quotes.sql.go @@ -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, + ) +} diff --git a/go-app/internal/cmc/email/email.go b/go-app/internal/cmc/email/email.go new file mode 100644 index 00000000..77f6fefa --- /dev/null +++ b/go-app/internal/cmc/email/email.go @@ -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(", "))) +} diff --git a/go-app/internal/cmc/handlers/pages.go b/go-app/internal/cmc/handlers/pages.go index e97ca415..73417f2a 100644 --- a/go-app/internal/cmc/handlers/pages.go +++ b/go-app/internal/cmc/handlers/pages.go @@ -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 diff --git a/go-app/internal/cmc/handlers/quotes/quotes.go b/go-app/internal/cmc/handlers/quotes/quotes.go new file mode 100644 index 00000000..ccc8f16c --- /dev/null +++ b/go-app/internal/cmc/handlers/quotes/quotes.go @@ -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 "" + } +} diff --git a/go-app/internal/cmc/handlers/quotes/quotes_test.go b/go-app/internal/cmc/handlers/quotes/quotes_test.go new file mode 100644 index 00000000..8085b2e9 --- /dev/null +++ b/go-app/internal/cmc/handlers/quotes/quotes_test.go @@ -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)) + } +} diff --git a/go-app/internal/cmc/templates/templates.go b/go-app/internal/cmc/templates/templates.go index 18f18e40..ac5e8f8d 100644 --- a/go-app/internal/cmc/templates/templates.go +++ b/go-app/internal/cmc/templates/templates.go @@ -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) -} \ No newline at end of file +} diff --git a/go-app/server b/go-app/server index 35d84982..bc461769 100755 Binary files a/go-app/server and b/go-app/server differ diff --git a/go-app/sql/queries/quotes.sql b/go-app/sql/queries/quotes.sql new file mode 100644 index 00000000..a8398e64 --- /dev/null +++ b/go-app/sql/queries/quotes.sql @@ -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 (?, ?, ?, ?); \ No newline at end of file diff --git a/go-app/sql/schema/014_quotes.sql b/go-app/sql/schema/014_quotes.sql new file mode 100644 index 00000000..d98b8d4d --- /dev/null +++ b/go-app/sql/schema/014_quotes.sql @@ -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; \ No newline at end of file diff --git a/go-app/sql/schema/001_customers.sql b/go-app/sql/schema/ignore_001_customers.sql similarity index 100% rename from go-app/sql/schema/001_customers.sql rename to go-app/sql/schema/ignore_001_customers.sql diff --git a/go-app/sql/schema/002_products.sql b/go-app/sql/schema/ignore_002_products.sql similarity index 100% rename from go-app/sql/schema/002_products.sql rename to go-app/sql/schema/ignore_002_products.sql diff --git a/go-app/sql/schema/003_purchase_orders.sql b/go-app/sql/schema/ignore_003_purchase_orders.sql similarity index 100% rename from go-app/sql/schema/003_purchase_orders.sql rename to go-app/sql/schema/ignore_003_purchase_orders.sql diff --git a/go-app/sql/schema/004_users.sql b/go-app/sql/schema/ignore_004_users.sql similarity index 100% rename from go-app/sql/schema/004_users.sql rename to go-app/sql/schema/ignore_004_users.sql diff --git a/go-app/sql/schema/005_invoices.sql b/go-app/sql/schema/ignore_005_invoices.sql similarity index 100% rename from go-app/sql/schema/005_invoices.sql rename to go-app/sql/schema/ignore_005_invoices.sql diff --git a/go-app/sql/schema/006_addresses.sql b/go-app/sql/schema/ignore_006_addresses.sql similarity index 100% rename from go-app/sql/schema/006_addresses.sql rename to go-app/sql/schema/ignore_006_addresses.sql diff --git a/go-app/sql/schema/007_attachments.sql b/go-app/sql/schema/ignore_007_attachments.sql similarity index 100% rename from go-app/sql/schema/007_attachments.sql rename to go-app/sql/schema/ignore_007_attachments.sql diff --git a/go-app/sql/schema/008_boxes.sql b/go-app/sql/schema/ignore_008_boxes.sql similarity index 100% rename from go-app/sql/schema/008_boxes.sql rename to go-app/sql/schema/ignore_008_boxes.sql diff --git a/go-app/sql/schema/009_states.sql b/go-app/sql/schema/ignore_009_states.sql similarity index 100% rename from go-app/sql/schema/009_states.sql rename to go-app/sql/schema/ignore_009_states.sql diff --git a/go-app/sql/schema/010_statuses.sql b/go-app/sql/schema/ignore_010_statuses.sql similarity index 100% rename from go-app/sql/schema/010_statuses.sql rename to go-app/sql/schema/ignore_010_statuses.sql diff --git a/go-app/sql/schema/011_principles.sql b/go-app/sql/schema/ignore_011_principles.sql similarity index 100% rename from go-app/sql/schema/011_principles.sql rename to go-app/sql/schema/ignore_011_principles.sql diff --git a/go-app/sql/schema/012_countries.sql b/go-app/sql/schema/ignore_012_countries.sql similarity index 100% rename from go-app/sql/schema/012_countries.sql rename to go-app/sql/schema/ignore_012_countries.sql diff --git a/go-app/sql/schema/013_line_items.sql b/go-app/sql/schema/ignore_013_line_items.sql similarity index 100% rename from go-app/sql/schema/013_line_items.sql rename to go-app/sql/schema/ignore_013_line_items.sql diff --git a/go-app/sql/schema/documents.sql b/go-app/sql/schema/ignore_documents.sql similarity index 100% rename from go-app/sql/schema/documents.sql rename to go-app/sql/schema/ignore_documents.sql diff --git a/go-app/sql/schema/enquiries.sql b/go-app/sql/schema/ignore_enquiries.sql similarity index 100% rename from go-app/sql/schema/enquiries.sql rename to go-app/sql/schema/ignore_enquiries.sql diff --git a/go-app/templates/layouts/base.html b/go-app/templates/layouts/base.html index 022bc5ad..3a6438f2 100644 --- a/go-app/templates/layouts/base.html +++ b/go-app/templates/layouts/base.html @@ -6,8 +6,32 @@ {{block "title" .}}CMC Sales{{end}} - - + + + + @@ -20,70 +44,153 @@ {{block "head" .}}{{end}} - - - -
    -
    +
    +
    {{block "content" .}}{{end}}
    - -