Compare commits

..

No commits in common. "master" and "kzrl/64-editable-payment-terms" have entirely different histories.

2777 changed files with 519 additions and 32044 deletions

10
.gitignore vendored
View file

@ -2,8 +2,6 @@ app/tmp/*
*.tar.gz
*.swp
*.swo
.env.prod
.env.stg
app/vendors/tcpdf/cache/*
app/tests/*
app/emails/*
@ -13,11 +11,3 @@ app/vaultmsgs/*
app/cake_eclipse_helper.php
app/webroot/pdf/*
app/webroot/attachments_files/*
backups/*
# Go binaries
go/server
go/vault
go/go.mod
go/go.sum
go/goose.env

122
CLAUDE.md
View file

@ -1,122 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Application Overview
CMC Sales is a B2B sales management system for CMC Technologies. The codebase consists of:
- **Legacy CakePHP 1.2.5 application** (in `/php/`) - Primary business logic
- **Modern Go API** (in `/go/`) - New development using sqlc and Gorilla Mux
**Note**: Documentation also references a Django application that is not present in the current codebase.
## Development Commands
### Docker Development
```bash
# Start all services (both CakePHP and Go applications)
docker compose up
# Start specific services
docker compose up cmc-php # CakePHP app only
docker compose up cmc-go # Go app only
docker compose up db # Database only
# Restore database from backup (get backups via rsync first)
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
# Access development sites (add to /etc/hosts: 127.0.0.1 cmclocal)
# http://cmclocal (CakePHP legacy app via nginx)
# http://localhost:8080 (Go modern app direct access)
```
### Testing
```bash
# CakePHP has minimal test coverage
# Test files located in /app/tests/
# Uses SimpleTest framework
```
### Go Application Development
```bash
# Navigate to Go app directory
cd go
# Configure private module access (first time only)
go env -w GOPRIVATE=code.springupsoftware.com
# Install dependencies and sqlc
make install
# Generate sqlc code from SQL queries
make sqlc
# Run the Go server locally
make run
# Run tests
make test
# Build binary
make build
```
## Architecture
### CakePHP Application (Legacy)
- **Framework**: CakePHP 1.2.5 (MVC pattern)
- **PHP Version**: PHP 5 (Ubuntu Lucid 10.04 container)
- **Location**: `/app/`
- **Key Directories**:
- `app/models/` - ActiveRecord models
- `app/controllers/` - Request handlers
- `app/views/` - Templates (.ctp files)
- `app/config/` - Configuration including database.php
- `app/vendors/` - Third-party libraries (TCPDF for PDFs, PHPExcel)
- `app/webroot/` - Public assets and file uploads
### Go Application (Modern)
- **Framework**: Gorilla Mux (HTTP router)
- **Database**: sqlc for type-safe SQL queries
- **Location**: `/go/`
- **Structure**:
- `cmd/server/` - Main application entry point
- `internal/cmc/handlers/` - HTTP request handlers
- `internal/cmc/db/` - Generated sqlc code
- `sql/queries/` - SQL query definitions
- `sql/schema/` - Database schema
- **API Base Path**: `/api/v1`
### Database
- **Engine**: MariaDB
- **Host**: `db` (Docker service) or `127.0.0.1` locally
- **Name**: `cmc`
- **User**: `cmc`
- **Password**: `xVRQI&cA?7AU=hqJ!%au` (hardcoded in `app/config/database.php`)
## Key Business Entities
- **Customers** - Client companies with ABN validation
- **Purchase Orders** - Orders with suppliers (revision tracking enabled)
- **Quotes** - Customer price quotations
- **Invoices** - Billing documents
- **Products** - Catalog with categories and options
- **Shipments** - Logistics and freight management
- **Jobs** - Project tracking
- **Documents** - PDF generation via TCPDF
- **Emails** - Correspondence tracking
## File Storage
- **PDF files**: `/app/webroot/pdf/` (mounted volume)
- **Attachments**: `/app/webroot/attachments_files/` (mounted volume)
## Critical Considerations
- **Security**: Running on extremely outdated Ubuntu Lucid (10.04) with PHP 5
- **Database Changes**: Exercise caution - the database may be shared with other applications
- **CakePHP Conventions**: Uses 2008-era patterns and practices
- **Authentication**: Basic auth configured via `userpasswd` file
- **PDF Generation**: Uses TCPDF library in `/app/vendors/tcpdf/`

View file

@ -1,4 +1,4 @@
FROM ghcr.io/kzrl/ubuntu:lucid
FROM ubuntu:lucid
# Set environment variables.
ENV HOME /root
@ -57,9 +57,5 @@ RUN chmod +x /var/www/cmc-sales/run_vault.sh
RUN chmod +x /var/www/cmc-sales/run_update_invoices.sh
# Ensure Apache error/access logs go to Docker stdout/stderr
RUN ln -sf /dev/stdout /var/log/apache2/access.log && \
ln -sf /dev/stderr /var/log/apache2/error.log
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

@ -1,27 +0,0 @@
FROM golang:1.24-alpine
WORKDIR /app
# Copy go.mod and go.sum first
COPY go/go.mod go/go.sum ./
# Download dependencies
RUN go mod download
# Install Air for hot reload (pinned to v1.52.3 for Go 1.24 compatibility)
RUN go install github.com/air-verse/air@v1.52.3
# Install sqlc for SQL code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Copy source code
COPY go/ .
# Generate sqlc code
RUN sqlc generate
# Copy Air config
COPY go/.air.toml .air.toml
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

View file

@ -1,47 +0,0 @@
# 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 php/ /var/www/cmc-sales
ADD php/app/config/database_local.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
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

@ -1,4 +0,0 @@
FROM mariadb:latest
# Copy custom MariaDB configuration to disable strict mode
COPY conf/mariadb-no-strict.cnf /etc/mysql/conf.d/

View file

@ -1,23 +0,0 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go/go.mod go/go.sum ./
RUN go mod download
COPY go/ .
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/templates ./templates
COPY go/static ./static
COPY go/.env.example .env
EXPOSE 8082
CMD ["./server"]

View file

@ -1,47 +0,0 @@
# 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 php/ /var/www/cmc-sales
ADD php/app/config/database.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
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

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

View file

@ -1,21 +0,0 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go/go.mod go/go.sum ./
RUN go mod download
COPY go/ .
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/templates ./templates
COPY go/static ./static
COPY go/.env.example .env
EXPOSE 8082
CMD ["./server"]

View file

@ -1,67 +0,0 @@
# 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 php/ /var/www/cmc-sales
ADD php/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

63
Dockerfile_stg Normal file
View file

@ -0,0 +1,63 @@
# This is 99% the same as the prod one. I should do something smarter here.
FROM ubuntu:lucid
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
RUN sed -i 's/archive/old-releases/' /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y upgrade
# Install apache, PHP, and supplimentary programs. curl and lynx-cur are for debugging the container.
RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
# Enable apache mods.
#RUN php5enmod openssl
RUN a2enmod php5
RUN a2enmod rewrite
RUN a2enmod headers
# Update the PHP.ini file, enable <? ?> tags and quieten logging.
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
#RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
ADD conf/php.ini /etc/php5/apache2/php.ini
# Manually set up the apache environment variables
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Update the default apache site with the config we created.
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime
RUN a2dissite 000-default
# Copy site into place.
ADD . /var/www/cmc-sales
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
RUN mkdir /var/www/cmc-sales/app/tmp
RUN mkdir /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
RUN chmod +x /var/www/cmc-sales/run_vault.sh
RUN a2ensite cmc-sales
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

18
MIGRATION.md Normal file
View file

@ -0,0 +1,18 @@
# 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:~$

167
README.md
View file

@ -1,111 +1,102 @@
# cmc-sales
CMC Sales is a business management system with two applications:
- **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
``` shell
git clone git@code.springupsoftware.com:cmc/cmc-sales.git
cd cmc-sales
# Easy way - use the setup script
./start-development.sh
# Manual way
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
docker compose up --build
gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
```
### Access Applications
**Add to /etc/hosts:**
```
127.0.0.1 cmclocal
```
**Application URLs:**
- **CakePHP (Legacy)**: http://cmclocal - Original CakePHP 1.2.5 application
- **Go (Modern)**: http://localhost:8080 - New Go application with HTMX frontend
- **Database**: localhost:3306 (user: `cmc`, password: see `app/config/database.php`)
### Architecture
- **cmc-php**: Legacy CakePHP application (nginx proxied)
- **cmc-go**: Modern Go application (direct access on port 8080)
- **db**: Shared MariaDB database
- **nginx**: Reverse proxy for CakePHP app
Both applications share the same database, allowing for gradual migration.
## Install
### Requirements
- **Go Application**: Requires Go 1.23+ (for latest sqlc)
- Alternative: Use `Dockerfile.go.legacy` with Go 1.21 and sqlc v1.26.0
Debian or Ubuntu OS. These instructions written for Debian 9.9
## Deployment
Assumed pre-work:
### Prerequisites
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
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:
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
```
Host cmc
HostName node0.prd.springupsoftware.com
User cmc
IdentityFile ~/.ssh/cmc
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
```
### Deployment Procedures
Deploy to staging or production using the scripts in the `deploy/` directory:
**Deploy to Staging:**
```bash
./scripts/deploy/deploy-stg.sh
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
```
**Deploy to Production:**
```bash
./deploy/deploy-prod.sh
6. Clone the cmc-sales repo
```
git clone git@gitlab.com:minimalist.software/cmc-sales.git
```
**Rebuild without cache (useful after dependency changes):**
```bash
./scripts/deploy/deploy-prod.sh --no-cache
./scripts/deploy/deploy-stg.sh --no-cache
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';
```
### How Deployment Works
8. Get the latest backup from Google Drive
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
In the shared google drive:
eg. backups/database/backup_20191217_21001.sql.gz
### Deployment Environments
Copy up to the new server:
```
rsync backup_*.gz root@newserver:~/
- **Staging**: Branch `stg` → https://stg.cmctechnologies.com.au
- **Production**: Branch `prod` → https://sales.cmctechnologies.com.au or https://prod.cmctechnologies.com.au
```
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
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
```

View file

@ -43,6 +43,4 @@
*
*/
//EOF
require_once(dirname(__FILE__) . '/php7_compat.php');
?>

View file

@ -47,13 +47,19 @@ 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' => 'postfix'
));
'host' => '172.17.0.1'));
//Production/Staging Config
@ -74,8 +80,8 @@ Cache::config('default', array(
));
Configure::write('email_directory', '/var/www/emails');
Configure::write('pdf_directory', $basedir.'webroot/pdf/');
Configure::write('attachments_directory', $basedir.'webroot/attachments_files/');
Configure::write('pdf_directory', $basedir.'/webroot/pdf/');
Configure::write('attachments_directory', $basedir.'/webroot/attachments_files/');
/**
@ -168,30 +174,6 @@ Configure::write('Security.salt', 'uiPxR3MzVXAID5zucbxLdxP4TX33buPoCWZr4JfroGoaE
Configure::write('Acl.classname', 'DbAcl');
Configure::write('Acl.database', 'default');
/**
* Tailscale Authentication Configuration
*
* Enable Tailscale HTTP header authentication support
* When enabled, the system will check for Tailscale authentication headers
* before falling back to HTTP Basic Auth
*/
Configure::write('Tailscale.enabled', true);
/**
* Auto-create users from Tailscale authentication
* When enabled, users authenticated via Tailscale headers will be
* automatically created if they don't exist in the database
*/
Configure::write('Tailscale.autoCreateUsers', false);
/**
* Default access level for auto-created Tailscale users
* Options: 'user', 'manager', 'admin'
*/
Configure::write('Tailscale.defaultAccessLevel', 'user');
/**
* Set timezone to Australian Eastern Time
* This handles both AEST and AEDT (daylight saving) automatically
*/
date_default_timezone_set('Australia/Sydney');

View file

@ -7,9 +7,9 @@
class DATABASE_CONFIG {
var $default = array(
'driver' => 'mysqli',
'driver' => 'mysql',
'persistent' => false,
'host' => 'cmc-prod-db',
'host' => '172.17.0.1',
'login' => 'cmc',
'password' => 'xVRQI&cA?7AU=hqJ!%au',
'database' => 'cmc',

View file

@ -0,0 +1,14 @@
<?php
class DATABASE_CONFIG {
var $default = array(
'driver' => 'mysql',
'persistent' => false,
'host' => '172.17.0.1',
'login' => 'staging',
'password' => 'stagingmoopwoopVerySecure',
'database' => 'staging',
'prefix' => '',
);
}

View file

@ -0,0 +1,143 @@
<?php
/* App Controller */
class AppController extends Controller {
var $components = array('RequestHandler');
var $uses = array('User');
var $helpers = array('Javascript', 'Time', 'Html', 'Form');
function beforeFilter() {
// Find the user that matches the HTTP basic auth user
$user = $this->User->find('first', array('recursive' => 0, 'conditions' => array('User.username'=>$_SERVER["PHP_AUTH_USER"])));
$this->set("currentuser", $user);
if($this->RequestHandler->isAjax()) {
Configure::write('debug', 0);
}
}
/**
* Check if the current logged in user is an admin
* @return boolean
*/
function isAdmin() {
$currentuser = $this->getCurrentUser();
if($currentuser['access_level'] == 'admin') {
return true;
}
else {
return false;
}
}
function isManager() {
$currentuser = $this->getCurrentUser();
if($currentuser['access_level'] == 'manager') {
return true;
}
else {
return false;
}
}
/**
* Read the current logged in user.
* @return array - the currently logged in user.
*/
function getCurrentUser() {
$user = $this->User->find('first', array('recursive' => 0, 'conditions' => array('User.username'=>$_SERVER["PHP_AUTH_USER"])));
return $user;
}
/**
* Return the id of the current user. False if not logged in.
*/
function getCurrentUserID() {
$currentuser = $this->getCurrentUser();
if($currentuser) {
return $currentuser['User']['id'];
}
else {
return false;
}
}
function calculateTotals($document, $gst) {
$totals = array('subtotal'=>0, 'gst'=>0, 'total'=>0);
foreach($document['LineItem'] as $lineitem) {
if($lineitem['option'] == 1) {
$totals['subtotal'] = 'TBA';
$totals['total'] = 'TBA';
$totals['gst'] = 'TBA';
return $totals;
}
else {
$totals['subtotal'] += $lineitem['net_price'];
}
}
if($gst == 1) {
$totals['gst'] = 0.1*$totals['subtotal'];
}
$totals['total'] = $totals['gst'] + $totals['subtotal'];
return $totals;
}
function unset_keys($array, $keys) {
foreach($keys as $key ) {
$array[$key] = null;
}
return $array;
}
function unset_multiple_keys($array, $keys) {
foreach($array as $index => $item) {
$array[$index]['id'] = null;
$array[$index]['document_id'] = null;
$array[$index]['costing_id'] = null;
}
}
/**
*
* @param <type> $year
* @param <type> $prevYear
* @return <type>
*/
function getFirstDayFY($year,$prevYear = false) {
if($prevYear == false) {
return mktime(0,0,0,7,1,$year);
}
else {
return mktime(0,0,0,7,1,$year-1);
}
}
/**
*
* @param <type> $year
* @return <int>
*/
function getLastDayFY($year) {
return mktime(23,59,59,6,30,$year);
}
}
?>

View file

@ -40,82 +40,19 @@ class AttachmentsController extends AppController {
function add() {
if (!empty($this->data)) {
// Check if file was uploaded
if (empty($this->data['Attachment']['file']['tmp_name'])) {
$error = 'No file uploaded';
if (isset($this->data['Attachment']['file']['error'])) {
$errorCodes = array(
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'File only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension',
);
$errorCode = $this->data['Attachment']['file']['error'];
$error = isset($errorCodes[$errorCode]) ? $errorCodes[$errorCode] : 'Unknown error: ' . $errorCode;
}
$this->Session->setFlash(__('File upload error: ' . $error, true));
$principles = $this->Attachment->Principle->find('list');
$this->set(compact('products', 'principles'));
return;
$attachment = $this->Attachment->process_attachment($this->data);
if(!$attachment) {
$this->Session->setFlash('The Attachment could not be saved. The filename exists');
}
else {
$this->Attachment->create();
// Proxy the upload request to the Go application
$goHost = getenv('GO_APP_HOST');
$goUrl = 'http://' . $goHost . '/go/attachments/upload';
// Prepare the multipart form data for the Go app
$postFields = array();
$postFields['file'] = new CURLFile(
$this->data['Attachment']['file']['tmp_name'],
$this->data['Attachment']['file']['type'],
$this->data['Attachment']['file']['name']
);
if (!empty($this->data['Attachment']['name'])) {
$postFields['name'] = $this->data['Attachment']['name'];
}
if (!empty($this->data['Attachment']['description'])) {
$postFields['description'] = $this->data['Attachment']['description'];
}
if (!empty($this->data['Attachment']['principle_id'])) {
$postFields['principle_id'] = $this->data['Attachment']['principle_id'];
}
// Make the request to Go app
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $goUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Accept: application/json'
));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($httpCode == 201) {
if ($this->Attachment->save($attachment)) {
$this->Session->setFlash(__('The Attachment has been saved', true));
$this->redirect(array('action'=>'index'));
} else {
$errorMsg = 'The Attachment could not be saved.';
if ($curlError) {
$errorMsg .= ' cURL Error: ' . $curlError;
} elseif ($response) {
$errorMsg .= ' Response: ' . $response;
} else {
$errorMsg .= ' HTTP Code: ' . $httpCode;
$this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true));
}
error_log('Attachment upload failed: ' . $errorMsg);
$this->Session->setFlash(__($errorMsg, true));
}
}
$principles = $this->Attachment->Principle->find('list');

View file

@ -98,21 +98,6 @@ ENDDETAILS;
)
));
$sql = "SELECT DISTINCT freight_forwarder_text FROM purchase_orders WHERE freight_forwarder_text != '' ORDER BY freight_forwarder_text ASC;";
$previousValues = $this->Document->query($sql);
$ffOptions = array(
"CMC will make the booking with Fedex Economy. We will send you the labels. Please send us the packing list, invoice, weights and dimensions.",
"FEDEX Airfreight Economy service on CMC Account #409368441",
"FEDEX INTERNATIONAL Priority on CMC Account #409368441"
);
foreach($previousValues as $val) {
array_push($ffOptions, $val["purchase_orders"]["freight_forwarder_text"]);
}
asort($ffOptions);
$this->set('freightForwarderSelect', $ffOptions);
break;
case 'orderAck':
$this->set('currencies', $this->Document->OrderAcknowledgement->Currency->find('list'));
@ -314,16 +299,13 @@ ENDDETAILS;
$this->data['PurchaseOrder']['title'] = $newPOnumber;
$this->data['PurchaseOrder']['issue_date'] = date('Y-m-d');
$this->data['PurchaseOrder']['description'] = "Items as per attached schedule";
$this->data['PurchaseOrder']['deliver_to'] = <<<ENDDELIVER
CMC TECHNOLOGIES PTY LTD<br>
Unit 19, 77 Bourke Road<br>
Alexandria NSW 2015 AUSTRALIA<br>
Attn: Con Carpis E: sales@cmctechnologies.com.au<br>
P: +61 2 9669 4000 F:+61 2 9669 4111
ENDDELIVER;
//Fuck it all.
$this->data['PurchaseOrder']['shipping_instructions'] = <<<ENDINSTRUCTIONS
<b>PART SHIPMENT:</b> Not Acceptable - please advise ASAP if production of an Item will Delay the Dispatch of the Complete Order by the Date Required stated above.<br>
@ -389,6 +371,7 @@ ENDINSTRUCTIONS;
if(isset($source_document_id)) {
//This is not ideal. But nothing else is either.
$sourceDoc = $this->Document->find('first', array('conditions' => array('Document.id' => $source_document_id)));
@ -444,19 +427,6 @@ ENDINSTRUCTIONS;
}
function getBaseTitle($titleWithRevision) {
// The pattern matches:
// - : the literal hyphen
// Rev : the literal string "Rev" (case-sensitive)
// \d+ : one or more digits (representing the revision number)
// $ : asserts that this pattern must be at the end of the string
//
// If "Rev" could be "rev", "REV", etc., you can make it case-insensitive
// by adding the 'i' flag: '/-Rev\d+$/i'
$baseTitle = preg_replace('/-Rev\d+$/', '', $titleWithRevision);
return $baseTitle;
}
/**
* Revise a Document.
@ -477,48 +447,7 @@ ENDINSTRUCTIONS;
$this->Document->create();
if(!empty($document['Invoice']['id'])) {
// Invoice revision
$newDoc = array();
// 1. Copy Document fields and update revision
$newDoc['Document'] = $this->unset_keys($document['Document'], array('id', 'created', 'modified'));
$newDoc['Document']['revision'] = $document['Document']['revision'] + 1;
// user_id for the new revision will be the current user
$currentUser = $this->GetCurrentUser(); // Assumes GetCurrentUser() is available
$newDoc['Document']['user_id'] = $currentUser['User']['id'];
// CakePHP will set created/modified timestamps automatically for the new record
// 2. Copy Invoice fields
$newDoc['Invoice'] = $this->unset_keys($document['Invoice'], array('id', 'document_id', 'created', 'modified'));
$newDoc['Invoice']['issue_date'] = date('Y-m-d');
$newDoc['Invoice']['due_date'] = date("Y-m-d", strtotime("+30 days"));
// Modify title for uniqueness, appending revision number
$orginalTitleParts = explode('-', $document['Invoice']['title']);
$newDoc['Invoice']['title'] = $orginalTitleParts[0] . '-REV' . $newDoc['Document']['revision'];
// 3. Copy DocPage records (if any)
if (!empty($document['DocPage'])) {
$newDoc['DocPage'] = $document['DocPage'];
foreach ($newDoc['DocPage'] as $index => $page) {
$newDoc['DocPage'][$index]['id'] = null; // New record
$newDoc['DocPage'][$index]['document_id'] = null; // Will be set by saveAll
}
}
// 4. Copy Job associations (if applicable - check your data model)
if (!empty($document['Job'])) {
$job_ids = array();
foreach ($document['Job'] as $job) {
$job_ids[] = $job['id'];
}
// This structure is typically used by saveAll for HABTM relationships
$newDoc['Job']['Job'] = $job_ids;
}
// Store info for flash message
// $this->set('revision_number_for_flash', $newDoc['Document']['revision']);
// $this->set('document_type_for_flash', 'Invoice');
echo "WE HAVE AN INVOICE";
}
else if (!empty($document['Quote']['id'])) {
@ -541,10 +470,6 @@ ENDINSTRUCTIONS;
$newDoc['Document']['revision'] = $number_of_revisions;
$newDoc['Document']['type'] = 'quote';
// user_id for the new revision will be the current user
$currentUser = $this->GetCurrentUser();
$newDoc['Document']['user_id'] = $currentUser['User']['id'];
$newDoc['DocPage'] = $document['DocPage'];
@ -556,55 +481,12 @@ ENDINSTRUCTIONS;
}
else if (!empty($document['PurchaseOrder']['id'])) {
// Purchase Order revision
$newDoc = array();
// 1. Copy Document fields and update revision
$newDoc['Document'] = $this->unset_keys($document['Document'], array('id', 'created', 'modified'));
$newDoc['Document']['revision'] = $document['Document']['revision'] + 1;
$newDoc['Document']['type'] = 'purchaseOrder'; // Ensure type is set
// user_id for the new revision will be the current user
$currentUser = $this->GetCurrentUser();
$newDoc['Document']['user_id'] = $currentUser['User']['id'];
// 2. Copy PurchaseOrder fields
$newDoc['PurchaseOrder'] = $this->unset_keys($document['PurchaseOrder'], array('id', 'document_id', 'created', 'modified'));
$newDoc['PurchaseOrder']['issue_date'] = date('Y-m-d');
// Modify title for uniqueness, appending revision number
$baseTitle = $this->getBaseTitle($document['PurchaseOrder']['title']);
$newDoc['PurchaseOrder']['title'] = $baseTitle . '-Rev' . $newDoc['Document']['revision'];
// 3. Copy DocPage records (if any)
if (!empty($document['DocPage'])) {
$newDoc['DocPage'] = array();
foreach ($document['DocPage'] as $page) {
$newPage = $this->unset_keys($page, array('id', 'document_id', 'created', 'modified'));
$newDoc['DocPage'][] = $newPage;
}
}
// 4. Handle Job associations (HABTM)
// First, we need to fetch the jobs associated with the original purchase order
$originalPO = $this->Document->PurchaseOrder->find('first', array(
'conditions' => array('PurchaseOrder.id' => $document['PurchaseOrder']['id']),
'contain' => array('Job')
));
if (!empty($originalPO['Job'])) {
$job_ids = array();
foreach ($originalPO['Job'] as $job) {
$job_ids[] = $job['id'];
}
// Store job IDs to be processed after the main save
$newDoc['_job_ids'] = $job_ids;
}
echo "WE ARE REVISING A PO";
}
else if (!empty($document['OrderAcknowledgement']['id'])) {
//TODO Order Acknowledgement revision
echo "WE ARE REVISING An ORDER ACK";
}
@ -619,39 +501,7 @@ ENDINSTRUCTIONS;
if ($this->Document->saveAll($newDoc)) {
$newid = $this->Document->id;
// Handle Purchase Order Job associations
if (!empty($document['PurchaseOrder']['id']) && !empty($newDoc['_job_ids'])) {
// Get the new PurchaseOrder ID
$newPurchaseOrder = $this->Document->PurchaseOrder->find('first', array(
'conditions' => array('PurchaseOrder.document_id' => $newid),
'fields' => array('PurchaseOrder.id')
));
if (!empty($newPurchaseOrder['PurchaseOrder']['id'])) {
$po_id = $newPurchaseOrder['PurchaseOrder']['id'];
// Method 1: Using CakePHP's HABTM save (recommended)
$this->Document->PurchaseOrder->id = $po_id;
// Method 2: If Method 1 doesn't work, use the SQL approach as a fallback
foreach($newDoc['_job_ids'] as $job_id) {
$query = "INSERT INTO `jobs_purchase_orders` (`job_id`, `purchase_order_id`) VALUES ('{$job_id}', '{$po_id}');";
$this->Document->query($query);
}
}
}
// Updated flash message logic
$revisionNumber = $newDoc['Document']['revision'];
$docTypeFullName = 'Unknown Document';
if (isset($newDoc['Document']['type'])) {
$docTypeFullName = $this->Document->getDocFullName($newDoc['Document']['type']);
} else if (isset($document['Document']['type'])) {
$docTypeFullName = $this->Document->getDocFullName($document['Document']['type']);
}
$this->Session->setFlash(__("Revision {$revisionNumber} of {$docTypeFullName} created", true));
$this->Session->setFlash(__("Revision {$number_of_revisions} created", true));
$this->redirect(array('action'=>'view',$newid));
} else {
$this->Session->setFlash(__('The Document could not be saved. Please, try again.', true));
@ -676,7 +526,8 @@ ENDINSTRUCTIONS;
//Delete all the existing JobPurchaseOrder relationships for this PO.
//Fuck it. @TODO SQL injection potential here.
$query = "DELETE FROM jobs_purchase_orders WHERE purchase_order_id =".$this->data['PurchaseOrder']['id'];
$this->Document->query($query);
$result = $this->Document->query($query);
}
if(isset($this->data['PurchaseOrder']['Job'])) {
@ -1116,18 +967,21 @@ EOT;
$this->Email->delivery = 'smtp';
$document = $this->Document->read(null,$id);
if(empty($document['Document']['pdf_filename'])) {
$this->Session->setFlash(__('Error. Please generate the PDF before attempting to email it', true));
return;
}
else {
$pdf_dir = Configure::read('pdf_directory');
$attachment_files = array($pdf_dir.$document['Document']['pdf_filename']);
foreach($document['DocumentAttachment'] as $document_attachment) {
$attachment = $this->Document->DocumentAttachment->Attachment->read(null, $document_attachment['attachment_id']);
$attachment_files[] = $attachment['Attachment']['file'];
}
$this->Email->attachments = $attachment_files;
}
$enquiry = $this->Document->getEnquiry($document);
@ -1206,285 +1060,6 @@ 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;
}
function parse_email_to_array($input) {
try {
if (empty($input) || !is_string($input)) {
return array();
}
$input = trim($input);
if ($input === '') {
return array();
}
if (strpos($input, ',') !== false) {
$parts = explode(',', $input);
$result = array();
foreach ($parts as $email) {
$email = $this->format_email($email);
if ($email !== '') {
$result[] = $email;
}
}
return $result;
} else {
$result = $this->format_email($input);
$array[] = $result;
return $array;
}
} catch (Exception $e) {
return array();
}
}
/**
* Email the PDF(document + attachments) for this Document to custom recipients.
*
* @param int $id - Document ID
* @param string $to - Recipient email address (comma-separated if multiple)
* @param string|null $cc - CC email address(es), optional (comma-separated)
* @param string|null $bcc - BCC email address(es), optional (comma-separated)
*/
function email_pdf_with_custom_recipients($id = null, $to = null, $cc = null, $bcc = null) {
// Disable automatic rendering of a view
$this->autoRender = false;
// Retrieve recipient emails from form data if not provided as arguments
if (empty($to) && !empty($this->params['form']['to'])) {
$to = $this->params['form']['to'];
}
if (empty($cc) && isset($this->params['form']['cc'])) {
$cc = $this->params['form']['cc'];
}
if (empty($bcc) && isset($this->params['form']['bcc'])) {
$bcc = $this->params['form']['bcc'];
}
// Basic validation for required parameters
if (empty($id) || empty($to)) {
$msg = 'Document ID and recipient email are required.';
echo json_encode(array('success' => false, 'message' => $msg));
return;
}
// Configure SMTP settings for email delivery
$this->Email->smtpOptions = Configure::read('smtp_settings');
$this->Email->delivery = 'smtp';
// Load the document and its attachments
$document = $this->Document->read(null, $id);
error_log("[email_pdf_with_custom_recipients] Document loaded: " . print_r($document['Document'], true));
error_log("[email_pdf_with_custom_recipients] DocumentAttachments: " . print_r($document['DocumentAttachment'], true));
if (empty($document) || empty($document['Document'])) {
$msg = 'Document not found.';
echo json_encode(array('success' => false, 'message' => $msg));
return;
}
// Ensure the PDF has been generated before emailing
if (empty($document['Document']['pdf_filename'])) {
$msg = 'Error. Please generate the PDF before attempting to email it';
echo json_encode(array('success' => false, 'message' => $msg));
return;
}
// Build the list of attachments (PDF + any additional attachments)
$pdf_dir = Configure::read('pdf_directory');
$attachment_files = array($pdf_dir.$document['Document']['pdf_filename']);
foreach($document['DocumentAttachment'] as $document_attachment) {
$attachment = $this->Document->DocumentAttachment->Attachment->read(null, $document_attachment['attachment_id']);
$attachment_files[] = $attachment['Attachment']['file'];
error_log("[email_pdf_with_custom_recipients] Added attachment: " . $attachment['Attachment']['file']);
}
if (!empty($document['DocumentAttachment'])) {
foreach ($document['DocumentAttachment'] as $document_attachment) {
if (!empty($document_attachment['attachment_id'])) {
$attachment = $this->Document->DocumentAttachment->Attachment->read(null, $document_attachment['attachment_id']);
if (!empty($attachment['Attachment']['file'])) {
$attachment_files[] = $attachment['Attachment']['file'];
error_log("[email_pdf_with_custom_recipients] Added attachment: " . $attachment['Attachment']['file']);
}
}
}
}
error_log("[email_pdf_with_custom_recipients] All attachments: " . print_r($attachment_files, true));
$this->Email->attachments = $attachment_files;
// Get related enquiry for the document
$enquiry = $this->Document->getEnquiry($document);
// Parse and validate recipient email addresses
$toArray = $this->parse_email_to_array($to);
if (empty($toArray)) {
$msg = 'Invalid recipient email address.';
echo json_encode(array('success' => false, 'message' => $msg));
return;
} 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);
// 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)) {
$this->Email->bcc = $bccArray;
}
// Set reply-to and from addresses
$this->Email->replyTo = 'CMC Technologies - Sales <sales@cmctechnologies.com.au>';
$this->Email->from = 'CMC Technologies - Sales <sales@cmctechnologies.com.au>';
// Determine document type, email template, and subject
$docType = $this->Document->getDocType($document);
$template = $docType . '_email';
$subject = !empty($enquiry['Enquiry']['title']) ? $enquiry['Enquiry']['title'] . ' ' : 'Document';
error_log("[email_pdf_with_custom_recipients] Enquiry Title: " . empty($enquiry['Enquiry']['title']) . $enquiry['Enquiry']['title']);
// Customise subject and template based on document type
switch($docType) {
case 'quote':
$subject = !empty($enquiry['Enquiry']['title']) ? "Quotation: " . $enquiry['Enquiry']['title'] : 'Quotation';
break;
case 'invoice':
$subject = $this->invoice_email_subject($document);
if (!empty($document['Invoice']['id'])) {
$this->set('invoice', $this->Document->Invoice->find('first', array('conditions'=>array('Invoice.id'=>$document['Invoice']['id']))));
}
if (!empty($document['Invoice']['job_id'])) {
$this->set('job', $this->Document->Invoice->Job->find('first', array('conditions'=>array('Job.id'=>$document['Invoice']['job_id']))));
}
break;
case 'purchaseOrder':
$subject .= "Purchase Order";
$primary_contact = null;
if (!empty($document['PurchaseOrder']['principle_id'])) {
$primary_contact = $this->Document->User->find('first', array('conditions'=>array('User.principle_id' => $document['PurchaseOrder']['principle_id'],'User.primary_contact' => 1)));
}
if(empty($primary_contact)) {
$msg = 'Unable to send. No primary Principle Contact';
echo json_encode(array('success' => false, 'message' => $msg));
return;
}
$subject = $this->po_email_subject($document['PurchaseOrder']);
if (!empty($document['OrderAcknowledgement']['job_id'])) {
$this->set('job', $this->Document->PurchaseOrder->Job->find('first', array('conditions'=>array('Job.id'=>$document['OrderAcknowledgement']['job_id']))));
}
break;
case 'orderAck':
$subject = $this->orderack_email_subject($document);
if (!empty($document['OrderAcknowledgement']['job_id'])) {
$this->set('job', $this->Document->OrderAcknowledgement->Job->find('first', array('conditions'=>array('Job.id'=>$document['OrderAcknowledgement']['job_id']))));
}
if (!empty($document['OrderAcknowledgement']['signature_required'])) {
$template = 'orderAck_email_signature_required';
}
break;
case 'packingList':
$subject = $this->packing_list_email_subject($document);
break;
}
// Set email template and other parameters
$this->Email->template = $template;
$this->Email->subject = $subject;
$this->Email->sendAs = 'both';
$this->Email->charset = 'iso-8859-1';
$this->set('enquiry', $enquiry);
$this->set('document', $document);
$this->set('DocFullName', $this->Document->getDocFullName($document['Document']['type']));
// Attempt to send the email and handle errors
$sent = false;
try {
$sent = $this->Email->send();
} catch (Exception $e) {
$msg = 'Exception: ' . $e->getMessage();
echo json_encode(array('success' => false, 'message' => $msg));
return;
}
if ($sent) {
echo json_encode(array('success' => true, 'message' => 'The Email has been sent'));
return;
} else {
$msg = 'The Email has NOT been sent';
echo json_encode(array('success' => false, 'message' => $msg, 'smtp_errors' => $this->Email->smtpError));
return;
}
echo json_encode(array('success' => false, 'message' => 'No response from email function'));
}
// generateShippingReference builds the Shipping Instructions: with the PO number and job titles.
function generateShippingInstructions($document_id) {
$this->layout = 'ajax';
$po = $this->Document->PurchaseOrder->find('first',
array('conditions'=>
array('Document.id' => $document_id)
)
);
// TODO SQL injection risk. Need to rewrite this whole rickety thing..
$query = 'select j.title as job_title, po.title as po_title from jobs_purchase_orders jpo JOIN jobs j on j.id = jpo.job_id JOIN purchase_orders po ON po.id= jpo.purchase_order_id WHERE jpo.purchase_order_id = '.$po['PurchaseOrder']['id'].';';
$results = $this->Document->query($query);
//echo "<pre>";
//print_r($results);
//echo "</pre>";
$label = "";
if (count($results) > 0) {
$label = $results[0]["po"]["po_title"];
}
foreach ($results as $result) {
$label .= " / ". $result["j"]["job_title"];
}
$ffText = $po['PurchaseOrder']['freight_forwarder_text'];
echo <<<ENDINSTRUCTIONS
<b>FREIGHT FORWARDER:</b> $ffText<br>
<b>PART SHIPMENT:</b> Not Acceptable - please advise ASAP if production of an Item will Delay the Dispatch of the Complete Order by the Date Required stated above.<br>
<b>INSURANCE:</b> DO NOT INSURE - Insurance effected by CMC<br>
<b>SECURITY:</b> It is essential that the cargo is prepared & handled so as not to compromise its security standing.<br>
<b>SHIPMENT MARKINGS:</b><br>
(Please put red fragile stickers on boxing)<br>
CMC TECHNOLOGIES PTY LTD<br>
UNIT 19, 77 BOURKE ROAD<br>
ALEXANDRIA NSW 2015 AUSTRALIA<br>
REF: $label
ENDINSTRUCTIONS;
/*if($this->Document->PurchaseOrder->save($po["PurchaseOrder"])) {
echo "SUCCESS"; //THIS IS SO STUPID.
}
else {
echo "FAILURE";
}*/
}
function add_job_items_to_po($job_id, $document_id) {
@ -1554,4 +1129,5 @@ ENDINSTRUCTIONS;
}
return $newDoc[$model];
}
}

View file

@ -15,17 +15,8 @@ class EmailAttachmentsController extends AppController {
$file = $this->EmailAttachment->findById($id);
// Try legacy emails directory first (where vault saves files)
$file_path = '/var/www/emails';
$full_path = $file_path."/".$file['EmailAttachment']['name'];
// Fallback to attachments directory if not found in emails
if(!file_exists($full_path)) {
$file_path = Configure::read('attachments_directory');
$full_path = $file_path."/".$file['EmailAttachment']['name'];
}
if(file_exists($full_path)) {
$file_path = Configure::read('email_directory');
if(file_exists($file_path."/".$file['EmailAttachment']['name'])) {
@ -42,13 +33,12 @@ class EmailAttachmentsController extends AppController {
header('Content-length: ' . $file['EmailAttachment']['size']);
header('Content-Disposition: attachment; filename='.$filename);
readfile($full_path);
readfile($file_path."/".$file['EmailAttachment']['name']);
exit();
}
else {
echo "ERROR: File Not Found";
echo '\n';
echo "ERROR!! : File Not Found";
echo $file['EmailAttachment']['filename'];
die();
}
@ -65,17 +55,9 @@ class EmailAttachmentsController extends AppController {
$file = $this->EmailAttachment->find('first', array('conditions'=>array('EmailAttachment.id'=>$id)));
//$this->set('attachment', $file);
// Try legacy emails directory first (where vault saves files)
$file_path = '/var/www/emails';
$full_path = $file_path."/".$file['EmailAttachment']['name'];
$file_path = Configure::read('email_directory');
// Fallback to attachments directory if not found in emails
if(!file_exists($full_path)) {
$file_path = Configure::read('attachments_directory');
$full_path = $file_path."/".$file['EmailAttachment']['name'];
}
$contents = file_get_contents($full_path);
$contents = file_get_contents($file_path."/".$file['EmailAttachment']['name']);
if($file['EmailAttachment']['type'] == 'text/plain') {

View file

@ -5,7 +5,7 @@ class PrinciplesController extends AppController {
var $helpers = array('Html', 'Form');
var $paginate = array(
'Principle' => array('limit' => 500, 'order' => array('Principle.name' => 'asc')),
'Principle' => array('limit' => 50, 'order' => array('Principle.name' => 'asc')),
'Enquiry' => array('limit' => 150, 'order' => array('Enquiry.id' => 'desc'))
);
@ -13,7 +13,6 @@ class PrinciplesController extends AppController {
$this->Principle->recursive = 0;
$this->set('principles', $this->paginate());
$this->set('enquiries', $this->paginate());
}
function view($id = null) {
@ -25,14 +24,14 @@ class PrinciplesController extends AppController {
$this->set('enquiries', $this->paginate('Enquiry', array('Enquiry.principle_id' => $id)));
$this->set('addresses', $this->Principle->PrincipleAddress->findAllByPrincipleId($id));
$this->set('principleContacts', $this->Principle->PrincipleContact->find('all', array('conditions'=>array('PrincipleContact.principle_id'=>$id))));
$status_list = $this->Principle->Enquiry->Status->getJSON();
$statuses = $this->Principle->Enquiry->Status->find('all');
$status_list = array();
foreach ($statuses as $status) {
$status_list[] = array($status['Status']['id'], $status['Status']['name']);
}
$this->set('status_list', $status_list);
$classNames = $this->Principle->Enquiry->Status->getClassNamesJSON();
$this->set('class_names', $classNames);
$this->set('enquiries', $this->paginate('Enquiry', array('Enquiry.principle_id' => $id)));
}
function add() {
@ -72,15 +71,5 @@ class PrinciplesController extends AppController {
}
function defaults($id = null) {
$this->layout = 'ajax';
if($id == null) {
echo("No ID");
}
$principle = $this->Principle->read(null, $id);
$principle_json = json_encode($principle);
$this->set('principle_json', $principle_json);
}
}
?>

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