prod #123

Merged
finley merged 122 commits from prod into master 2025-11-22 17:52:40 -08:00
103 changed files with 3773 additions and 2015 deletions

3
.gitignore vendored
View file

@ -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/*

View file

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

View file

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

View file

@ -1,57 +0,0 @@
# Build stage
FROM golang:1.23-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go mod files
COPY go-app/go.mod go-app/go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY go-app/ .
# Install sqlc (compatible with Go 1.23+)
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Generate sqlc code
RUN sqlc generate
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/server .
# Copy templates and static files
COPY go-app/templates ./templates
COPY go-app/static ./static
# Copy Gmail OAuth credentials if they exist
# Note: These files contain sensitive data and should not be committed to version control
# Consider using Docker secrets or environment variables for production
# Using conditional copy pattern to handle missing files gracefully
COPY go-app/credentials.jso[n] ./
COPY go-app/token.jso[n] ./
# Copy .env file if needed
COPY go-app/.env.example .env
# Expose port
EXPOSE 8080
# Run the application
CMD ["./server"]

1
Dockerfile.prod.db Normal file
View file

@ -0,0 +1 @@
FROM mariadb:latest

23
Dockerfile.prod.go Normal file
View file

@ -0,0 +1,23 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go-app/go.mod go-app/go.sum ./
RUN go mod download
COPY go-app/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/server .
COPY --from=builder /app/vault .
COPY go-app/templates ./templates
COPY go-app/static ./static
COPY go-app/.env.example .env
EXPOSE 8082
CMD ["./server"]

47
Dockerfile.prod.php Normal file
View file

@ -0,0 +1,47 @@
# Use the official PHP 5.6 Apache image for classic mod_php
FROM php:5.6-apache
# Install required system libraries and PHP extensions for CakePHP
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
apt-get update && \
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Copy vhost config to Apache's sites-available
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime \
&& a2ensite cmc-sales \
&& a2dissite 000-default \
&& a2enmod rewrite \
&& a2enmod headers
# Copy site into place.
ADD . /var/www/cmc-sales
ADD app/config/database.php /var/www/cmc-sales/app/config/database.php
RUN mkdir /var/www/cmc-sales/app/tmp
RUN mkdir /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

1
Dockerfile.stg.db Normal file
View file

@ -0,0 +1 @@
FROM mariadb:latest

21
Dockerfile.stg.go Normal file
View file

@ -0,0 +1,21 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go-app/go.mod go-app/go.sum ./
RUN go mod download
COPY go-app/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/server .
COPY go-app/templates ./templates
COPY go-app/static ./static
COPY go-app/.env.example .env
EXPOSE 8082
CMD ["./server"]

67
Dockerfile.stg.php Normal file
View file

@ -0,0 +1,67 @@
# Use the official PHP 5.6 Apache image for classic mod_php
FROM php:5.6-apache
# Install required system libraries and PHP extensions for CakePHP
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
apt-get update && \
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Legacy apt compatibility and install steps for Ubuntu 16.04 (now commented out)
# RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://old-releases.ubuntu.com/ubuntu/|g' /etc/apt/sources.list && \
# sed -i 's|http://security.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' /etc/apt/sources.list
# RUN apt-get update
# RUN apt-get -y upgrade
# RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure
# RUN apt-get update -o Acquire::AllowInsecureRepositories=true --allow-unauthenticated
# RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
# RUN a2enmod php5
# RUN php5enmod openssl
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
# RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
# ADD conf/php.ini /etc/php5/apache2/php.ini
# Copy vhost config to Apache's sites-available
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime \
&& a2ensite cmc-sales \
&& a2dissite 000-default \
&& a2enmod rewrite \
&& a2enmod headers
# Copy site into place.
ADD . /var/www/cmc-sales
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
RUN mkdir -p /var/www/cmc-sales/app/tmp
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
# No need to disable proxy_fcgi or remove PHP-FPM conf files in this image
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

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

View file

@ -1,18 +0,0 @@
# migration instructions
mysql -u cmc -p cmc < ~/migration/latest.sql
MariaDB [(none)]> CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'somepass';
Query OK, 0 rows affected (0.00 sec)
MariaDB [(none)]> GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
www-data@helios:~$ du -hcs vaultmsgs
64G vaultmsgs
64G total
www-data@helios:~$ du -hcs emails
192G emails
192G total
www-data@helios:~$

138
README.md
View file

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

View file

@ -1,394 +0,0 @@
# Running CMC Django Tests in Docker
This guide explains how to run the comprehensive CMC Django test suite using Docker for consistent, isolated testing.
## Quick Start
```bash
# 1. Setup test environment (one-time)
./run-tests-docker.sh setup
# 2. Run all tests
./run-tests-docker.sh run
# 3. Run tests with coverage
./run-tests-docker.sh coverage
```
## Test Environment Overview
The Docker test environment includes:
- **Isolated test database** (MariaDB on port 3307)
- **Django test container** with all dependencies
- **Coverage reporting** with HTML and XML output
- **PDF generation testing** with WeasyPrint/ReportLab
- **Parallel test execution** support
## Available Commands
### Setup and Management
```bash
# Build containers and setup test database
./run-tests-docker.sh setup
# Clean up all test containers and data
./run-tests-docker.sh clean
# View test container logs
./run-tests-docker.sh logs
# Open shell in test container
./run-tests-docker.sh shell
```
### Running Tests
```bash
# Run all tests
./run-tests-docker.sh run
# Run specific test suites
./run-tests-docker.sh run models # Model tests only
./run-tests-docker.sh run services # Service layer tests
./run-tests-docker.sh run auth # Authentication tests
./run-tests-docker.sh run views # View and URL tests
./run-tests-docker.sh run pdf # PDF generation tests
./run-tests-docker.sh run integration # Integration tests
# Run quick tests (models + services)
./run-tests-docker.sh quick
# Run tests with coverage reporting
./run-tests-docker.sh coverage
```
## Advanced Test Options
### Using Docker Compose Directly
```bash
# Run specific test with custom options
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
python cmcsales/manage.py test cmc.tests.test_models --verbosity=2 --keepdb
# Run tests with coverage
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
coverage run --source='.' cmcsales/manage.py test cmc.tests
# Generate coverage report
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
coverage report --show-missing
```
### Using the Test Script Directly
```bash
# Inside the container, you can use the test script with advanced options
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
/app/scripts/run-tests.sh --coverage --keepdb --failfast models
# Script options:
# -c, --coverage Enable coverage reporting
# -k, --keepdb Keep test database between runs
# -p, --parallel NUM Run tests in parallel
# -f, --failfast Stop on first failure
# -v, --verbosity NUM Verbosity level 0-3
```
## Test Suite Structure
### 1. Model Tests (`test_models.py`)
Tests all Django models including:
- Customer, Enquiry, Job, Document models
- Model validation and constraints
- Relationships and cascade behavior
- Financial calculations
```bash
./run-tests-docker.sh run models
```
### 2. Service Tests (`test_services.py`)
Tests business logic layer:
- Number generation service
- Financial calculation service
- Document service workflows
- Validation service
```bash
./run-tests-docker.sh run services
```
### 3. Authentication Tests (`test_authentication.py`)
Tests authentication system:
- Multiple authentication backends
- Permission decorators and middleware
- User management workflows
- Security features
```bash
./run-tests-docker.sh run auth
```
### 4. View Tests (`test_views.py`)
Tests web interface:
- CRUD operations for all entities
- AJAX endpoints
- Permission enforcement
- URL routing
```bash
./run-tests-docker.sh run views
```
### 5. PDF Tests (`test_pdf.py`)
Tests PDF generation:
- WeasyPrint and ReportLab engines
- Template rendering
- Document formatting
- Security and performance
```bash
./run-tests-docker.sh run pdf
```
### 6. Integration Tests (`test_integration.py`)
Tests complete workflows:
- End-to-end business processes
- Multi-user collaboration
- System integration scenarios
- Performance and security
```bash
./run-tests-docker.sh run integration
```
## Test Reports and Coverage
### Coverage Reports
After running tests with coverage, reports are available in:
- **HTML Report**: `./coverage-reports/html/index.html`
- **XML Report**: `./coverage-reports/coverage.xml`
- **Console**: Displayed after test run
```bash
# Run tests with coverage
./run-tests-docker.sh coverage
# View HTML report
open coverage-reports/html/index.html
```
### Test Artifacts
Test outputs are saved to:
- **Test Reports**: `./test-reports/`
- **Coverage Reports**: `./coverage-reports/`
- **Logs**: `./logs/`
- **PDF Test Files**: `./test-reports/pdf/`
## Configuration
### Environment Variables
The test environment uses these key variables:
```yaml
# Database configuration
DATABASE_HOST: test-db
DATABASE_NAME: test_cmc
DATABASE_USER: test_cmc
DATABASE_PASSWORD: testPassword123
# Django settings
DJANGO_SETTINGS_MODULE: cmcsales.settings
TESTING: 1
DEBUG: 0
# PDF generation
PDF_GENERATION_ENGINE: weasyprint
PDF_SAVE_DIRECTORY: /app/test-reports/pdf
```
### Test Database
- **Isolated database** separate from development/production
- **Runs on port 3307** to avoid conflicts
- **Optimized for testing** with reduced buffer sizes
- **Automatically reset** between test runs (unless `--keepdb` used)
## Performance Optimization
### Parallel Test Execution
```bash
# Run tests in parallel (faster execution)
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
/app/scripts/run-tests.sh --parallel=4 all
```
### Keeping Test Database
```bash
# Keep database between runs for faster subsequent tests
./run-tests-docker.sh run models --keepdb
```
### Quick Test Suite
```bash
# Run only essential tests for rapid feedback
./run-tests-docker.sh quick
```
## Continuous Integration
### GitHub Actions Example
```yaml
name: Test CMC Django
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup test environment
run: ./run-tests-docker.sh setup
- name: Run tests with coverage
run: ./run-tests-docker.sh coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage-reports/coverage.xml
```
## Troubleshooting
### Database Connection Issues
```bash
# Check database status
docker-compose -f docker-compose.test.yml ps
# View database logs
docker-compose -f docker-compose.test.yml logs test-db
# Restart database
docker-compose -f docker-compose.test.yml restart test-db
```
### Test Failures
```bash
# Run with maximum verbosity for debugging
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
python cmcsales/manage.py test cmc.tests.test_models --verbosity=3
# Use failfast to stop on first error
./run-tests-docker.sh run models --failfast
# Open shell to investigate
./run-tests-docker.sh shell
```
### Permission Issues
```bash
# Fix file permissions
sudo chown -R $USER:$USER test-reports coverage-reports logs
# Check Docker permissions
docker-compose -f docker-compose.test.yml run --rm cmc-django-test whoami
```
### Memory Issues
```bash
# Run tests with reduced parallel workers
docker-compose -f docker-compose.test.yml run --rm cmc-django-test \
/app/scripts/run-tests.sh --parallel=1 all
# Monitor resource usage
docker stats
```
## Development Workflow
### Recommended Testing Workflow
1. **Initial Setup** (one-time):
```bash
./run-tests-docker.sh setup
```
2. **During Development** (fast feedback):
```bash
./run-tests-docker.sh quick --keepdb
```
3. **Before Commit** (comprehensive):
```bash
./run-tests-docker.sh coverage
```
4. **Debugging Issues**:
```bash
./run-tests-docker.sh shell
# Inside container:
python cmcsales/manage.py test cmc.tests.test_models.CustomerModelTest.test_customer_creation --verbosity=3
```
### Adding New Tests
1. Create test file in appropriate module
2. Follow existing test patterns and base classes
3. Test locally:
```bash
./run-tests-docker.sh run models --keepdb
```
4. Run full suite before committing:
```bash
./run-tests-docker.sh coverage
```
## Integration with IDE
### PyCharm/IntelliJ
Configure remote interpreter using Docker:
1. Go to Settings → Project → Python Interpreter
2. Add Docker Compose interpreter
3. Use `docker-compose.test.yml` configuration
4. Set service to `cmc-django-test`
### VS Code
Use Dev Containers extension:
1. Create `.devcontainer/devcontainer.json`
2. Configure to use test Docker environment
3. Run tests directly in integrated terminal
## Best Practices
1. **Always run tests in Docker** for consistency
2. **Use `--keepdb` during development** for speed
3. **Run coverage reports before commits**
4. **Clean up regularly** to free disk space
5. **Monitor test performance** and optimize slow tests
6. **Use parallel execution** for large test suites
7. **Keep test data realistic** but minimal
8. **Test error conditions** as well as happy paths
## Resources
- **Django Testing Documentation**: https://docs.djangoproject.com/en/5.1/topics/testing/
- **Coverage.py Documentation**: https://coverage.readthedocs.io/
- **Docker Compose Reference**: https://docs.docker.com/compose/
- **CMC Test Suite Documentation**: See individual test modules for detailed information

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -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');

View file

@ -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',

View file

@ -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' => '',
);
}

View file

@ -1205,13 +1205,13 @@ EOT;
}
function format_email($email) {
$email = trim($email);
// Basic RFC 5322 email validation
if (!preg_match('/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $email)) {
return '';
}
return "$email <$email>";
function format_email($email) {
$email = trim($email);
// Basic RFC 5322 email validation
if (!preg_match('/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $email)) {
return '';
}
return $email;
}
function parse_email_to_array($input) {
@ -1325,18 +1325,21 @@ function email_pdf_with_custom_recipients($id = null, $to = null, $cc = null, $b
$msg = 'Invalid recipient email address.';
echo json_encode(array('success' => false, 'message' => $msg));
return;
} else {
$this->Email->to = implode(', ', $toArray);
}
} else {
// Pass as array - EmailComponent now properly handles arrays for TO field
$this->Email->to = $toArray;
}
$ccArray = $this->parse_email_to_array($cc);
if (!empty($ccArray)) {
$this->Email->cc = $ccArray;
}
$bccArray = $this->parse_email_to_array($bcc);
$defaultBccs = array('<carpis@cmctechnologies.com.au>', '<mcarpis@cmctechnologies.com.au>');
foreach ($defaultBccs as $defaultBcc) {
if (!in_array($defaultBcc, $bccArray)) {
$bccArray[] = $defaultBcc;
// Add always BCC recipients
// These emails will always be included in the BCC list, regardless of user input
$alwaysBcc = array('<carpis@cmctechnologies.com.au>', '<mcarpis@cmctechnologies.com.au>');
foreach ($alwaysBcc as $bccEmail) {
if (!in_array($bccEmail, $bccArray)) {
$bccArray[] = $bccEmail;
}
}
if (!empty($bccArray)) {

View file

@ -53,6 +53,7 @@ echo $scripts_for_layout;
<li><?php echo $html->link('Enquiries', '/enquiries/index'); ?>
<ul>
<li class="last"><?php echo $html->link('Enquiry Register', '/enquiries/index'); ?></li>
<li class="last"><?php echo $html->link('Quotes Expiring', '/go/quotes'); ?></li>
</ul>
</li>

BIN
app/webroot/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,23 @@
# Dev Dockerfile for Go hot reload with Air and sqlc
FROM golang:1.24.0
WORKDIR /app
# Install Air for hot reload
RUN go install github.com/air-verse/air@latest
# Install sqlc for SQL code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Copy source code
COPY go-app/ .
# Generate sqlc code
RUN sqlc generate
# Copy Air config
COPY go-app/.air.toml .air.toml
COPY go-app/.env.example .env
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

40
archive/scripts/refresh_data.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
set -euo pipefail
# Config
SSH_CONFIG="$HOME/.ssh/config"
SSH_IDENTITY="$HOME/.ssh/cmc" #Path to your SSH identity file
REMOTE_USER="cmc"
REMOTE_HOST="sales.cmctechnologies.com.au"
REMOTE_PATH="~/backups/"
LOCAL_BACKUP_DIR="backups"
DB_HOST="127.0.0.1"
DB_USER="cmc"
DB_NAME="cmc"
# Ensure backups dir exists
if [ ! -d "$LOCAL_BACKUP_DIR" ]; then
echo "Creating $LOCAL_BACKUP_DIR directory..."
mkdir -p "$LOCAL_BACKUP_DIR"
fi
# Step 1: Rsync backups (flatten output)
echo "Starting rsync..."
rsync -avz --progress -e "ssh -F $SSH_CONFIG -i $SSH_IDENTITY" --no-relative --include='backup_*.sql.gz' --exclude='*' "$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH" "$LOCAL_BACKUP_DIR/" || { echo "Rsync failed"; exit 1; }
echo "Rsync complete."
# Step 2: Find latest backup
LATEST_BACKUP=$(ls -t $LOCAL_BACKUP_DIR/backup_*.sql.gz | head -n1)
if [[ -z "$LATEST_BACKUP" ]]; then
echo "No backup file found!"
exit 1
fi
echo "Latest backup: $LATEST_BACKUP"
# Step 3: Import to MariaDB
read -s -p "Enter DB password for $DB_USER: " DB_PASS
echo
echo "Importing backup to MariaDB..."
gunzip -c "$LATEST_BACKUP" | mariadb -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" || { echo "Database import failed"; exit 1; }
echo "Database import complete."

View file

@ -0,0 +1,38 @@
#!/bin/bash
set -e
# Default to staging
TARGET="stg"
for arg in "$@"; do
if [[ "$arg" == "-target" ]]; then
NEXT_IS_TARGET=1
continue
fi
if [[ $NEXT_IS_TARGET == 1 ]]; then
TARGET="$arg"
NEXT_IS_TARGET=0
fi
done
if [[ "$TARGET" == "prod" ]]; then
DB_CONTAINER="cmc-prod-db"
DB_USER="cmc"
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
DB_NAME="cmc"
else
DB_CONTAINER="cmc-db"
DB_USER="cmc"
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
DB_NAME="cmc"
fi
# Sync latest backup from production
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:~/backups /home/cmc/
LATEST_BACKUP=$(ls -t /home/cmc/backups/backup_*.sql.gz | head -n1)
echo "Restoring database from latest backup: $LATEST_BACKUP to $TARGET ($DB_CONTAINER)"
if [ -f "$LATEST_BACKUP" ]; then
docker cp "$LATEST_BACKUP" "$DB_CONTAINER":/tmp/backup.sql.gz
docker exec "$DB_CONTAINER" sh -c "gunzip < /tmp/backup.sql.gz | mariadb -u $DB_USER -p'$DB_PASS' $DB_NAME"
echo "Database restore complete."
else
echo "No backup file found in /home/cmc/backups. Skipping database restore."
fi

View file

@ -0,0 +1,33 @@
#!/bin/bash
set -e
# Default target directory for docs
TARGET_DIR="/mnt/cmc-docs"
# Parse optional target dir
for arg in "$@"; do
if [[ "$arg" == "-target" ]]; then
NEXT_IS_TARGET=1
continue
fi
if [[ $NEXT_IS_TARGET == 1 ]]; then
TARGET_DIR="$arg"
NEXT_IS_TARGET=0
fi
done
PDF_SRC="/mnt/vault/pdf"
ATTACH_SRC="/mnt/vault/attachments_files"
PDF_DEST="$TARGET_DIR/pdf"
ATTACH_DEST="$TARGET_DIR/attachments_files"
mkdir -p "$PDF_DEST" "$ATTACH_DEST"
# Sync PDF files from remote host
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:"$PDF_SRC/" "$PDF_DEST/"
# Sync attachment files from remote host
rsync -avz -e "ssh -i ~/.ssh/cmc-old" --progress cmc@sales.cmctechnologies.com.au:"$ATTACH_SRC/" "$ATTACH_DEST/"
echo "Sync complete. Files are in $TARGET_DIR (pdf/ and attachments_files/)"

View file

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

View file

@ -1,3 +0,0 @@
ID=$(docker ps -f ancestor=cmc:latest -q)
docker kill $ID
docker build . -t "cmc:latest" --platform linux/amd64

View file

@ -1,3 +0,0 @@
ID=$(docker ps -f ancestor=cmc:stg -q)
docker kill $ID
docker build -f Dockerfile_stg . -t "cmc:stg"

View file

@ -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) {

View file

@ -1,7 +1,12 @@
NameVirtualHost *:80
<VirtualHost *:80>
DocumentRoot /var/www/cmc-sales/app/webroot
<Directory /var/www/cmc-sales/app/webroot>
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Send Apache logs to stdout/stderr for Docker
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
@ -9,4 +14,4 @@ CustomLog /dev/stdout combined
# Ensure PHP errors are also logged
php_flag log_errors on
php_value error_log /dev/stderr
</VirtualHost>
</VirtualHost>

View file

@ -2,6 +2,14 @@ server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
location /go/ {
proxy_pass http://cmc-go:8080;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-php:80;
proxy_read_timeout 300s;

26
conf/nginx-site.prod.conf Normal file
View file

@ -0,0 +1,26 @@
server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
location /go/ {
proxy_pass http://cmc-prod-go:8082;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-prod-php:80;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 0.0.0.0:80;
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

26
conf/nginx-site.stg.conf Normal file
View file

@ -0,0 +1,26 @@
server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
location /go/ {
proxy_pass http://cmc-stg-go:8082;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-stg-php:80;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 0.0.0.0:80;
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

55
deploy/Caddyfile Normal file
View file

@ -0,0 +1,55 @@
stg.cmctechnologies.com.au {
reverse_proxy localhost:8081
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/stg-access.log
format console
}
}
mail.stg.cmctechnologies.com.au {
basic_auth {
mailpit $2a$14$yTNicvMBIwF5cBNGnM3Ya.EIagOkP1Y0..qvMfdwUzUoN6Okw.nUG
}
reverse_proxy localhost:8025
}
localhost:2019 {
log_skip
}
prod.cmctechnologies.com.au {
reverse_proxy localhost:8080
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/prod-access.log
format console
}
}
sales.cmctechnologies.com.au {
reverse_proxy localhost:8080
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/prod-access.log
format console
}
}

87
deploy/deploy-prod.sh Executable file
View file

@ -0,0 +1,87 @@
#!/bin/bash
# Deploy production environment for cmc-sales
# Usage: ./deploy-prod.sh [--no-cache]
USE_CACHE=true
for arg in "$@"; do
if [[ "$arg" == "--no-cache" ]]; then
USE_CACHE=false
echo "No cache flag detected: will rebuild images without cache."
fi
done
if [[ "$USE_CACHE" == "true" ]]; then
echo "Using cached layers for docker build."
fi
echo "Starting production deployment for cmc-sales..."
echo "Setting variables..."
SERVER="cmc"
REPO="git@code.springupsoftware.com:cmc/cmc-sales.git"
BRANCH="master"
PROD_DIR="cmc-sales-prod"
echo "Connecting to server $SERVER via SSH..."
# Pass variables into SSH session
ssh $SERVER \
"SERVER=$SERVER REPO='$REPO' BRANCH='$BRANCH' PROD_DIR='$PROD_DIR' USE_CACHE='$USE_CACHE' bash -s" << 'ENDSSH'
set -e
echo "Connected to $SERVER."
cd /home/cmc
# Clone or update production branch
if [ -d "$PROD_DIR" ]; then
echo "Updating existing production directory $PROD_DIR..."
cd "$PROD_DIR"
git fetch origin
git checkout $BRANCH
git reset --hard origin/$BRANCH
else
echo "Cloning repository $REPO to $PROD_DIR..."
git clone -b $BRANCH $REPO $PROD_DIR
cd "$PROD_DIR"
fi
# Create .env.prod file for docker-compose
COMPOSE_ENV_PATH="/home/cmc/$PROD_DIR/.env.prod"
echo "(Re)creating .env.prod file for docker-compose..."
cat > "$COMPOSE_ENV_PATH" <<'COMPOSEENVEOF'
# SMTP Configuration for postfix relay
SMTP_USERNAME=sales
SMTP_PASSWORD=S%s'mMZ})MGsg$k!5N|mPSQ>
COMPOSEENVEOF
# Create .env file for go-app if it doesn't exist
ENV_PATH="/home/cmc/$PROD_DIR/go-app/.env"
echo "(Re)creating .env file for go-app..."
cat > "$ENV_PATH" <<'ENVEOF'
# Database configuration
DB_HOST=db
DB_PORT=3306
DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
# Root database password (for dbshell-root)
DB_ROOT_PASSWORD=secureRootPassword
# Environment variables for Go app mail configuration
SMTP_HOST=postfix
SMTP_PORT=25
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="CMC Sales <sales@cmctechnologies.com.au>"
ENVEOF
if [[ "$USE_CACHE" == "false" ]]; then
echo "Building and starting docker compose for production (no cache)..."
docker compose --env-file .env.prod -f docker-compose.prod.yml build --no-cache
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --remove-orphans
else
echo "Building and starting docker compose for production (using cache)..."
docker compose --env-file .env.prod -f docker-compose.prod.yml build
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --remove-orphans
fi
echo "Checking running containers..."
echo "Production deployment complete."
ENDSSH

78
deploy/deploy-stg.sh Executable file
View file

@ -0,0 +1,78 @@
#!/bin/bash
# Deploy staging environment for cmc-sales
# Usage: ./deploy-stg.sh [--no-cache]
USE_CACHE=true
for arg in "$@"; do
if [[ "$arg" == "--no-cache" ]]; then
USE_CACHE=false
echo "No cache flag detected: will rebuild images without cache."
fi
done
if [[ "$USE_CACHE" == "true" ]]; then
echo "Using cached layers for docker build."
fi
echo "Starting staging deployment for cmc-sales..."
echo "Setting variables..."
SERVER="cmc"
REPO="git@code.springupsoftware.com:cmc/cmc-sales.git"
BRANCH="stg"
STAGING_DIR="cmc-sales-staging"
echo "Connecting to server $SERVER via SSH..."
# Pass variables into SSH session
ssh $SERVER \
"SERVER=$SERVER REPO='$REPO' BRANCH='$BRANCH' STAGING_DIR='$STAGING_DIR' USE_CACHE='$USE_CACHE' bash -s" << 'ENDSSH'
set -e
echo "Connected to $SERVER."
cd /home/cmc
# Clone or update staging branch
if [ -d "$STAGING_DIR" ]; then
echo "Updating existing staging directory $STAGING_DIR..."
cd "$STAGING_DIR"
git fetch origin
git checkout $BRANCH
git reset --hard origin/$BRANCH
else
echo "Cloning repository $REPO to $STAGING_DIR..."
git clone -b $BRANCH $REPO $STAGING_DIR
cd "$STAGING_DIR"
fi
# Create .env file for go-app if it doesn't exist
ENV_PATH="/home/cmc/$STAGING_DIR/go-app/.env"
echo "(Re)creating .env file for go-app..."
cat > "$ENV_PATH" <<'ENVEOF'
# Database configuration
DB_HOST=db
DB_PORT=3306
DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
# Root database password (for dbshell-root)
DB_ROOT_PASSWORD=secureRootPassword
# Environment variables for Go app mail configuration
SMTP_HOST=postfix
SMTP_PORT=25
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="CMC Sales <sales@cmctechnologies.com.au>"
ENVEOF
if [[ "$USE_CACHE" == "false" ]]; then
echo "Building and starting docker compose for staging (no cache)..."
docker compose -f docker-compose.stg.yml build --no-cache
docker compose -f docker-compose.stg.yml up -d --remove-orphans
else
echo "Building and starting docker compose for staging (using cache)..."
docker compose -f docker-compose.stg.yml build
docker compose -f docker-compose.stg.yml up -d --remove-orphans
fi
echo "Checking running containers..."
echo "Deployment complete."
ENDSSH

View file

@ -0,0 +1,16 @@
#!/bin/bash
set -e
BACKUP_DIR="/home/cmc/backups"
mkdir -p "$BACKUP_DIR"
FILENAME="$BACKUP_DIR/backup_$(date +'%Y%m%d-%H%M%S').sql.gz"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting MariaDB backup to $FILENAME"
if docker exec cmc-prod-db sh -c 'mariadb-dump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' | gzip > "$FILENAME"; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup successful: $FILENAME"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup FAILED"
exit 1
fi
rclone copy "$FILENAME" gdrivebackups:database/
# rclone sync /home/cmc/cmc-sales/app/webroot/pdf gdrivebackups:pdf/

View file

@ -0,0 +1,40 @@
#!/bin/bash
set -e
# Default to staging
TARGET="stg"
for arg in "$@"; do
if [[ "$arg" == "-target" ]]; then
NEXT_IS_TARGET=1
continue
fi
if [[ $NEXT_IS_TARGET == 1 ]]; then
TARGET="$arg"
NEXT_IS_TARGET=0
fi
done
if [[ "$TARGET" == "prod" ]]; then
DB_CONTAINER="cmc-prod-db"
DB_USER="cmc"
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
DB_NAME="cmc"
SQL_DIR="/home/cmc/cmc-sales-prod/go-app/sql/schema"
else
DB_CONTAINER="cmc-db"
DB_USER="cmc"
DB_PASS="xVRQI&cA?7AU=hqJ!%au"
DB_NAME="cmc"
SQL_DIR="/home/cmc/cmc-sales-staging/go-app/sql/schema"
fi
for sqlfile in "$SQL_DIR"/*.sql; do
# Skip files starting with ignore_
if [[ $(basename "$sqlfile") == ignore_* ]]; then
echo "Skipping ignored migration: $sqlfile"
continue
fi
echo "Running migration: $sqlfile on $TARGET ($DB_CONTAINER)"
docker cp "$sqlfile" "$DB_CONTAINER":/tmp/migration.sql
docker exec "$DB_CONTAINER" sh -c "mariadb -u $DB_USER -p'$DB_PASS' $DB_NAME < /tmp/migration.sql"
done
echo "All migrations applied."

View file

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

View file

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

View file

@ -1,61 +0,0 @@
version: '3.8'
services:
cmc-php-staging:
build:
context: .
dockerfile: Dockerfile
platform: linux/amd64
container_name: cmc-php-staging
depends_on:
- db-staging
ports:
- "127.0.0.1:8091:80" # Only accessible from localhost
volumes:
- staging_pdf_data:/var/www/cmc-sales/app/webroot/pdf
- staging_attachments_data:/var/www/cmc-sales/app/webroot/attachments_files
restart: unless-stopped
environment:
- APP_ENV=staging
db-staging:
image: mariadb:latest
container_name: cmc-db-staging
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD_STAGING}
MYSQL_DATABASE: cmc_staging
MYSQL_USER: cmc_staging
MYSQL_PASSWORD: ${DB_PASSWORD_STAGING}
volumes:
- staging_db_data:/var/lib/mysql
restart: unless-stopped
ports:
- "127.0.0.1:3307:3306" # Only accessible from localhost
cmc-go-staging:
build:
context: .
dockerfile: Dockerfile.go.staging
container_name: cmc-go-staging
environment:
DB_HOST: db-staging
DB_PORT: 3306
DB_USER: cmc_staging
DB_PASSWORD: ${DB_PASSWORD_STAGING}
DB_NAME: cmc_staging
PORT: 8080
APP_ENV: staging
depends_on:
db-staging:
condition: service_started
ports:
- "127.0.0.1:8092:8080" # Only accessible from localhost
volumes:
- staging_pdf_data:/root/webroot/pdf
- ./credentials/staging:/root/credentials:ro
restart: unless-stopped
volumes:
staging_db_data:
staging_pdf_data:
staging_attachments_data:

116
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,116 @@
services:
postfix:
image: boky/postfix
restart: unless-stopped
container_name: cmc-prod-postfix
env_file:
- .env.prod
# Production: relay to Gmail SMTP
environment:
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
# Gmail SMTP relay settings
- RELAYHOST=smtp-relay.gmail.com
- RELAYHOST_PORT=587
# SMTP_USERNAME and SMTP_PASSWORD are loaded from .env.prod via env_file
- SMTP_TLS_SECURITY_LEVEL=encrypt
- SMTP_USE_TLS=yes
- SMTP_USE_STARTTLS=yes
# --- Mailpit relay (for testing only) ---
# - RELAYHOST=mailpit:1025
networks:
- cmc-prod-network
nginx:
image: nginx:latest
container_name: cmc-prod-nginx
hostname: nginx-prod
ports:
- "8080:80" # Production nginx on port 8080 to avoid conflict
volumes:
- ./conf/nginx-site.prod.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-prod-php
restart: unless-stopped
networks:
- cmc-prod-network
cmc-prod-php:
build:
context: .
dockerfile: Dockerfile.prod.php
container_name: cmc-prod-php
environment:
MAIL_HOST: postfix
MAIL_PORT: 25
DB_HOST: cmc-prod-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
- ./userpasswd:/etc/nginx/userpasswd:ro
networks:
- cmc-prod-network
restart: unless-stopped
depends_on:
- cmc-prod-db
cmc-prod-go:
build:
context: .
dockerfile: Dockerfile.prod.go
container_name: cmc-prod-go
environment:
DB_HOST: cmc-prod-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
PORT: 8082
SMTP_HOST: postfix
SMTP_PORT: 25
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "sales@cmctechnologies.com.au"
ports:
- "8083:8082"
volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
networks:
- cmc-prod-network
restart: unless-stopped
depends_on:
- cmc-prod-db
cmc-prod-db:
build:
context: .
dockerfile: Dockerfile.prod.db
container_name: cmc-prod-db
environment:
MYSQL_ROOT_PASSWORD: secureRootPassword
MYSQL_DATABASE: cmc
MYSQL_USER: cmc
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
volumes:
- db_data:/var/lib/mysql
ports:
- "3307:3306"
networks:
- cmc-prod-network
networks:
cmc-stg-network:
cmc-prod-network:
volumes:
db_data:

124
docker-compose.stg.yml Normal file
View file

@ -0,0 +1,124 @@
services:
postfix:
image: boky/postfix
restart: unless-stopped
container_name: cmc-stg-postfix
# Staging: relay to Mailpit (no authentication required)
environment:
- RELAYHOST=mailpit:1025
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
# --- Gmail SMTP relay settings (uncomment for production) ---
# - RELAYHOST=smtp-relay.gmail.com
# - RELAYHOST_PORT=587
# - SMTP_USERNAME=sales
# - SMTP_PASSWORD=S%s'mMZ})MGsg$k!5N|mPSQ>
# - SMTP_TLS_SECURITY_LEVEL=encrypt
# - SMTP_USE_TLS=yes
# - SMTP_USE_STARTTLS=yes
networks:
- cmc-stg-network
nginx:
image: nginx:latest
container_name: cmc-stg-nginx
hostname: nginx-stg
ports:
- "8081:80" # Staging nginx on different port
volumes:
- ./conf/nginx-site.stg.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-stg-php
restart: unless-stopped
networks:
- cmc-stg-network
cmc-stg-php:
build:
context: .
dockerfile: Dockerfile.stg.php
container_name: cmc-stg-php
environment:
MAIL_HOST: postfix
MAIL_PORT: 25
DB_HOST: cmc-stg-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
- ./userpasswd:/etc/nginx/userpasswd:ro
networks:
- cmc-stg-network
restart: unless-stopped
depends_on:
- cmc-stg-db
cmc-stg-go:
build:
context: .
dockerfile: Dockerfile.stg.go
container_name: cmc-stg-go
environment:
DB_HOST: cmc-stg-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
PORT: 8082
SMTP_HOST: postfix
SMTP_PORT: 25
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "sales@cmctechnologies.com.au"
ports:
- "8082:8082"
volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro
networks:
- cmc-stg-network
restart: unless-stopped
depends_on:
- cmc-stg-db
cmc-stg-db:
build:
context: .
dockerfile: Dockerfile.stg.db
container_name: cmc-stg-db
environment:
MYSQL_ROOT_PASSWORD: secureRootPassword
MYSQL_DATABASE: cmc
MYSQL_USER: cmc
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
volumes:
- db_data:/var/lib/mysql
ports:
- "3308:3306"
networks:
- cmc-stg-network
mailpit:
image: axllent/mailpit:latest
container_name: mailpit
ports:
- "8025:8025" # Mailpit web UI
- "1025:1025" # SMTP
networks:
- cmc-stg-network
- cmc-sales-prod_cmc-prod-network
restart: unless-stopped
networks:
cmc-stg-network:
cmc-sales-prod_cmc-prod-network:
external: true
volumes:
db_data:

View file

@ -16,7 +16,7 @@ services:
cmc-php:
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile.stg.php
platform: linux/amd64
container_name: cmc-php
depends_on:
@ -60,7 +60,7 @@ services:
cmc-go:
build:
context: .
dockerfile: Dockerfile.go
dockerfile: Dockerfile.local.go
container_name: cmc-go
environment:
DB_HOST: db
@ -75,25 +75,12 @@ services:
ports:
- "8080:8080"
volumes:
- ./app/webroot/pdf:/root/webroot/pdf
- ./go-app:/app
- ./go-app/.air.toml:/root/.air.toml
- ./go-app/.env.example:/root/.env
networks:
- cmc-network
restart: unless-stopped
develop:
watch:
- action: rebuild
path: ./go-app
ignore:
- ./go-app/bin
- ./go-app/.env
- ./go-app/tmp
- "**/.*" # Ignore hidden files
- action: sync
path: ./go-app/templates
target: /app/templates
- action: sync
path: ./go-app/static
target: /app/static
volumes:
db_data:

25
go-app/.air.toml Normal file
View file

@ -0,0 +1,25 @@
# Air configuration for Go hot reload
root = "./"
cmd = ["air"]
[build]
cmd = "go build -o server cmd/server/main.go"
bin = "server"
include = ["cmd", "internal", "go.mod", "go.sum"]
exclude = ["bin", "tmp", ".env"]
delay = 1000
log = "stdout"
kill_on_error = true
color = true
[[watch]]
path = "templates"
reload = true
[[watch]]
path = "static"
reload = true
[[watch]]
path = "tmp"
reload = false
mkdir = true

View file

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

View file

@ -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")

View file

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

View file

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

View file

@ -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"`

View file

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

View file

@ -0,0 +1,474 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: quotes.sql
package db
import (
"context"
"database/sql"
"time"
)
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until
`
type GetExpiringSoonQuotesRow struct {
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotes, dateADD)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetExpiringSoonQuotesRow{}
for rows.Next() {
var i GetExpiringSoonQuotesRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until
`
type GetExpiringSoonQuotesOnDayRow struct {
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetExpiringSoonQuotesOnDayRow{}
for rows.Next() {
var i GetExpiringSoonQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getQuoteRemindersByType = `-- name: GetQuoteRemindersByType :many
SELECT id, quote_id, reminder_type, date_sent, username
FROM quote_reminders
WHERE quote_id = ? AND reminder_type = ?
ORDER BY date_sent
`
type GetQuoteRemindersByTypeParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
}
func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error) {
rows, err := q.db.QueryContext(ctx, getQuoteRemindersByType, arg.QuoteID, arg.ReminderType)
if err != nil {
return nil, err
}
defer rows.Close()
items := []QuoteReminder{}
for rows.Next() {
var i QuoteReminder
if err := rows.Scan(
&i.ID,
&i.QuoteID,
&i.ReminderType,
&i.DateSent,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC
`
type GetRecentlyExpiredQuotesRow struct {
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotes, dateSUB)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRecentlyExpiredQuotesRow{}
for rows.Next() {
var i GetRecentlyExpiredQuotesRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC
`
type GetRecentlyExpiredQuotesOnDayRow struct {
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotesOnDay, dateSUB)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRecentlyExpiredQuotesOnDayRow{}
for rows.Next() {
var i GetRecentlyExpiredQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertQuoteReminder = `-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?)
`
type InsertQuoteReminderParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
DateSent time.Time `json:"date_sent"`
Username sql.NullString `json:"username"`
}
func (q *Queries) InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error) {
return q.db.ExecContext(ctx, insertQuoteReminder,
arg.QuoteID,
arg.ReminderType,
arg.DateSent,
arg.Username,
)
}

View file

@ -0,0 +1,177 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"html/template"
"net/smtp"
"os"
"strconv"
"sync"
)
var (
emailServiceInstance *EmailService
once sync.Once
)
// EmailService provides methods to send templated emails via SMTP.
type EmailService struct {
SMTPHost string
SMTPPort int
Username string
Password string
FromAddress string
}
// GetEmailService returns a singleton EmailService loaded from environment variables
func GetEmailService() *EmailService {
once.Do(func() {
host := os.Getenv("SMTP_HOST")
portStr := os.Getenv("SMTP_PORT")
port, err := strconv.Atoi(portStr)
if err != nil {
port = 25 // default SMTP port
}
username := os.Getenv("SMTP_USER")
password := os.Getenv("SMTP_PASS")
from := os.Getenv("SMTP_FROM")
emailServiceInstance = &EmailService{
SMTPHost: host,
SMTPPort: port,
Username: username,
Password: password,
FromAddress: from,
}
})
return emailServiceInstance
}
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
defaultBccs := []string{"carpis@cmctechnologies.com.au", "mcarpis@cmctechnologies.com.au"}
bccs = append(defaultBccs, bccs...)
const templateDir = "templates/quotes"
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
headers := make(map[string]string)
headers["From"] = es.FromAddress
headers["To"] = to
if len(ccs) > 0 {
headers["Cc"] = joinAddresses(ccs)
}
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
var msg bytes.Buffer
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
}
msg.WriteString("\r\n")
msg.Write(body.Bytes())
recipients := []string{to}
recipients = append(recipients, ccs...)
recipients = append(recipients, bccs...)
smtpAddr := fmt.Sprintf("%s:%d", es.SMTPHost, es.SMTPPort)
// If no username/password, assume no auth or TLS (e.g., MailHog)
if es.Username == "" && es.Password == "" {
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
auth := smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost)
// Establish connection to SMTP server
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Upgrade to TLS if supported (STARTTLS)
tlsconfig := &tls.Config{
ServerName: es.SMTPHost,
}
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(tlsconfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
// joinAddresses joins email addresses with a comma and space.
func joinAddresses(addrs []string) string {
return fmt.Sprintf("%s", bytes.Join([][]byte(func() [][]byte {
b := make([][]byte, len(addrs))
for i, a := range addrs {
b[i] = []byte(a)
}
return b
}()), []byte(", ")))
}

View file

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

View file

@ -0,0 +1,440 @@
package handlers
import (
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
)
// Import getUsername from pages.go
// If you move getUsername to a shared utils package, update the import accordingly.
func getUsername(r *http.Request) string {
username, _, ok := r.BasicAuth()
if ok && username != "" {
// Capitalise the username for display
return strings.Title(username)
}
return "Guest"
}
// Helper: returns date string or empty if zero
func formatDate(t time.Time) string {
if t.IsZero() || t.Year() == 1970 {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
func isValidDBTime(t time.Time) bool {
return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC))
}
// calcExpiryInfo is a helper to calculate expiry info for a quote
func calcExpiryInfo(validUntil time.Time) (string, int, int) {
now := time.Now()
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
validUntilDate := time.Date(validUntil.Year(), validUntil.Month(), validUntil.Day(), 0, 0, 0, 0, validUntil.Location())
daysUntil := int(validUntilDate.Sub(nowDate).Hours() / 24)
daysSince := int(nowDate.Sub(validUntilDate).Hours() / 24)
var relative string
if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) {
switch daysUntil {
case 0:
relative = "expires today"
case 1:
relative = "expires tomorrow"
default:
relative = "expires in " + strconv.Itoa(daysUntil) + " days"
}
} else {
switch daysSince {
case 0:
relative = "expired today"
case 1:
relative = "expired yesterday"
default:
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
}
}
return relative, daysUntil, daysSince
}
// QuoteRow interface for all quote row types
// (We use wrapper types since sqlc structs can't be modified directly)
type QuoteRow interface {
GetID() int32
GetUsername() string
GetEnquiryID() int32
GetEnquiryRef() string
GetDateIssued() time.Time
GetValidUntil() time.Time
GetReminderType() int32
GetReminderSent() time.Time
GetCustomerName() string
GetCustomerEmail() string
}
// Wrapper types for each DB row struct
type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow }
func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID }
func (q ExpiringSoonQuoteRowWrapper) GetUsername() string { return q.Username }
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow }
func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID }
func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string { return q.Username }
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type ExpiringSoonQuoteOnDayRowWrapper struct {
db.GetExpiringSoonQuotesOnDayRow
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string { return q.Username }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteOnDayRowWrapper struct {
db.GetRecentlyExpiredQuotesOnDayRow
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string { return q.Username }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
// Helper: formats a quote row for output (generic)
func formatQuoteRow(q QuoteRow) map[string]interface{} {
relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil())
return map[string]interface{}{
"ID": q.GetID(),
"Username": strings.Title(q.GetUsername()),
"EnquiryID": q.GetEnquiryID(),
"EnquiryRef": q.GetEnquiryRef(),
"CustomerName": strings.TrimSpace(q.GetCustomerName()),
"CustomerEmail": q.GetCustomerEmail(),
"DateIssued": formatDate(q.GetDateIssued()),
"ValidUntil": formatDate(q.GetValidUntil()),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
"LatestReminderSent": formatDate(q.GetReminderSent()),
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
}
}
type QuoteQueries interface {
GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error)
InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error)
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error)
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
}
type EmailSender interface {
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
}
type QuotesHandler struct {
queries QuoteQueries
tmpl *templates.TemplateManager
emailService EmailSender
}
func NewQuotesHandler(queries QuoteQueries, tmpl *templates.TemplateManager, emailService EmailSender) *QuotesHandler {
return &QuotesHandler{
queries: queries,
tmpl: tmpl,
emailService: emailService,
}
}
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
// Days to look ahead and behind for expiring quotes
days := 7
// Show all quotes that are expiring in the next 7 days
expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Show all quotes that have expired in the last 60 days
recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
"ExpiringSoonQuotes": expiringSoonQuotes,
"User": getUsername(r),
}
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// GetOutstandingQuotes returns outstanding quotes based on daysUntilExpiry.
func (h *QuotesHandler) GetOutstandingQuotes(r *http.Request, daysUntilExpiry int) ([]map[string]interface{}, error) {
var rows []map[string]interface{}
ctx := r.Context()
// If daysUntilExpiry is positive, get quotes expiring soon; if negative, get recently expired quotes
if daysUntilExpiry >= 0 {
quotes, err := h.queries.GetExpiringSoonQuotes(ctx, daysUntilExpiry)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteRowWrapper{q}))
}
} else {
days := -daysUntilExpiry
quotes, err := h.queries.GetRecentlyExpiredQuotes(ctx, days)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteRowWrapper{q}))
}
}
return rows, nil
}
// GetOutstandingQuotesOnDay returns quotes expiring exactly N days from today (if day >= 0), or exactly N days ago (if day < 0).
func (h *QuotesHandler) GetOutstandingQuotesOnDay(r *http.Request, day int) ([]map[string]interface{}, error) {
var rows []map[string]interface{}
ctx := r.Context()
// If day is positive, get quotes expiring on that day; if negative, get recently expired quotes on that day in the past
if day >= 0 {
quotes, err := h.queries.GetExpiringSoonQuotesOnDay(ctx, day)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteOnDayRowWrapper{q}))
}
} else {
days := -day
quotes, err := h.queries.GetRecentlyExpiredQuotesOnDay(ctx, days)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteOnDayRowWrapper{q}))
}
}
return rows, nil
}
type QuoteReminderType int
const (
FirstReminder QuoteReminderType = 1
SecondReminder QuoteReminderType = 2
ThirdReminder QuoteReminderType = 3
)
func (t QuoteReminderType) String() string {
switch t {
case FirstReminder:
return "FirstReminder"
case SecondReminder:
return "SecondReminder"
case ThirdReminder:
return "ThirdReminder"
default:
return "UnknownReminder"
}
}
type quoteReminderJob struct {
DayOffset int
ReminderType QuoteReminderType
Subject string
Template string
}
// DailyQuoteExpirationCheck checks quotes for reminders and expiry notices (callable as a cron job from main)
func (h *QuotesHandler) DailyQuoteExpirationCheck() {
fmt.Println("Running DailyQuoteExpirationCheck...")
reminderJobs := []quoteReminderJob{
{7, FirstReminder, "Reminder: Quote %s Expires Soon", "first_reminder.html"},
{-7, SecondReminder, "Follow-Up: Quote %s Expired", "second_reminder.html"},
{-60, ThirdReminder, "Final Reminder: Quote %s Closed", "final_reminder.html"},
}
for _, job := range reminderJobs {
quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset)
if err != nil {
fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err)
continue
}
if len(quotes) == 0 {
continue
}
for _, q := range quotes {
// Format dates as DD/MM/YYYY
var submissionDate, expiryDate string
if dateIssued, ok := q["DateIssued"].(string); ok && dateIssued != "" {
t, err := time.Parse(time.RFC3339, dateIssued)
if err == nil {
submissionDate = t.Format("02/01/2006")
} else {
submissionDate = dateIssued
}
}
if validUntil, ok := q["ValidUntil"].(string); ok && validUntil != "" {
t, err := time.Parse(time.RFC3339, validUntil)
if err == nil {
expiryDate = t.Format("02/01/2006")
} else {
expiryDate = validUntil
}
}
templateData := map[string]interface{}{
"CustomerName": q["CustomerName"],
"SubmissionDate": submissionDate,
"ExpiryDate": expiryDate,
"QuoteRef": q["EnquiryRef"],
}
err := h.SendQuoteReminderEmail(
context.Background(),
q["ID"].(int32),
job.ReminderType,
q["CustomerEmail"].(string),
fmt.Sprintf(job.Subject, q["EnquiryRef"]),
job.Template,
templateData,
nil,
)
if err != nil {
fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err)
} else {
fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"])
}
}
}
}
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, username *string) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
}
// Safeguard: check for valid template data
if templateData == nil {
return fmt.Errorf("template data is required")
}
// Safeguard: check for valid reminder type
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if reminder already sent
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
})
if err != nil {
return fmt.Errorf("failed to check existing reminders: %w", err)
}
// Exit if the email has already been sent
if len(reminders) > 0 {
return nil
}
// Send the email
err = h.emailService.SendTemplateEmail(
recipient,
subject,
templateName,
templateData,
nil, nil,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
// Record the reminder
var user sql.NullString
if username != nil {
user = sql.NullString{String: *username, Valid: true}
} else {
user = sql.NullString{Valid: false}
}
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
DateSent: time.Now().UTC(),
Username: user,
})
if err != nil {
return fmt.Errorf("failed to record reminder: %w", err)
}
return nil
}
// Helper: get reminder type as string
func reminderTypeString(reminderType int) string {
switch reminderType {
case 0:
return "No Reminder"
case 1:
return "First Reminder"
case 2:
return "Second Reminder"
case 3:
return "Final Reminder"
default:
return ""
}
}

View file

@ -0,0 +1,359 @@
package handlers
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
)
// Mocks
type mockQuoteRow struct {
reminderType int32
reminderSent time.Time
designatedDay time.Weekday
emailSent bool
}
func (m *mockQuoteRow) GetReminderType() int32 { return m.reminderType }
func (m *mockQuoteRow) GetReminderSent() time.Time { return m.reminderSent }
func (m *mockQuoteRow) GetCustomerEmail() string { return "test@example.com" }
// Realistic mock for db.Queries
type mockQueries struct {
reminders []db.QuoteReminder
insertCalled bool
}
func (m *mockQueries) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
var filtered []db.QuoteReminder
for _, r := range m.reminders {
if r.QuoteID == params.QuoteID && r.ReminderType == params.ReminderType {
filtered = append(filtered, r)
}
}
return filtered, nil
}
func (m *mockQueries) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
m.insertCalled = true
m.reminders = append(m.reminders, db.QuoteReminder{
QuoteID: params.QuoteID,
ReminderType: params.ReminderType,
DateSent: params.DateSent,
Username: params.Username,
})
return &mockResult{}, nil
}
func (m *mockQueries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Mock sql.Result for InsertQuoteReminder
type mockResult struct{}
func (m *mockResult) LastInsertId() (int64, error) { return 1, nil }
func (m *mockResult) RowsAffected() (int64, error) { return 1, nil }
// Realistic mock for email.EmailService
type mockEmailService struct {
sent bool
sentReminders map[int32]map[int32]bool // quoteID -> reminderType -> sent
}
func (m *mockEmailService) SendTemplateEmail(to, subject, templateName string, data interface{}, ccs, bccs []string) error {
m.sent = true
if m.sentReminders == nil {
m.sentReminders = make(map[int32]map[int32]bool)
}
var quoteID int32
var reminderType int32
if dataMap, ok := data.(map[string]interface{}); ok {
if id, ok := dataMap["QuoteID"].(int32); ok {
quoteID = id
} else if id, ok := dataMap["QuoteID"].(int); ok {
quoteID = int32(id)
}
if rt, ok := dataMap["ReminderType"].(int32); ok {
reminderType = rt
} else if rt, ok := dataMap["ReminderType"].(int); ok {
reminderType = int32(rt)
}
}
if quoteID == 0 {
quoteID = 123
}
if reminderType == 0 {
reminderType = 1
}
if m.sentReminders[quoteID] == nil {
m.sentReminders[quoteID] = make(map[int32]bool)
}
m.sentReminders[quoteID][reminderType] = true
return nil
}
// Mock for db.Queries with error simulation
type mockQueriesError struct{}
func (m *mockQueriesError) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
return nil, errors.New("db error")
}
func (m *mockQueriesError) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
return &mockResult{}, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Test: Should send email on designated day if not already sent
// Description: Verifies that a reminder email is sent and recorded when no previous reminder exists for the quote. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// Simulate designated day logic by calling SendQuoteReminderEmail
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent on designated day")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded on designated day")
}
}
// Test: Should NOT send email if reminder already sent
// Description: Verifies that if a reminder for the quote and type already exists, the handler does not send another email or record a duplicate. Should fail if duplicate reminders are allowed.
func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent if already reminded")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded if already reminded")
}
}
// Test: Should only send reminder once, second call should fail
// Description: Sends a reminder, then tries to send the same reminder again. The first should succeed, the second should fail and not send or record a duplicate. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// First call should succeed
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected first call to succeed, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
}
// Second call should fail
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err2 == nil {
t.Error("Expected error for already sent reminder on second call")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected no additional reminder recorded in DB, got %d", len(mq.reminders))
}
}
// Test: Should send and record reminder if not already sent
// Description: Verifies that a reminder is sent and recorded when no previous reminder exists. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded")
}
}
// Test: Should ignore already sent reminder
// Description: Verifies that the handler does not send or record a reminder if one already exists for the quote/type. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded")
}
}
// Test: Should fail if DB returns error
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled.
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
h := &QuotesHandler{queries: &mockQueriesError{}}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error when DB fails, got nil")
}
}
// Edge case: nil recipient
// Test: Should fail if recipient is empty
// Description: Verifies that the handler returns an error if the recipient email is missing. Should fail if emails are sent to empty recipients.
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error for nil recipient, got nil")
}
}
// Edge case: missing template data
// Test: Should fail if template data is missing
// Description: Verifies that the handler returns an error if template data is nil. Should fail if emails are sent without template data.
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil)
if err == nil {
t.Error("Expected error for missing template data, got nil")
}
}
// Boundary: invalid reminder type
// Test: Should fail if reminder type is invalid
// Description: Verifies that the handler returns an error for an invalid reminder type. Should fail if invalid types are allowed.
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error for invalid reminder type, got nil")
}
}
// Test: Multiple reminders of different types allowed for same quote
// Description: Verifies that reminders of different types for the same quote can be sent and recorded independently. Should fail if only one reminder per quote is allowed.
func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// First reminder type
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected first reminder to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Second reminder type
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil)
if err2 != nil {
t.Fatalf("Expected second reminder to be sent, got %v", err2)
}
if !me.sentReminders[123][2] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 2")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}
// Test: Reminders for different quotes are independent
// Description: Verifies that reminders for different quotes do not block each other and are recorded independently. Should fail if reminders for one quote affect another.
func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// Send reminder for quote 123
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Send reminder for quote 456
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil)
if err2 != nil {
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
}
if !me.sentReminders[456][1] {
t.Error("Expected email to be sent and recorded for quote 456, reminder 1")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}

View file

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

Binary file not shown.

View file

@ -0,0 +1,217 @@
-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until;
-- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until;
-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC;
-- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC;
-- name: GetQuoteRemindersByType :many
SELECT id, quote_id, reminder_type, date_sent, username
FROM quote_reminders
WHERE quote_id = ? AND reminder_type = ?
ORDER BY date_sent;
-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?);

View file

@ -0,0 +1,34 @@
-- cmc.quotes definition (matches existing database)
CREATE TABLE IF NOT EXISTS `quotes` (
`created` datetime NOT NULL,
`modified` datetime NOT NULL,
`id` int(11) NOT NULL AUTO_INCREMENT,
`enquiry_id` int(50) NOT NULL,
`currency_id` int(11) NOT NULL,
`revision` int(5) NOT NULL COMMENT 'limited at 5 digits. Really, you''re not going to have more revisions of a single quote than that',
`delivery_time` varchar(400) NOT NULL COMMENT 'estimated delivery time for quote',
`delivery_time_frame` varchar(100) NOT NULL,
`payment_terms` varchar(400) NOT NULL,
`days_valid` int(3) NOT NULL,
`date_issued` date NOT NULL,
`valid_until` date NOT NULL,
`delivery_point` varchar(400) NOT NULL,
`exchange_rate` varchar(255) NOT NULL,
`customs_duty` varchar(255) NOT NULL,
`document_id` int(11) NOT NULL,
`commercial_comments` text DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=18245 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-- cmc.quote_reminders definition
CREATE TABLE IF NOT EXISTS `quote_reminders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`quote_id` int(11) NOT NULL,
`reminder_type` int(3) NOT NULL COMMENT '1=1st, 2=2nd, 3=3rd reminder',
`date_sent` datetime NOT NULL,
`username` varchar(100) DEFAULT NULL COMMENT 'User who manually (re)sent the reminder',
PRIMARY KEY (`id`),
KEY `quote_id` (`quote_id`),
CONSTRAINT `quote_reminders_ibfk_1` FOREIGN KEY (`quote_id`) REFERENCES `quotes` (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;

View file

@ -6,8 +6,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}CMC Sales{{end}}</title>
<!-- Bulma CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
cmcblue: '#4686c3',
}
}
}
}
</script>
<style>
/* Additional CSS to assist with more complicated transitions for the nav bar items */
.navbar-dropdown {
opacity: 0;
visibility: hidden;
transition: opacity 80ms ease, visibility 80ms ease;
}
.group:hover .navbar-dropdown,
.group:focus-within .navbar-dropdown {
opacity: 1;
visibility: visible;
}
</style>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@ -20,70 +44,153 @@
{{block "head" .}}{{end}}
</head>
<body>
<!-- Navigation -->
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<strong>CMC Sales</strong>
<body class="min-h-screen flex flex-col">
<!-- Cute Light Blue Navigation -->
<nav class="bg-cmcblue shadow flex items-center justify-between px-4 py-2" role="navigation" aria-label="main navigation">
<div class="flex items-center space-x-2">
<a class="flex items-center space-x-2 text-white font-bold text-lg" href="/">
<span><i class="fas fa-flask text-cmcblue bg-white rounded p-1"></i></span>
<span>CMC Sales</span>
</a>
<span class="mx-4 h-8 w-px bg-white opacity-40"></span>
</div>
<div class="flex-1 flex items-center">
<ul class="flex space-x-2">
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/enquiries/index">
<span>Enquiries</span>
<span><i class="ml-1 fas fa-envelope text-white"></i></span>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/enquiries/index">Enquiry Register</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/go/quotes">Quotes Expiring</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/documents/index">
<span>Documents</span>
<span><i class="ml-1 fas fa-file-alt text-white"></i></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Dashboard</span>
</a>
<a class="navbar-item" href="/customers">
<span class="icon"><i class="fas fa-users"></i></span>
<span>Customers</span>
</a>
<a class="navbar-item" href="/products">
<span class="icon"><i class="fas fa-box"></i></span>
<span>Products</span>
</a>
<a class="navbar-item" href="/purchase-orders">
<span class="icon"><i class="fas fa-file-invoice"></i></span>
<span>Purchase Orders</span>
</a>
<a class="navbar-item" href="/enquiries">
<span class="icon"><i class="fas fa-envelope"></i></span>
<span>Enquiries</span>
</a>
<a class="navbar-item" href="/emails">
<span class="icon"><i class="fas fa-mail-bulk"></i></span>
<span>Emails</span>
</a>
</div>
</div>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/documents/index">Documents Index</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/jobs/index">
<span>Jobs</span>
<span><i class="ml-1 fas fa-briefcase text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/jobs/reports">Reports</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/jobs/index">Job List</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/shipments/index">
<span>Shipments</span>
<span><i class="ml-1 fas fa-truck text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-64 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index">All Shipments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/import">Import Shipments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/direct">Direct Shipments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/export">Export Shipments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/index/local">Local Shipments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/shipments/reports">Monthly Deferred GST</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/freight_forwarders">Freight Forwarders</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/customers/index">
<span>Customers</span>
<span><i class="ml-1 fas fa-users text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/customers/index">Customer Index</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/customers/add">Add Customer</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/industries/index">Industries</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/purchase_orders/index">
<span>POs</span>
<span><i class="ml-1 fas fa-file-invoice text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/purchase_orders/index">PO Index</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/invoices/index">
<span>Invoices</span>
<span><i class="ml-1 fas fa-receipt text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/invoices/index">Invoices Index</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/invoices/printView">Print View</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/products/index">
<span>Products</span>
<span><i class="ml-1 fas fa-box text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/products/index">Product Index</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/products/add">Add Product</a></li>
</ul>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/principles/index">
<span>Principles</span>
<span><i class="ml-1 fas fa-user-tie text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-56 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/principles/index">Principle Index</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/attachments/index">Attachments</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/principles/add">Add Principle</a></li>
</ul>
</li>
<li>
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/enquiries/search" id="searchLink">
<span>Search</span>
<span><i class="ml-1 fas fa-search text-white"></i></span>
</a>
</li>
<li class="relative group">
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/pages/about">
<span>Help</span>
<span><i class="ml-1 fas fa-question-circle text-white"></i></span>
</a>
<ul class="navbar-dropdown absolute left-0 top-[calc(100%_-_2px)] w-48 bg-white shadow-lg rounded z-10">
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/pages/bug">Raise a bug</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="https://gitlab.com/minimalist.software/cmc-sales/issues">Issue tracker</a></li>
<li><a class="block px-4 py-2 text-cmcblue hover:bg-cmcblue/10" href="/pages/about">About</a></li>
</ul>
</li>
<li>
<a class="flex items-center space-x-1 text-white px-2 py-1 bg-cmcblue cursor-pointer rounded transition duration-200 ease-in-out" href="/admin">
<span>Admin</span>
<span><i class="ml-1 fas fa-cog text-white"></i></span>
</a>
</li>
</ul>
</div>
<div class="flex items-center space-x-2">
<span><i class="fas fa-user text-cmcblue bg-white rounded p-1"></i></span>
<span class="text-white">{{if .User}}{{.User}}{{else}}Guest{{end}}</span>
</div>
</nav>
<!-- Main Content -->
<section class="section">
<div class="container">
<section class="section flex-1">
<div class="container p-4">
{{block "content" .}}{{end}}
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>CMC Sales</strong> &copy; 2024 CMC Technologies
</p>
</div>
<footer class="bg-slate-500 text-white text-center py-2 text-sm mt-8 w-full">
<strong>CMC Sales</strong> &copy; {{ currentYear }}
</footer>
<!-- Custom JS -->

View file

@ -0,0 +1,12 @@
<p>Dear {{.CustomerName}},</p>
<p>Were getting in touch regarding the quotation issued on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As of today, more than <strong>60 days</strong> have passed without a response.</p>
<p>This quotation is no longer valid, and well consider the proposal closed unless we hear otherwise. If youre still interested in moving forward, wed be happy to provide a new quote tailored to your current requirements.</p>
<p>If you have any outstanding questions or would like to initiate a new quote, simply reply to this email — were here to help.</p>
<p>Thank you again for your time and consideration.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>

View file

@ -0,0 +1,15 @@
<html>
<body>
<p>Dear {{.CustomerName}},</p>
<p>Wed like to remind you that the quote we provided on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, remains valid for another <strong>7 days</strong>. After this period, the quote will expire.</p>
<p>If you require more time, were happy to help — simply reply to this email to request an extension or a refreshed version of the quote.</p>
<p>Should you have any questions or need further clarification, please dont hesitate to get in touch. Were here to support you and ensure everything is in order.</p>
<p>Thank you for time and consideration. We look forward to the opportunity to work with you.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>
</body>
</html>

View file

@ -0,0 +1,127 @@
{{define "content"}}
<h1 class="text-3xl font-bold mb-4 text-gray-800">Quotes Expiring</h1>
<div class="pl-4">
<!-- Expiring Soon Section -->
<h2 class="text-xl font-semibold mt-6 mb-2">Expiring Soon</h2>
<table class="min-w-full border text-center align-middle mt-1 ml-4">
<thead>
<tr>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued At</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Expires</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
</tr>
</thead>
<tbody>
{{range .ExpiringSoonQuotes}}
<tr class="hover:bg-slate-50 transition">
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
<td class="px-4 py-2 border align-middle"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryRef}}</a></td>
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderType}}
{{if or (eq .LatestReminderType "First Reminder") (eq .LatestReminderType "First Reminder Sent")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{.LatestReminderType}}</span>
{{else if or (eq .LatestReminderType "Second Reminder") (eq .LatestReminderType "Second Reminder Sent")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700 border border-yellow-200">{{.LatestReminderType}}</span>
{{else if or (eq .LatestReminderType "Final Reminder") (eq .LatestReminderType "Final Reminder Sent")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700 border border-red-200">{{.LatestReminderType}}</span>
{{else}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">{{.LatestReminderType}}</span>
{{end}}
{{else}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">No Reminder Sent</span>
{{end}}
</td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No quotes expiring soon.</td></tr>
{{end}}
</tbody>
</table>
<!-- Recently Expired Quotes Section -->
<h2 class="text-xl font-semibold mt-6 mb-2">Recently Expired</h2>
<table class="min-w-full border mb-6 text-center align-middle mt-1 ml-4">
<thead>
<tr>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued At</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Expires</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
</tr>
</thead>
<tbody>
{{range .RecentlyExpiredQuotes}}
<tr class="hover:bg-slate-50 transition">
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
<td class="px-4 py-2 border align-middle"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryRef}}</a></td>
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderType}}
{{if or (eq .LatestReminderType "First Reminder Sent") (eq .LatestReminderType "First Reminder")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200">{{.LatestReminderType}}</span>
{{else if or (eq .LatestReminderType "Second Reminder Sent") (eq .LatestReminderType "Second Reminder")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700 border border-yellow-200">{{.LatestReminderType}}</span>
{{else if or (eq .LatestReminderType "Final Reminder Sent") (eq .LatestReminderType "Final Reminder")}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700 border border-red-200">{{.LatestReminderType}}</span>
{{else}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">{{.LatestReminderType}}</span>
{{end}}
{{else}}
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700 border border-gray-300">No Reminder Sent</span>
{{end}}
</td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No recently expired quotes.</td></tr>
{{end}}
</tbody>
</table>
</div>
<script>
// Convert .localdate to browser local date, .localdatetime to browser local date+time (no offset)
function formatLocalDate(isoString) {
if (!isoString) return '';
var d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
return d.toLocaleDateString();
}
function formatLocalDateTime(isoString) {
if (!isoString) return '';
var d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
// Show date and time in local time, no offset
return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit' });
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.localdate').forEach(function(el) {
var iso = el.textContent.trim();
if (iso) {
el.textContent = formatLocalDate(iso);
}
});
document.querySelectorAll('.localdatetime').forEach(function(el) {
var iso = el.textContent.trim();
if (iso) {
el.textContent = formatLocalDateTime(iso);
}
});
});
</script>
{{end}}

View file

@ -0,0 +1,12 @@
<p>Dear {{.CustomerName}},</p>
<p>Were following up on the quotation submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we havent heard back, we wanted to check whether youre still considering this proposal or if your requirements have changed.</p>
<p>If youd like an updated or revised quote tailored to your current needs, simply reply to this email — were more than happy to assist.</p>
<p>If you have any questions or feedback regarding the original quotation, please dont hesitate to let us know.</p>
<p>Thank you again for your time and consideration. Wed be glad to support you whenever you're ready.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>

1
go-app/tmp/stdout Normal file
View file

@ -0,0 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1

Some files were not shown because too many files have changed in this diff Show more