Compare commits
196 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a251da2d95 | ||
|
|
b3b0c040f3 | ||
|
|
7f3cb5cbaa | ||
|
|
bc958b6acf | ||
|
|
5db8451f2c | ||
|
|
34747336dd | ||
|
|
a99b818f6b | ||
|
|
a5ccdd8472 | ||
|
|
52d50e8b57 | ||
|
|
bb5d48a0a0 | ||
|
|
3950cef4c9 | ||
|
|
57e7f7fe24 | ||
|
|
2459df569e | ||
|
|
e11013bd9f | ||
|
|
a579380905 | ||
|
|
016f77a2c9 | ||
|
|
e94cabfc3e | ||
|
|
793b8b4a7d | ||
|
|
b62877d1bd | ||
|
|
d8526f12b6 | ||
|
|
b62d7fdb17 | ||
|
|
c4d60bc1c9 | ||
|
|
6668e1af4f | ||
|
|
942522458f | ||
|
|
8de7fc675b | ||
|
|
e6d444c4af | ||
|
|
f6bbfb3c83 | ||
|
|
b0a09c159d | ||
|
|
bbdd035d04 | ||
|
|
57047e3b32 | ||
|
|
46cf098dfa | ||
|
|
d898149810 | ||
|
|
8dbf221908 | ||
|
|
97d7420f17 | ||
|
|
1d8753764f | ||
|
|
c496e7232f | ||
|
|
23853455f4 | ||
|
|
9eb7747c45 | ||
|
|
ee70c11431 | ||
|
|
ba770cb87d | ||
|
|
e4c8fa8a57 | ||
|
|
1b5a23b3c0 | ||
|
|
ce5d44ae6b | ||
|
|
50d2541600 | ||
|
|
ee182f3c6e | ||
|
|
e7babb7523 | ||
|
|
953a9f473c | ||
|
|
40e9f98fef | ||
|
|
f6b3d90297 | ||
|
|
1f116c09ba | ||
|
|
8c118098bf | ||
|
|
352ee4cfcd | ||
|
|
88ffe7bcd9 | ||
|
|
702186000d | ||
|
|
678e9be2b2 | ||
|
|
98d5a415a6 | ||
|
|
5646851ae3 | ||
|
|
f9ea771d46 | ||
|
|
40560f17a5 | ||
|
|
477f9c39d6 | ||
|
|
4af8afaece | ||
|
|
5e36cc304e | ||
|
|
d794ded611 | ||
|
|
c4a458c352 | ||
|
|
3e9bf95da5 | ||
|
|
4e769e5591 | ||
|
|
f4cf670684 | ||
|
|
da4eefdbf6 | ||
|
|
4ae4ca097a | ||
|
|
59c77fcf15 | ||
|
|
96bf160198 | ||
|
|
f873d51c86 | ||
|
|
1837ed2dca | ||
|
|
33990fad71 | ||
|
|
445b5bf16a | ||
|
|
277349b528 | ||
|
|
1cd6f484cd | ||
|
|
a3e0fcf3bb | ||
|
|
d0a20c5529 | ||
|
|
d185f4569b | ||
|
|
a08b51ec81 | ||
|
|
64872445c5 | ||
|
|
999a208e51 | ||
|
|
e68c6d1f22 | ||
|
|
ff2c48a289 | ||
|
|
b21ab4a7bf | ||
|
|
2ff5461784 | ||
|
|
8e373e677b | ||
|
|
37b474992b | ||
|
|
f9c562a85d | ||
|
|
eecb120a93 | ||
|
|
d42089a3e4 | ||
|
|
161350e218 | ||
|
|
4bec2d1356 | ||
|
|
5674557981 | ||
|
|
2eef930d75 | ||
|
|
5f9a5b74ee | ||
|
|
b36ead6237 | ||
|
|
0251ffd2d7 | ||
|
|
108080b9e0 | ||
|
|
0c1c7de5c2 | ||
|
|
0d52f483bf | ||
|
|
5e825162af | ||
|
|
e5041ef3a1 | ||
|
|
1531db26d7 | ||
|
|
d793f7b6ed | ||
|
|
c54d50d3fc | ||
|
|
32f77e0ae1 | ||
|
|
5d180c10c5 | ||
|
|
eb130720b9 | ||
|
|
8866703a9f | ||
|
|
62c44c3fda | ||
|
|
c059b38e80 | ||
|
|
9e1c5e95f0 | ||
|
|
a47d1e9e51 | ||
|
|
161f404129 | ||
|
|
b76510accc | ||
|
|
cfefe25b9a | ||
|
|
e211f58fc6 | ||
|
|
923fdee657 | ||
|
|
e469e14ae1 | ||
|
|
2e8855040d | ||
|
|
42aecef5c2 | ||
|
|
68b2a37731 | ||
|
|
b4fba46f13 | ||
|
|
6e15ee90d3 | ||
|
|
409c9aac5b | ||
|
|
6b801822fd | ||
|
|
86de417427 | ||
|
|
d8d361d06c | ||
|
|
f2917c11bc | ||
|
|
f5a12d6a2d | ||
|
|
c810163cc6 | ||
|
|
315b1b78e5 | ||
|
|
a5bcbcbdeb | ||
|
|
65273b3816 | ||
|
|
727301c195 | ||
|
|
fa02bf24e4 | ||
|
|
284a28d9f0 | ||
|
|
dcb5186d89 | ||
|
|
b44cbb68d9 | ||
|
|
eea4853a0f | ||
|
|
9cbe9b5177 | ||
|
|
47b9e8e709 | ||
|
|
885c02543f | ||
|
|
52ac142907 | ||
|
|
36e95d36f7 | ||
|
|
851a1f8ac6 | ||
|
|
d8fe1cbac3 | ||
|
|
a9fb2adc92 | ||
|
|
112a0023b2 | ||
|
|
61d7b54628 | ||
|
|
33260df8db | ||
|
|
8634d89912 | ||
|
|
09f2db82f5 | ||
|
|
ffc5831f51 | ||
|
|
a46ca9d0ab | ||
|
|
33795bb47f | ||
|
|
97dbeb6a1c | ||
|
|
144a7a0fb1 | ||
|
|
2ea0398f41 | ||
|
|
6f538e3e4d | ||
|
|
dc2b67d300 | ||
|
|
30f84fefe7 | ||
|
|
bb34ae5881 | ||
|
|
687739e9d6 | ||
|
|
f6eef99d47 | ||
|
|
bc7c99007b | ||
|
|
3684a81b7c | ||
|
|
9e20068f8c | ||
|
|
e1a3d20332 | ||
|
|
761e48ca62 | ||
|
|
c73af8eef1 | ||
|
|
44d444503f | ||
|
|
e834cd325f | ||
|
|
42f84b7b74 | ||
|
|
ea773fbd7c | ||
|
|
b8059fbbbb | ||
|
|
049256025a | ||
|
|
87caa649ed | ||
|
|
b3ce7a1054 | ||
|
|
5953bfa8d5 | ||
|
|
0d440132ca | ||
|
|
fac6d0254a | ||
|
|
eb10de500c | ||
|
|
057231a414 | ||
|
|
c2eb01b59a | ||
|
|
ff729adf55 | ||
|
|
7d8953f311 | ||
|
|
0b70b2c209 | ||
|
|
2a601df758 | ||
|
|
e4453a56fc | ||
|
|
6276167663 | ||
|
|
9fee1677e2 | ||
|
|
e97be7e261 | ||
|
|
4cd67eaf6c |
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -2,6 +2,8 @@ app/tmp/*
|
|||
*.tar.gz
|
||||
*.swp
|
||||
*.swo
|
||||
.env.prod
|
||||
.env.stg
|
||||
app/vendors/tcpdf/cache/*
|
||||
app/tests/*
|
||||
app/emails/*
|
||||
|
|
@ -11,3 +13,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
CMC Sales is a B2B sales management system for CMC Technologies. The codebase consists of:
|
||||
|
||||
- **Legacy CakePHP 1.2.5 application** (2008-era) - Primary business logic
|
||||
- **Modern Go API** (in `/go-app/`) - New development using sqlc and Gorilla Mux
|
||||
- **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.
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
|
|||
### Go Application Development
|
||||
```bash
|
||||
# Navigate to Go app directory
|
||||
cd go-app
|
||||
cd go
|
||||
|
||||
# Configure private module access (first time only)
|
||||
go env -w GOPRIVATE=code.springupsoftware.com
|
||||
|
|
@ -80,7 +80,7 @@ make build
|
|||
### Go Application (Modern)
|
||||
- **Framework**: Gorilla Mux (HTTP router)
|
||||
- **Database**: sqlc for type-safe SQL queries
|
||||
- **Location**: `/go-app/`
|
||||
- **Location**: `/go/`
|
||||
- **Structure**:
|
||||
- `cmd/server/` - Main application entry point
|
||||
- `internal/cmc/handlers/` - HTTP request handlers
|
||||
|
|
|
|||
61
Dockerfile
61
Dockerfile
|
|
@ -1,61 +0,0 @@
|
|||
FROM ghcr.io/kzrl/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
|
||||
RUN a2ensite cmc-sales
|
||||
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
RUN chmod +x /var/www/cmc-sales/run_vault.sh
|
||||
RUN chmod +x /var/www/cmc-sales/run_update_invoices.sh
|
||||
|
||||
|
||||
# By default, simply start apache.
|
||||
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||
|
|
@ -1,50 +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 .env file if needed
|
||||
COPY go-app/.env.example .env
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./server"]
|
||||
27
Dockerfile.local.go
Normal file
27
Dockerfile.local.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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"]
|
||||
47
Dockerfile.local.php
Normal file
47
Dockerfile.local.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||
FROM php:5.6-apache
|
||||
|
||||
# Install required system libraries and PHP extensions for CakePHP
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
apt-get update && \
|
||||
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Copy vhost config to Apache's sites-available
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime \
|
||||
&& a2ensite cmc-sales \
|
||||
&& a2dissite 000-default \
|
||||
&& a2enmod rewrite \
|
||||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD 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
|
||||
4
Dockerfile.prod.db
Normal file
4
Dockerfile.prod.db
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FROM mariadb:latest
|
||||
|
||||
# Copy custom MariaDB configuration to disable strict mode
|
||||
COPY conf/mariadb-no-strict.cnf /etc/mysql/conf.d/
|
||||
23
Dockerfile.prod.go
Normal file
23
Dockerfile.prod.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY go/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"]
|
||||
47
Dockerfile.prod.php
Normal file
47
Dockerfile.prod.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||
FROM php:5.6-apache
|
||||
|
||||
# Install required system libraries and PHP extensions for CakePHP
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
apt-get update && \
|
||||
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Copy vhost config to Apache's sites-available
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime \
|
||||
&& a2ensite cmc-sales \
|
||||
&& a2dissite 000-default \
|
||||
&& a2enmod rewrite \
|
||||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD 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
|
||||
1
Dockerfile.stg.db
Normal file
1
Dockerfile.stg.db
Normal file
|
|
@ -0,0 +1 @@
|
|||
FROM mariadb:latest
|
||||
21
Dockerfile.stg.go
Normal file
21
Dockerfile.stg.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY go/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"]
|
||||
67
Dockerfile.stg.php
Normal file
67
Dockerfile.stg.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
|
||||
|
||||
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||
FROM php:5.6-apache
|
||||
|
||||
# Install required system libraries and PHP extensions for CakePHP
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||
apt-get update && \
|
||||
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Legacy apt compatibility and install steps for Ubuntu 16.04 (now commented out)
|
||||
# RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://old-releases.ubuntu.com/ubuntu/|g' /etc/apt/sources.list && \
|
||||
# sed -i 's|http://security.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' /etc/apt/sources.list
|
||||
# RUN apt-get update
|
||||
# RUN apt-get -y upgrade
|
||||
# RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure
|
||||
# RUN apt-get update -o Acquire::AllowInsecureRepositories=true --allow-unauthenticated
|
||||
# RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
|
||||
# RUN a2enmod php5
|
||||
# RUN php5enmod openssl
|
||||
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
|
||||
# RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
|
||||
# ADD conf/php.ini /etc/php5/apache2/php.ini
|
||||
|
||||
|
||||
# Copy vhost config to Apache's sites-available
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime \
|
||||
&& a2ensite cmc-sales \
|
||||
&& a2dissite 000-default \
|
||||
&& a2enmod rewrite \
|
||||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD 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
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# This is 99% the same as the prod one. I should do something smarter here.
|
||||
|
||||
FROM ubuntu:lucid
|
||||
|
||||
# Set environment variables.
|
||||
ENV HOME /root
|
||||
|
||||
# Define working directory.
|
||||
WORKDIR /root
|
||||
|
||||
RUN sed -i 's/archive/old-releases/' /etc/apt/sources.list
|
||||
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get -y upgrade
|
||||
|
||||
# Install apache, PHP, and supplimentary programs. curl and lynx-cur are for debugging the container.
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
|
||||
|
||||
# Enable apache mods.
|
||||
#RUN php5enmod openssl
|
||||
RUN a2enmod php5
|
||||
RUN a2enmod rewrite
|
||||
RUN a2enmod headers
|
||||
|
||||
|
||||
# Update the PHP.ini file, enable <? ?> tags and quieten logging.
|
||||
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
|
||||
#RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
|
||||
|
||||
ADD conf/php.ini /etc/php5/apache2/php.ini
|
||||
|
||||
# Manually set up the apache environment variables
|
||||
ENV APACHE_RUN_USER www-data
|
||||
ENV APACHE_RUN_GROUP www-data
|
||||
ENV APACHE_LOG_DIR /var/log/apache2
|
||||
ENV APACHE_LOCK_DIR /var/lock/apache2
|
||||
ENV APACHE_PID_FILE /var/run/apache2.pid
|
||||
|
||||
ARG COMMIT
|
||||
ENV COMMIT_SHA=${COMMIT}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Update the default apache site with the config we created.
|
||||
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales
|
||||
ADD conf/ripmime /bin/ripmime
|
||||
|
||||
RUN chmod +x /bin/ripmime
|
||||
RUN a2dissite 000-default
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
RUN chmod +x /var/www/cmc-sales/run_vault.sh
|
||||
|
||||
RUN a2ensite cmc-sales
|
||||
|
||||
# By default, simply start apache.
|
||||
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||
18
MIGRATION.md
18
MIGRATION.md
|
|
@ -1,18 +0,0 @@
|
|||
# migration instructions
|
||||
|
||||
mysql -u cmc -p cmc < ~/migration/latest.sql
|
||||
|
||||
MariaDB [(none)]> CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'somepass';
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
MariaDB [(none)]> GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
|
||||
|
||||
|
||||
www-data@helios:~$ du -hcs vaultmsgs
|
||||
64G vaultmsgs
|
||||
64G total
|
||||
www-data@helios:~$ du -hcs emails
|
||||
192G emails
|
||||
192G total
|
||||
www-data@helios:~$
|
||||
|
||||
138
README.md
138
README.md
|
|
@ -1,8 +1,22 @@
|
|||
# cmc-sales
|
||||
|
||||
## Development Setup
|
||||
CMC Sales is a business management system with two applications:
|
||||
|
||||
CMC Sales now runs both legacy CakePHP and modern Go applications side by side.
|
||||
- **PHP Application**: CakePHP 1.2.5 (currently the primary application)
|
||||
- **Go Application**: Go + HTMX (used for select features, growing)
|
||||
|
||||
**Future development should be done in the Go application wherever possible.**
|
||||
|
||||
## Architecture
|
||||
|
||||
Both applications:
|
||||
- Share the same MariaDB database
|
||||
- Run behind a shared Caddy reverse proxy with basic authentication
|
||||
- Support staging and production environments on the same server
|
||||
|
||||
The PHP application currently handles most functionality, while the Go application is used for select screens and new features as they're developed.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Quick Start
|
||||
|
||||
|
|
@ -45,105 +59,53 @@ Both applications share the same database, allowing for gradual migration.
|
|||
- **Go Application**: Requires Go 1.23+ (for latest sqlc)
|
||||
- Alternative: Use `Dockerfile.go.legacy` with Go 1.21 and sqlc v1.26.0
|
||||
|
||||
## Deployment
|
||||
|
||||
## Install a new server
|
||||
### Prerequisites
|
||||
|
||||
(TODO this is all likely out of date)
|
||||
|
||||
### Requirements
|
||||
|
||||
Debian or Ubuntu OS. These instructions written for Debian 9.9
|
||||
|
||||
Assumed pre-work:
|
||||
|
||||
Create a new VM with hostname newserver.cmctechnologies.com.au
|
||||
Configure DNS appropriately. cmctechnologies.com.au zones is currently managed in Google Cloud DNS on Karl's account:
|
||||
https://console.cloud.google.com/net-services/dns/zones/cmctechnologies?project=cmc-technologies&authuser=1&folder&organizationId
|
||||
|
||||
Will need to migrate that to CMC's GSuite account at some point.
|
||||
|
||||
|
||||
1. Install ansible on your workstation
|
||||
```
|
||||
apt-get install ansible
|
||||
```
|
||||
2. Clone the playbooks
|
||||
```
|
||||
git clone git@gitlab.com:minimalist.software/cmc-playbooks.git
|
||||
```
|
||||
3. Execute the playbooks
|
||||
|
||||
The nginx config expects the site to be available at sales.cmctechnologies.com.au.
|
||||
|
||||
You'll need to add the hostname to config/nginx-site, if this isn't sales.cmctechnologies.com.au
|
||||
The deployment scripts use SSH to connect to the server. Configure your SSH config (`~/.ssh/config`) with a host entry named `cmc` pointing to the correct server:
|
||||
|
||||
```
|
||||
cd cmc-playbooks
|
||||
# Add the hostname of your new server to the inventory.txt
|
||||
ansible-playbook -i inventory.txt setup.yml
|
||||
```
|
||||
4. SSH to the new server and configure gitlab-runner
|
||||
```
|
||||
ssh newserver.cmctechnologies.com.au
|
||||
sudo gitlab-runner register
|
||||
```
|
||||
5. SSH to the new server as cmc user
|
||||
```
|
||||
ssh cmc@newserver.cmctechnologies.com.au
|
||||
Host cmc
|
||||
HostName node0.prd.springupsoftware.com
|
||||
User cmc
|
||||
IdentityFile ~/.ssh/cmc
|
||||
```
|
||||
|
||||
6. Add the SSH key to the cmc-sales repo on gitlab as a deploy key
|
||||
https://gitlab.com/minimalist.software/cmc-sales/-/settings/repository
|
||||
```
|
||||
cmc@cmc:~$ cat .ssh/id_rsa.pub
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFIdoWVp2pGDb46ubW6jkfIpREMa/veD6xZVAtnj3WG1sX7NEUlQYq3RKbZ5CThlw6GKMSYoIsIqk7p6zSoJHGlJSLxoJ0edKflciMUFMTQrdm4T1USXsK+gd0C4DUCyVkYFOs37sy+JtziymnBTm7iOeVI3aMxwfoCOs6mNiD0ettjJT6WtVyy0ZTb6yU4uz7CHj1IGsvwsoKJWPGwJrZ/MfByNl6aJ8R/8zDwbtP06owKD4b3ZPgakM3nYRRoKzHZ/SClz50SXMKC4/nmFY9wLuuMhCWK+9x4/4VPSnxXESOlENMfUoa1IY4osAnZCtaFrWDyenJ+spZrNfgcscD ansible-generated on cmc
|
||||
### Deployment Procedures
|
||||
|
||||
Deploy to staging or production using the scripts in the `deploy/` directory:
|
||||
|
||||
**Deploy to Staging:**
|
||||
```bash
|
||||
./scripts/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
|
||||
./scripts/deploy/deploy-prod.sh --no-cache
|
||||
./scripts/deploy/deploy-stg.sh --no-cache
|
||||
```
|
||||
|
||||
8. Get the latest backup from Google Drive
|
||||
### How Deployment Works
|
||||
|
||||
In the shared google drive:
|
||||
eg. backups/database/backup_20191217_21001.sql.gz
|
||||
1. The deploy script connects to the server via the `cmc` SSH host
|
||||
2. Clones or updates the appropriate git branch (`stg` or `prod`)
|
||||
3. Creates environment configuration for the Go application
|
||||
4. Builds and starts Docker containers using the appropriate compose file
|
||||
5. Applications are accessible through Caddy reverse proxy with basic auth
|
||||
|
||||
Copy up to the new server:
|
||||
```
|
||||
rsync backup_*.gz root@newserver:~/
|
||||
### Deployment Environments
|
||||
|
||||
```
|
||||
- **Staging**: Branch `stg` → https://stg.cmctechnologies.com.au
|
||||
- **Production**: Branch `prod` → https://sales.cmctechnologies.com.au or https://prod.cmctechnologies.com.au
|
||||
|
||||
9. Restore backup to cmc database
|
||||
```
|
||||
zcat backup_* | mysql -u cmc -p
|
||||
```
|
||||
|
||||
10. Redeploy from Gitlab
|
||||
https://gitlab.com/minimalist.software/cmc-sales/pipelines/new
|
||||
|
||||
11. You should have a new installation of cmc-sales.
|
||||
|
||||
12. Seems new Linux kernels break the docker
|
||||
https://github.com/moby/moby/issues/28705
|
||||
|
||||
|
||||
13. Mysql needs special args not to break
|
||||
|
||||
```
|
||||
# /etc/mysql/my.cnf
|
||||
sql_mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
|
||||
```
|
||||
Both environments run on the same server and share:
|
||||
- A single Caddy reverse proxy (handles HTTPS and basic authentication for both environments)
|
||||
- Separate Docker containers for each environment's PHP and Go applications
|
||||
- Separate MariaDB database instances
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
/* SVN FILE: $Id: bootstrap.php 6311 2008-01-02 06:33:52Z phpnut $ */
|
||||
/**
|
||||
* Short description for file.
|
||||
*
|
||||
* Long description for file
|
||||
*
|
||||
* PHP versions 4 and 5
|
||||
*
|
||||
* CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
|
||||
* Copyright 2005-2008, Cake Software Foundation, Inc.
|
||||
* 1785 E. Sahara Avenue, Suite 490-204
|
||||
* Las Vegas, Nevada 89104
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @filesource
|
||||
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc.
|
||||
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
|
||||
* @package cake
|
||||
* @subpackage cake.app.config
|
||||
* @since CakePHP(tm) v 0.10.8.2117
|
||||
* @version $Revision: 6311 $
|
||||
* @modifiedby $LastChangedBy: phpnut $
|
||||
* @lastmodified $Date: 2008-01-01 22:33:52 -0800 (Tue, 01 Jan 2008) $
|
||||
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* This file is loaded automatically by the app/webroot/index.php file after the core bootstrap.php is loaded
|
||||
* This is an application wide file to load any function that is not used within a class define.
|
||||
* You can also use this to include or require any files in your application.
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* The settings below can be used to set additional paths to models, views and controllers.
|
||||
* This is related to Ticket #470 (https://trac.cakephp.org/ticket/470)
|
||||
*
|
||||
* $modelPaths = array('full path to models', 'second full path to models', 'etc...');
|
||||
* $viewPaths = array('this path to views', 'second full path to views', 'etc...');
|
||||
* $controllerPaths = array('this path to controllers', 'second full path to controllers', 'etc...');
|
||||
*
|
||||
*/
|
||||
//EOF
|
||||
?>
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
<?php
|
||||
error_reporting(E_ALL & ~E_STRICT & ~E_DEPRECATED);
|
||||
/* SVN FILE: $Id: core.php 7296 2008-06-27 09:09:03Z gwoo $ */
|
||||
/**
|
||||
* This is core configuration file.
|
||||
*
|
||||
* Use it to configure core behavior of Cake.
|
||||
*
|
||||
* PHP versions 4 and 5
|
||||
*
|
||||
* CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
|
||||
* Copyright 2005-2008, Cake Software Foundation, Inc.
|
||||
* 1785 E. Sahara Avenue, Suite 490-204
|
||||
* Las Vegas, Nevada 89104
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @filesource
|
||||
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc.
|
||||
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
|
||||
* @package cake
|
||||
* @subpackage cake.app.config
|
||||
* @since CakePHP(tm) v 0.2.9
|
||||
* @version $Revision: 7296 $
|
||||
* @modifiedby $LastChangedBy: gwoo $
|
||||
* @lastmodified $Date: 2008-06-27 02:09:03 -0700 (Fri, 27 Jun 2008) $
|
||||
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
|
||||
*/
|
||||
/**
|
||||
* CakePHP Debug Level:
|
||||
*
|
||||
* Production Mode:
|
||||
* 0: No error messages, errors, or warnings shown. Flash messages redirect.
|
||||
*
|
||||
* Development Mode:
|
||||
* 1: Errors and warnings shown, model caches refreshed, flash messages halted.
|
||||
* 2: As in 1, but also with full debug messages and SQL output.
|
||||
* 3: As in 2, but also with full controller dump.
|
||||
*
|
||||
* In production mode, flash messages redirect after a time interval.
|
||||
* In development mode, you need to click the flash message to continue.
|
||||
*/
|
||||
Configure::write('debug', 0);
|
||||
|
||||
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>}'
|
||||
));
|
||||
*/
|
||||
|
||||
Configure::write('smtp_settings', array(
|
||||
'port' => '25',
|
||||
'timeout' => '30',
|
||||
'host' => '172.17.0.1'));
|
||||
|
||||
//Production/Staging Config
|
||||
|
||||
$production_hosts = array('cmc.lan', '192.168.0.7', 'cmcbeta.lan', 'office.cmctechnologies.com.au:5000');
|
||||
|
||||
|
||||
|
||||
|
||||
$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,
|
||||
));
|
||||
|
||||
Configure::write('email_directory', '/var/www/emails');
|
||||
Configure::write('pdf_directory', $basedir.'/webroot/pdf/');
|
||||
Configure::write('attachments_directory', $basedir.'/webroot/attachments_files/');
|
||||
|
||||
|
||||
/**
|
||||
* Application wide charset encoding
|
||||
*/
|
||||
Configure::write('App.encoding', 'UTF-8');
|
||||
|
||||
/**
|
||||
* Turn off all caching application-wide.
|
||||
*
|
||||
*/
|
||||
Configure::write('Cache.disable', true);
|
||||
|
||||
|
||||
/**
|
||||
* Defines the default error type when using the log() function. Used for
|
||||
* differentiating error logging and debugging. Currently PHP supports LOG_DEBUG.
|
||||
*/
|
||||
define('LOG_ERROR', 1);
|
||||
|
||||
/**
|
||||
* The preferred session handling method. Valid values:
|
||||
*
|
||||
* 'php' Uses settings defined in your php.ini.
|
||||
* 'cake' Saves session files in CakePHP's /tmp directory.
|
||||
* 'database' Uses CakePHP's database sessions.
|
||||
*
|
||||
* To define a custom session handler, save it at /app/config/<name>.php.
|
||||
* Set the value of 'Session.save' to <name> to utilize it in CakePHP.
|
||||
*
|
||||
* To use database sessions, execute the SQL file found at /app/config/sql/sessions.sql.
|
||||
*
|
||||
*/
|
||||
Configure::write('Session.save', 'database');
|
||||
/**
|
||||
* The name of the table used to store CakePHP database sessions.
|
||||
*
|
||||
* 'Session.save' must be set to 'database' in order to utilize this constant.
|
||||
*
|
||||
* The table name set here should *not* include any table prefix defined elsewhere.
|
||||
*/
|
||||
Configure::write('Session.table', 'cake_sessions');
|
||||
/**
|
||||
* The DATABASE_CONFIG::$var to use for database session handling.
|
||||
*
|
||||
* 'Session.save' must be set to 'database' in order to utilize this constant.
|
||||
*/
|
||||
Configure::write('Session.database', 'default');
|
||||
/**
|
||||
* The name of CakePHP's session cookie.
|
||||
*/
|
||||
Configure::write('Session.cookie', 'QUOTENIK');
|
||||
/**
|
||||
* Session time out time (in seconds).
|
||||
* Actual value depends on 'Security.level' setting.
|
||||
*/
|
||||
Configure::write('Session.timeout', '324');
|
||||
/**
|
||||
* If set to false, sessions are not automatically started.
|
||||
*/
|
||||
Configure::write('Session.start', true);
|
||||
/**
|
||||
* When set to false, HTTP_USER_AGENT will not be checked
|
||||
* in the session
|
||||
*/
|
||||
Configure::write('Session.checkAgent', true);
|
||||
/**
|
||||
* The level of CakePHP security. The session timeout time defined
|
||||
* in 'Session.timeout' is multiplied according to the settings here.
|
||||
* Valid values:
|
||||
*
|
||||
* 'high' Session timeout in 'Session.timeout' x 10
|
||||
* 'medium' Session timeout in 'Session.timeout' x 100
|
||||
* 'low' Session timeout in 'Session.timeout' x 300
|
||||
*
|
||||
* CakePHP session IDs are also regenerated between requests if
|
||||
* 'Security.level' is set to 'high'.
|
||||
*/
|
||||
Configure::write('Security.level', 'medium');
|
||||
/**
|
||||
* A random string used in security hashing methods.
|
||||
*/
|
||||
Configure::write('Security.salt', 'uiPxR3MzVXAID5zucbxLdxP4TX33buPoCWZr4JfroGoaE57UQC');
|
||||
|
||||
|
||||
/**
|
||||
* The classname and database used in CakePHP's
|
||||
* access control lists.
|
||||
*/
|
||||
Configure::write('Acl.classname', 'DbAcl');
|
||||
Configure::write('Acl.database', 'default');
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?php
|
||||
|
||||
//CakePHP is pretty awful. I was so foolish.
|
||||
|
||||
|
||||
|
||||
class DATABASE_CONFIG {
|
||||
|
||||
var $default = array(
|
||||
'driver' => 'mysql',
|
||||
'persistent' => false,
|
||||
'host' => '172.17.0.1',
|
||||
'login' => 'cmc',
|
||||
'password' => 'xVRQI&cA?7AU=hqJ!%au',
|
||||
'database' => 'cmc',
|
||||
'prefix' => '',
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
class DATABASE_CONFIG {
|
||||
|
||||
var $default = array(
|
||||
'driver' => 'mysql',
|
||||
'persistent' => false,
|
||||
'host' => '172.17.0.1',
|
||||
'login' => 'staging',
|
||||
'password' => 'stagingmoopwoopVerySecure',
|
||||
'database' => 'staging',
|
||||
'prefix' => '',
|
||||
);
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<?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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
?>
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
<?php
|
||||
class AttachmentsController extends AppController {
|
||||
|
||||
var $name = 'Attachments';
|
||||
var $helpers = array('Html', 'Form','Number','Time');
|
||||
|
||||
|
||||
var $paginate = array(
|
||||
|
||||
'limit' => 500,
|
||||
'order' => array(
|
||||
array('Principle.short_name' => 'asc'),
|
||||
array('Attachment.name' => 'asc')
|
||||
),
|
||||
'conditions' => array('Attachment.archived' => 0)
|
||||
);
|
||||
|
||||
|
||||
function index() {
|
||||
$this->Attachment->recursive = 1;
|
||||
$this->set('attachments', $this->paginate());
|
||||
}
|
||||
|
||||
function view($id = null) {
|
||||
if (!$id) {
|
||||
$this->Session->setFlash(__('Invalid Attachment.', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
}
|
||||
$this->set('attachment', $this->Attachment->read(null, $id));
|
||||
$this->layout = 'pdf';
|
||||
}
|
||||
|
||||
function archived() {
|
||||
$this->set('archived', $this->Attachment->find('all', array('conditions'=> array('archived' => 1), 'order' => array(
|
||||
array('Principle.short_name' => 'asc'),
|
||||
array('Attachment.name' => 'asc')
|
||||
))));
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (!empty($this->data)) {
|
||||
|
||||
$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();
|
||||
|
||||
if ($this->Attachment->save($attachment)) {
|
||||
$this->Session->setFlash(__('The Attachment has been saved', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
} else {
|
||||
$this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true));
|
||||
}
|
||||
}
|
||||
}
|
||||
$principles = $this->Attachment->Principle->find('list');
|
||||
$this->set(compact('products', 'principles'));
|
||||
}
|
||||
|
||||
function edit($id = null) {
|
||||
if (!$id && empty($this->data)) {
|
||||
$this->Session->setFlash(__('Invalid Attachment', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
}
|
||||
if (!empty($this->data)) {
|
||||
$attachment = $this->data;
|
||||
$existing = $this->Attachment->find('first', array('conditions'=>array('Attachment.id' => $this->data['Attachment']['id'])));
|
||||
//Process editing the PDFs.
|
||||
if(!empty($this->data['Attachment']['file']['tmp_name'])) {
|
||||
|
||||
if(!empty($existing)) {
|
||||
//Delete old file
|
||||
unlink($existing['Attachment']['file']);
|
||||
|
||||
}
|
||||
$attachment = $this->Attachment->process_attachment($this->data);
|
||||
|
||||
if(!$attachment) {
|
||||
$this->Session->setFlash('The Attachment could not be saved. The filename exists');
|
||||
}
|
||||
}
|
||||
else {
|
||||
$attachment['Attachment']['file'] = $existing['Attachment']['file'];
|
||||
}
|
||||
if ($this->Attachment->save($attachment)) {
|
||||
$this->Session->setFlash(__('The Attachment has been saved', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
} else {
|
||||
$this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
else {
|
||||
$this->data = $this->Attachment->read(null, $id);
|
||||
}
|
||||
$principles = $this->Attachment->Principle->find('list');
|
||||
$this->set(compact('principles'));
|
||||
}
|
||||
|
||||
function delete($id = null) {
|
||||
if (!$id) {
|
||||
$this->Session->setFlash(__('Invalid id for Attachment', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
}
|
||||
$attachment = $this->Attachment->read(null, $id);
|
||||
if ($this->Attachment->del($id)) {
|
||||
unlink($attachment['Attachment']['file']);
|
||||
$this->Session->setFlash(__('Attachment deleted', true));
|
||||
$this->redirect(array('action'=>'index'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
class EmailAttachmentsController extends AppController {
|
||||
|
||||
var $name = 'EmailAttachments';
|
||||
var $helpers = array('Html', 'Form');
|
||||
|
||||
|
||||
|
||||
|
||||
function download($id) {
|
||||
// maybe make this GET a go webservice that's something like
|
||||
// GET vault.cmctechnologies.com.au/filename
|
||||
// HTTP basic auth, or some sort of preshared key
|
||||
// service hits S3 if required. Cached on disk for $sometime
|
||||
|
||||
$file = $this->EmailAttachment->findById($id);
|
||||
|
||||
$file_path = Configure::read('email_directory');
|
||||
if(file_exists($file_path."/".$file['EmailAttachment']['name'])) {
|
||||
|
||||
|
||||
|
||||
Configure::write('debug', 0);
|
||||
|
||||
if(!$file['EmailAttachment']['filename']) {
|
||||
$filename = 'vault_'.time();
|
||||
}
|
||||
else {
|
||||
$filename = $file['EmailAttachment']['filename'];
|
||||
}
|
||||
|
||||
header('Content-type: ' . $file['EmailAttachment']['type']);
|
||||
header('Content-length: ' . $file['EmailAttachment']['size']);
|
||||
header('Content-Disposition: attachment; filename='.$filename);
|
||||
|
||||
readfile($file_path."/".$file['EmailAttachment']['name']);
|
||||
|
||||
exit();
|
||||
}
|
||||
else {
|
||||
echo "ERROR!! : File Not Found";
|
||||
echo $file['EmailAttachment']['filename'];
|
||||
die();
|
||||
}
|
||||
}
|
||||
|
||||
function view($id = null) {
|
||||
Configure::write('debug', 0);
|
||||
$this->layout = 'minimal';
|
||||
if(!$id) {
|
||||
return;
|
||||
}
|
||||
else {
|
||||
|
||||
$file = $this->EmailAttachment->find('first', array('conditions'=>array('EmailAttachment.id'=>$id)));
|
||||
//$this->set('attachment', $file);
|
||||
|
||||
$file_path = Configure::read('email_directory');
|
||||
|
||||
$contents = file_get_contents($file_path."/".$file['EmailAttachment']['name']);
|
||||
|
||||
|
||||
if($file['EmailAttachment']['type'] == 'text/plain') {
|
||||
$contents = nl2br($contents, true);
|
||||
}
|
||||
|
||||
|
||||
$this->set('contents', $contents);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<div class="attachments form">
|
||||
<?php echo $form->create('Attachment', array('type'=>'file'));?>
|
||||
<fieldset>
|
||||
<legend><?php __('Add Attachment');?></legend>
|
||||
<?php
|
||||
echo $form->input('principle_id');
|
||||
echo $form->input('name');
|
||||
echo $form->file('file');
|
||||
echo $form->input('description');
|
||||
echo $form->input('archived');
|
||||
?>
|
||||
</fieldset>
|
||||
<?php echo $form->end('Submit');?>
|
||||
</div>
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<?php // Quotenik 1.2 - Default Layout. Some Markup retained from CakePHP Default ?>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>
|
||||
<?php __('CMC'); ?>
|
||||
<?php echo $title_for_layout; ?>
|
||||
</title>
|
||||
<?php
|
||||
$debugLevel = Configure::read('debug');
|
||||
|
||||
echo $html->meta('icon');
|
||||
echo $html->css('quotenik');
|
||||
echo $html->css('jquery-ui.custom.css');
|
||||
|
||||
|
||||
//echo $javascript->link('ckeditor/ckeditor');
|
||||
|
||||
echo $javascript->link('jquery');
|
||||
|
||||
echo $javascript->link('jquery-ui');
|
||||
echo $javascript->link('jquery.form');
|
||||
echo $javascript->link('menu');
|
||||
echo $javascript->link('global');
|
||||
echo $javascript->link('search');
|
||||
echo $javascript->link('jquery.jeditable.mini');
|
||||
echo $javascript->link('jquery.validate');
|
||||
|
||||
echo $scripts_for_layout;
|
||||
?>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
|
||||
<div id="header">
|
||||
<h3 id="logo">CMC Technologies</h3>
|
||||
|
||||
<div id="username">
|
||||
|
||||
<?php
|
||||
$logoutlink = $html->link('Logout', '/users/logout');
|
||||
echo $html->link($currentuser['User']['username'], '/users/view/' . $currentuser['User']['id']);
|
||||
|
||||
?>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="navdiv">
|
||||
<ul id="nav">
|
||||
<li><?php echo $html->link('Enquiries', '/enquiries/index'); ?>
|
||||
<ul>
|
||||
<li class="last"><?php echo $html->link('Enquiry Register', '/enquiries/index'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('Documents', '/documents/index'); ?>
|
||||
<ul>
|
||||
<li class="last"><?php echo $html->link('Documents Index', '/documents/index'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('Jobs', '/jobs/index'); ?>
|
||||
<ul>
|
||||
|
||||
<?
|
||||
if ($currentuser['User']['access_level'] == 'manager' || $currentuser['User']['access_level'] == 'admin'):
|
||||
|
||||
?>
|
||||
<li><?= $html->link('Reports', '/jobs/reports'); ?></li>
|
||||
<? endif; ?>
|
||||
<li class="last"><?php echo $html->link('Job List', '/jobs/index'); ?></li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('Shipments', '/shipments/index'); ?>
|
||||
<ul>
|
||||
<li class=""><?php echo $html->link('All Shipments', '/shipments/index'); ?></li>
|
||||
<li class=""><?php echo $html->link('Import Shipments', '/shipments/index/import'); ?></li>
|
||||
<li class=""><?php echo $html->link('Direct Shipments', '/shipments/index/direct'); ?></li>
|
||||
<li class=""><?php echo $html->link('Export Shipments', '/shipments/index/export'); ?></li>
|
||||
<li class=""><?php echo $html->link('Local Shipments', '/shipments/index/local'); ?></li>
|
||||
<li class=""><?php echo $html->link('Monthly Deferred GST', '/shipments/reports'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Freight Forwarders', '/freight_forwarders'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('Customers', '/customers/index'); ?>
|
||||
<ul>
|
||||
<li><?php echo $html->link('Customer Index', '/customers/index'); ?></li>
|
||||
<li><?php echo $html->link('Add Customer', '/customers/add'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Industries', '/industries/index'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('POs', '/purchase_orders/index'); ?>
|
||||
<ul>
|
||||
<li class="last"><?php echo $html->link('PO Index', '/purchase_orders/index'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
<li><?php echo $html->link('Invoices', '/invoices/index'); ?>
|
||||
<ul>
|
||||
<li class=""><?php echo $html->link('Invoices Index', '/invoices/index'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Print View', '/invoices/printView'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><?php echo $html->link('Products', '/products/index'); ?>
|
||||
<ul>
|
||||
<li><?php echo $html->link('Product Index', '/products/index'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Add Product', '/products/add'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
<li><?php echo $html->link('Principles', '/principles/index'); ?>
|
||||
<ul>
|
||||
<li><?php echo $html->link('Principle Index', '/principles/index'); ?></li>
|
||||
<li><?php echo $html->link('Attachments', '/attachments/index'); ?></li>
|
||||
<li class="last"><?php echo $html->link('Add Principle', '/principles/add'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<?php /* <li> echo $html->link('Users', '/users/index'); ?>
|
||||
<ul>
|
||||
<li> echo $html->link('Users Index', '/users/index'); </li>
|
||||
<li class="last"><?php echo $html->link('Add User', '/users/add'); </li>
|
||||
</ul>
|
||||
</li> */ ?>
|
||||
|
||||
<li><?php echo $html->link('Search', '/enquiries/search', array('id' => 'searchLink')); ?>
|
||||
</li>
|
||||
<li><?php echo $html->link('Help', '/pages/about'); ?>
|
||||
<ul>
|
||||
<li><a href="/pages/bug">Raise a bug</a></li>
|
||||
<li><a href="https://gitlab.com/minimalist.software/cmc-sales/issues">Issue tracker</a></li>
|
||||
<li class="last"><?php echo $html->link('About', '/pages/about'); ?></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<?
|
||||
if ($currentuser['User']['access_level'] == 'manager' || $currentuser['User']['access_level'] == 'admin'):
|
||||
?>
|
||||
<li class="extra"><?=$html->link('Admin', '/admin'); ?>
|
||||
</li>
|
||||
<? endif; ?>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div id="content">
|
||||
<?php
|
||||
if ($session->check('Message.flash')):
|
||||
$session->flash();
|
||||
endif;
|
||||
?>
|
||||
<div id="globalAjaxLoading">
|
||||
<h3>Loading, please wait</h3>
|
||||
<?php echo $html->image('ajax-loader.gif'); ?>
|
||||
</div>
|
||||
<?php echo $content_for_layout; ?>
|
||||
|
||||
</div>
|
||||
<div id="footer">
|
||||
Powered by CMC-Sales <a href="https://code.springupsoftware.com/cmc/cmc-sales/commit/<?= getenv("COMMIT_SHA"); ?>"><?= getenv("COMMIT_SHA"); ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dialogDiv">
|
||||
|
||||
|
||||
|
||||
<?php
|
||||
echo $form->create('Enquiry', array('default' => false, 'id' => 'searchForm', 'default' => 'false'));
|
||||
echo '<h2>Search';
|
||||
echo $html->image('system-search.png');
|
||||
echo '</h2>';
|
||||
echo "<p>Search for: Enquiry Number, Job Number, Customer, Contact or Customer Order Number</p>";
|
||||
|
||||
echo $form->input('Enquiry.search_string', array('label' => false, 'id' => 'searchString'));
|
||||
?>
|
||||
|
||||
<div class ="ui-widget">
|
||||
<button id="searchButton">Search</button>
|
||||
</div>
|
||||
<?
|
||||
echo $form->end();
|
||||
?>
|
||||
|
||||
|
||||
|
||||
<div id="ajaxLoading">
|
||||
<h2>Loading, please wait..</h2>
|
||||
<?php echo $html->image('ajax-loader.gif'); ?>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<?php
|
||||
|
||||
$mem_usage = memory_get_usage(true);
|
||||
|
||||
if ($mem_usage < 1024)
|
||||
echo $mem_usage . " bytes";
|
||||
elseif ($mem_usage < 1048576)
|
||||
echo round($mem_usage / 1024, 2) . " kilobytes";
|
||||
else
|
||||
echo round($mem_usage / 1048576, 2) . " megabytes";
|
||||
echo "<br/>";
|
||||
?>
|
||||
|
||||
<?php echo $cakeDebug; ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
FILENAME=backups/backup_$(date +'%Y%m%d-%H%M%S').sql.gz
|
||||
mysqldump cmc | gzip > $FILENAME
|
||||
rclone copy $FILENAME gdrivebackups:database/
|
||||
rclone sync cmc-sales/app/webroot/pdf gdrivebackups:pdf/
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ID=$(docker ps -f ancestor=cmc:latest -q)
|
||||
docker kill $ID
|
||||
docker build . -t "cmc:latest" --platform linux/amd64
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ID=$(docker ps -f ancestor=cmc:stg -q)
|
||||
docker kill $ID
|
||||
docker build -f Dockerfile_stg . -t "cmc:stg"
|
||||
|
|
@ -1,797 +0,0 @@
|
|||
<?php
|
||||
/* SVN FILE: $Id$ */
|
||||
/**
|
||||
* Short description for file.
|
||||
*
|
||||
* Long description for file
|
||||
*
|
||||
* PHP versions 4 and 5
|
||||
*
|
||||
* CakePHP(tm) : Rapid Development Framework (http://www.cakephp.org)
|
||||
* Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @filesource
|
||||
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
|
||||
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
|
||||
* @package cake
|
||||
* @subpackage cake.cake.libs.controller.components
|
||||
* @since CakePHP(tm) v 1.2.0.3467
|
||||
* @version $Revision$
|
||||
* @modifiedby $LastChangedBy$
|
||||
* @lastmodified $Date$
|
||||
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
|
||||
*/
|
||||
/**
|
||||
* EmailComponent
|
||||
*
|
||||
* This component is used for handling Internet Message Format based
|
||||
* based on the standard outlined in http://www.rfc-editor.org/rfc/rfc2822.txt
|
||||
*
|
||||
* @package cake
|
||||
* @subpackage cake.cake.libs.controller.components
|
||||
*
|
||||
*/
|
||||
App::import('Core', 'Multibyte');
|
||||
class EmailComponent extends Object{
|
||||
/**
|
||||
* Recipient of the email
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $to = null;
|
||||
/**
|
||||
* The mail which the email is sent from
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $from = null;
|
||||
/**
|
||||
* The email the recipient will reply to
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $replyTo = null;
|
||||
/**
|
||||
* The read receipt email
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $readReceipt = null;
|
||||
/**
|
||||
* The mail that will be used in case of any errors like
|
||||
* - Remote mailserver down
|
||||
* - Remote user has exceeded his quota
|
||||
* - Unknown user
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $return = null;
|
||||
/**
|
||||
* Carbon Copy
|
||||
*
|
||||
* List of email's that should receive a copy of the email.
|
||||
* The Recipient WILL be able to see this list
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $cc = array();
|
||||
/**
|
||||
* Blind Carbon Copy
|
||||
*
|
||||
* List of email's that should receive a copy of the email.
|
||||
* The Recipient WILL NOT be able to see this list
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $bcc = array();
|
||||
/**
|
||||
* The subject of the email
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $subject = null;
|
||||
/**
|
||||
* Associative array of a user defined headers
|
||||
* Keys will be prefixed 'X-' as per RFC2822 Section 4.7.5
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $headers = array();
|
||||
/**
|
||||
* List of additional headers
|
||||
*
|
||||
* These will NOT be used if you are using safemode and mail()
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $additionalParams = null;
|
||||
/**
|
||||
* Layout for the View
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $layout = 'default';
|
||||
/**
|
||||
* Template for the view
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $template = null;
|
||||
/**
|
||||
* as per RFC2822 Section 2.1.1
|
||||
*
|
||||
* @var integer
|
||||
* @access public
|
||||
*/
|
||||
var $lineLength = 70;
|
||||
/**
|
||||
* @deprecated see lineLength
|
||||
*/
|
||||
var $_lineLength = null;
|
||||
/**
|
||||
* What format should the email be sent in
|
||||
*
|
||||
* Supported formats:
|
||||
* - text
|
||||
* - html
|
||||
* - both
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $sendAs = 'text';
|
||||
/**
|
||||
* What method should the email be sent by
|
||||
*
|
||||
* Supported methods:
|
||||
* - mail
|
||||
* - smtp
|
||||
* - debug
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $delivery = 'mail';
|
||||
/**
|
||||
* charset the email is sent in
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $charset = 'utf-8';
|
||||
/**
|
||||
* List of files that should be attached to the email.
|
||||
*
|
||||
* Can be both absolute and relative paths
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $attachments = array();
|
||||
/**
|
||||
* What mailer should EmailComponent identify itself as
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $xMailer = 'CakePHP Email Component';
|
||||
/**
|
||||
* The list of paths to search if an attachment isnt absolute
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $filePaths = array();
|
||||
/**
|
||||
* List of options to use for smtp mail method
|
||||
*
|
||||
* Options is:
|
||||
* - port
|
||||
* - host
|
||||
* - timeout
|
||||
* - username
|
||||
* - password
|
||||
*
|
||||
* @var array
|
||||
* @access public
|
||||
*/
|
||||
var $smtpOptions = array(
|
||||
'port'=> 25, 'host' => 'localhost', 'timeout' => 30
|
||||
);
|
||||
/**
|
||||
* Placeholder for any errors that might happen with the
|
||||
* smtp mail methods
|
||||
*
|
||||
* @var string
|
||||
* @access public
|
||||
*/
|
||||
var $smtpError = null;
|
||||
/**
|
||||
* If set to true, the mail method will be auto-set to 'debug'
|
||||
*
|
||||
* @var string
|
||||
* @access protected
|
||||
*/
|
||||
var $_debug = false;
|
||||
/**
|
||||
* Temporary store of message header lines
|
||||
*
|
||||
* @var array
|
||||
* @access private
|
||||
*/
|
||||
var $__header = array();
|
||||
/**
|
||||
* If set, boundary to use for multipart mime messages
|
||||
*
|
||||
* @var string
|
||||
* @access private
|
||||
*/
|
||||
var $__boundary = null;
|
||||
/**
|
||||
* Temporary store of message lines
|
||||
*
|
||||
* @var array
|
||||
* @access private
|
||||
*/
|
||||
var $__message = array();
|
||||
/**
|
||||
* Variable that holds SMTP connection
|
||||
*
|
||||
* @var resource
|
||||
* @access private
|
||||
*/
|
||||
var $__smtpConnection = null;
|
||||
/**
|
||||
* Initialize component
|
||||
*
|
||||
* @param object $controller Instantiating controller
|
||||
* @access public
|
||||
*/
|
||||
function initialize(&$controller, $settings = array()) {
|
||||
$this->Controller =& $controller;
|
||||
if (Configure::read('App.encoding') !== null) {
|
||||
$this->charset = Configure::read('App.encoding');
|
||||
}
|
||||
$this->_set($settings);
|
||||
}
|
||||
/**
|
||||
* Startup component
|
||||
*
|
||||
* @param object $controller Instantiating controller
|
||||
* @access public
|
||||
*/
|
||||
function startup(&$controller) {}
|
||||
/**
|
||||
* Send an email using the specified content, template and layout
|
||||
*
|
||||
* @param mixed $content Either an array of text lines, or a string with contents
|
||||
* @param string $template Template to use when sending email
|
||||
* @param string $layout Layout to use to enclose email body
|
||||
* @return boolean Success
|
||||
* @access public
|
||||
*/
|
||||
function send($content = null, $template = null, $layout = null) {
|
||||
$this->__createHeader();
|
||||
|
||||
if ($template) {
|
||||
$this->template = $template;
|
||||
}
|
||||
|
||||
if ($layout) {
|
||||
$this->layout = $layout;
|
||||
}
|
||||
|
||||
if (is_array($content)) {
|
||||
$content = implode("\n", $content) . "\n";
|
||||
}
|
||||
|
||||
$message = $this->__wrap($content);
|
||||
if ($this->template === null) {
|
||||
$message = $this->__formatMessage($message);
|
||||
} else {
|
||||
$message = $this->__renderTemplate($message);
|
||||
}
|
||||
$message[] = '';
|
||||
$this->__message = $message;
|
||||
|
||||
if (!empty($this->attachments)) {
|
||||
$this->__attachFiles();
|
||||
}
|
||||
|
||||
if (!is_null($this->__boundary)) {
|
||||
$this->__message[] = '';
|
||||
$this->__message[] = '--' . $this->__boundary . '--';
|
||||
$this->__message[] = '';
|
||||
}
|
||||
|
||||
if ($this->_debug) {
|
||||
return $this->__debug();
|
||||
}
|
||||
$__method = '__' . $this->delivery;
|
||||
$sent = $this->$__method();
|
||||
|
||||
$this->__header = array();
|
||||
$this->__message = array();
|
||||
|
||||
return $sent;
|
||||
}
|
||||
/**
|
||||
* Reset all EmailComponent internal variables to be able to send out a new email.
|
||||
*
|
||||
* @access public
|
||||
*/
|
||||
function reset() {
|
||||
$this->template = null;
|
||||
$this->to = null;
|
||||
$this->from = null;
|
||||
$this->replyTo = null;
|
||||
$this->return = null;
|
||||
$this->cc = array();
|
||||
$this->bcc = array();
|
||||
$this->subject = null;
|
||||
$this->additionalParams = null;
|
||||
$this->smtpError = null;
|
||||
$this->attachments = array();
|
||||
$this->__header = array();
|
||||
$this->__boundary = null;
|
||||
$this->__message = array();
|
||||
}
|
||||
/**
|
||||
* Render the contents using the current layout and template.
|
||||
*
|
||||
* @param string $content Content to render
|
||||
* @return array Email ready to be sent
|
||||
* @access private
|
||||
*/
|
||||
function __renderTemplate($content) {
|
||||
$viewClass = $this->Controller->view;
|
||||
|
||||
if ($viewClass != 'View') {
|
||||
if (strpos($viewClass, '.') !== false) {
|
||||
list($plugin, $viewClass) = explode('.', $viewClass);
|
||||
}
|
||||
$viewClass = $viewClass . 'View';
|
||||
App::import('View', $this->Controller->view);
|
||||
}
|
||||
$View = new $viewClass($this->Controller, false);
|
||||
$View->layout = $this->layout;
|
||||
$msg = array();
|
||||
|
||||
$content = implode("\n", $content);
|
||||
|
||||
if ($this->sendAs === 'both') {
|
||||
$htmlContent = $content;
|
||||
if (!empty($this->attachments)) {
|
||||
$msg[] = '--' . $this->__boundary;
|
||||
$msg[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
|
||||
$msg[] = '';
|
||||
}
|
||||
$msg[] = '--alt-' . $this->__boundary;
|
||||
$msg[] = 'Content-Type: text/plain; charset=' . $this->charset;
|
||||
$msg[] = 'Content-Transfer-Encoding: 7bit';
|
||||
$msg[] = '';
|
||||
|
||||
$content = $View->element('email' . DS . 'text' . DS . $this->template, array('content' => $content), true);
|
||||
$View->layoutPath = 'email' . DS . 'text';
|
||||
$content = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($content)));
|
||||
$msg = array_merge($msg, $content);
|
||||
|
||||
$msg[] = '';
|
||||
$msg[] = '--alt-' . $this->__boundary;
|
||||
$msg[] = 'Content-Type: text/html; charset=' . $this->charset;
|
||||
$msg[] = 'Content-Transfer-Encoding: 7bit';
|
||||
$msg[] = '';
|
||||
|
||||
$htmlContent = $View->element('email' . DS . 'html' . DS . $this->template, array('content' => $htmlContent), true);
|
||||
$View->layoutPath = 'email' . DS . 'html';
|
||||
$htmlContent = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($htmlContent)));
|
||||
$msg = array_merge($msg, $htmlContent);
|
||||
$msg[] = '';
|
||||
$msg[] = '--alt-' . $this->__boundary . '--';
|
||||
$msg[] = '';
|
||||
|
||||
return $msg;
|
||||
}
|
||||
|
||||
if (!empty($this->attachments)) {
|
||||
if ($this->sendAs === 'html') {
|
||||
$msg[] = '';
|
||||
$msg[] = '--' . $this->__boundary;
|
||||
$msg[] = 'Content-Type: text/html; charset=' . $this->charset;
|
||||
$msg[] = 'Content-Transfer-Encoding: 7bit';
|
||||
$msg[] = '';
|
||||
} else {
|
||||
$msg[] = '--' . $this->__boundary;
|
||||
$msg[] = 'Content-Type: text/plain; charset=' . $this->charset;
|
||||
$msg[] = 'Content-Transfer-Encoding: 7bit';
|
||||
$msg[] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$content = $View->element('email' . DS . $this->sendAs . DS . $this->template, array('content' => $content), true);
|
||||
$View->layoutPath = 'email' . DS . $this->sendAs;
|
||||
$content = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($content)));
|
||||
$msg = array_merge($msg, $content);
|
||||
|
||||
return $msg;
|
||||
}
|
||||
/**
|
||||
* Create unique boundary identifier
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
function __createBoundary() {
|
||||
$this->__boundary = md5(uniqid(time()));
|
||||
}
|
||||
/**
|
||||
* Create emails headers including (but not limited to) from email address, reply to,
|
||||
* bcc and cc.
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
function __createHeader() {
|
||||
if ($this->delivery == 'smtp') {
|
||||
$this->__header[] = 'To: ' . $this->__formatAddress($this->to);
|
||||
}
|
||||
$this->__header[] = 'From: ' . $this->__formatAddress($this->from);
|
||||
|
||||
if (!empty($this->replyTo)) {
|
||||
$this->__header[] = 'Reply-To: ' . $this->__formatAddress($this->replyTo);
|
||||
}
|
||||
if (!empty($this->return)) {
|
||||
$this->__header[] = 'Return-Path: ' . $this->__formatAddress($this->return);
|
||||
}
|
||||
if (!empty($this->readReceipt)) {
|
||||
$this->__header[] = 'Disposition-Notification-To: ' . $this->__formatAddress($this->readReceipt);
|
||||
}
|
||||
|
||||
if (!empty($this->cc)) {
|
||||
$this->__header[] = 'cc: ' .implode(', ', array_map(array($this, '__formatAddress'), $this->cc));
|
||||
}
|
||||
|
||||
if (!empty($this->bcc) && $this->delivery != 'smtp') {
|
||||
$this->__header[] = 'Bcc: ' .implode(', ', array_map(array($this, '__formatAddress'), $this->bcc));
|
||||
}
|
||||
if ($this->delivery == 'smtp') {
|
||||
$this->__header[] = 'Subject: ' . $this->__encode($this->subject);
|
||||
}
|
||||
$this->__header[] = 'X-Mailer: ' . $this->xMailer;
|
||||
|
||||
if (!empty($this->headers)) {
|
||||
foreach ($this->headers as $key => $val) {
|
||||
$this->__header[] = 'X-' . $key . ': ' . $val;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->attachments)) {
|
||||
$this->__createBoundary();
|
||||
$this->__header[] = 'MIME-Version: 1.0';
|
||||
$this->__header[] = 'Content-Type: multipart/mixed; boundary="' . $this->__boundary . '"';
|
||||
$this->__header[] = 'This part of the E-mail should never be seen. If';
|
||||
$this->__header[] = 'you are reading this, consider upgrading your e-mail';
|
||||
$this->__header[] = 'client to a MIME-compatible client.';
|
||||
} elseif ($this->sendAs === 'text') {
|
||||
$this->__header[] = 'Content-Type: text/plain; charset=' . $this->charset;
|
||||
} elseif ($this->sendAs === 'html') {
|
||||
$this->__header[] = 'Content-Type: text/html; charset=' . $this->charset;
|
||||
} elseif ($this->sendAs === 'both') {
|
||||
$this->__header[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
|
||||
}
|
||||
|
||||
$this->__header[] = 'Content-Transfer-Encoding: 7bit';
|
||||
}
|
||||
/**
|
||||
* Format the message by seeing if it has attachments.
|
||||
*
|
||||
* @param string $message Message to format
|
||||
* @access private
|
||||
*/
|
||||
function __formatMessage($message) {
|
||||
if (!empty($this->attachments)) {
|
||||
$prefix = array('--' . $this->__boundary);
|
||||
if ($this->sendAs === 'text') {
|
||||
$prefix[] = 'Content-Type: text/plain; charset=' . $this->charset;
|
||||
} elseif ($this->sendAs === 'html') {
|
||||
$prefix[] = 'Content-Type: text/html; charset=' . $this->charset;
|
||||
} elseif ($this->sendAs === 'both') {
|
||||
$prefix[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
|
||||
}
|
||||
$prefix[] = 'Content-Transfer-Encoding: 7bit';
|
||||
$prefix[] = '';
|
||||
$message = array_merge($prefix, $message);
|
||||
}
|
||||
return $message;
|
||||
}
|
||||
/**
|
||||
* Attach files by adding file contents inside boundaries.
|
||||
*
|
||||
* @access private
|
||||
* @TODO: modify to use the core File class?
|
||||
*/
|
||||
function __attachFiles() {
|
||||
$files = array();
|
||||
foreach ($this->attachments as $attachment) {
|
||||
$file = $this->__findFiles($attachment);
|
||||
if (!empty($file)) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$handle = fopen($file, 'rb');
|
||||
$data = fread($handle, filesize($file));
|
||||
$data = chunk_split(base64_encode($data)) ;
|
||||
fclose($handle);
|
||||
|
||||
$this->__message[] = '--' . $this->__boundary;
|
||||
$this->__message[] = 'Content-Type: application/octet-stream';
|
||||
$this->__message[] = 'Content-Transfer-Encoding: base64';
|
||||
$this->__message[] = 'Content-Disposition: attachment; filename="' . basename($file) . '"';
|
||||
$this->__message[] = '';
|
||||
$this->__message[] = $data;
|
||||
$this->__message[] = '';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Find the specified attachment in the list of file paths
|
||||
*
|
||||
* @param string $attachment Attachment file name to find
|
||||
* @return string Path to located file
|
||||
* @access private
|
||||
*/
|
||||
function __findFiles($attachment) {
|
||||
if (file_exists($attachment)) {
|
||||
return $attachment;
|
||||
}
|
||||
foreach ($this->filePaths as $path) {
|
||||
if (file_exists($path . DS . $attachment)) {
|
||||
$file = $path . DS . $attachment;
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Wrap the message using EmailComponent::$lineLength
|
||||
*
|
||||
* @param string $message Message to wrap
|
||||
* @return array Wrapped message
|
||||
* @access private
|
||||
*/
|
||||
function __wrap($message) {
|
||||
$message = $this->__strip($message, true);
|
||||
$message = str_replace(array("\r\n","\r"), "\n", $message);
|
||||
$lines = explode("\n", $message);
|
||||
$formatted = array();
|
||||
|
||||
if ($this->_lineLength !== null) {
|
||||
trigger_error('_lineLength cannot be accessed please use lineLength', E_USER_WARNING);
|
||||
$this->lineLength = $this->_lineLength;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (substr($line, 0, 1) == '.') {
|
||||
$line = '.' . $line;
|
||||
}
|
||||
$formatted = array_merge($formatted, explode("\n", wordwrap($line, $this->lineLength, "\n", true)));
|
||||
}
|
||||
$formatted[] = '';
|
||||
return $formatted;
|
||||
}
|
||||
/**
|
||||
* Encode the specified string using the current charset
|
||||
*
|
||||
* @param string $subject String to encode
|
||||
* @return string Encoded string
|
||||
* @access private
|
||||
*/
|
||||
function __encode($subject) {
|
||||
$subject = $this->__strip($subject);
|
||||
|
||||
$nl = "\r\n";
|
||||
if ($this->delivery == 'mail') {
|
||||
$nl = '';
|
||||
}
|
||||
return mb_encode_mimeheader($subject, $this->charset, 'B', $nl);
|
||||
}
|
||||
/**
|
||||
* Format a string as an email address
|
||||
*
|
||||
* @param string $string String representing an email address
|
||||
* @return string Email address suitable for email headers or smtp pipe
|
||||
* @access private
|
||||
*/
|
||||
function __formatAddress($string, $smtp = false) {
|
||||
if (strpos($string, '<') !== false) {
|
||||
$value = explode('<', $string);
|
||||
if ($smtp) {
|
||||
$string = '<' . $value[1];
|
||||
} else {
|
||||
$string = $this->__encode($value[0]) . ' <' . $value[1];
|
||||
}
|
||||
}
|
||||
return $this->__strip($string);
|
||||
}
|
||||
/**
|
||||
* Remove certain elements (such as bcc:, to:, %0a) from given value
|
||||
*
|
||||
* @param string $value Value to strip
|
||||
* @param boolean $message Set to true to indicate main message content
|
||||
* @return string Stripped value
|
||||
* @access private
|
||||
*/
|
||||
function __strip($value, $message = false) {
|
||||
$search = '%0a|%0d|Content-(?:Type|Transfer-Encoding)\:';
|
||||
$search .= '|charset\=|mime-version\:|multipart/mixed|(?:[^a-z]to|b?cc)\:.*';
|
||||
|
||||
if ($message !== true) {
|
||||
$search .= '|\r|\n';
|
||||
}
|
||||
$search = '#(?:' . $search . ')#i';
|
||||
while (preg_match($search, $value)) {
|
||||
$value = preg_replace($search, '', $value);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
/**
|
||||
* Wrapper for PHP mail function used for sending out emails
|
||||
*
|
||||
* @return bool Success
|
||||
* @access private
|
||||
*/
|
||||
function __mail() {
|
||||
$header = implode("\n", $this->__header);
|
||||
$message = implode("\n", $this->__message);
|
||||
if (ini_get('safe_mode')) {
|
||||
return @mail($this->to, $this->__encode($this->subject), $message, $header);
|
||||
}
|
||||
return @mail($this->to, $this->__encode($this->subject), $message, $header, $this->additionalParams);
|
||||
}
|
||||
/**
|
||||
* Sends out email via SMTP
|
||||
*
|
||||
* @return bool Success
|
||||
* @access private
|
||||
*/
|
||||
function __smtp() {
|
||||
App::import('Core', array('Socket'));
|
||||
|
||||
$this->__smtpConnection =& new CakeSocket(array_merge(array('protocol'=>'smtp'), $this->smtpOptions));
|
||||
|
||||
if (!$this->__smtpConnection->connect()) {
|
||||
$this->smtpError = $this->__smtpConnection->lastError();
|
||||
return false;
|
||||
} elseif (!$this->__smtpSend(null, '220')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$httpHost = env('HTTP_HOST');
|
||||
|
||||
if (isset($this->smtpOptions['client'])) {
|
||||
$host = $this->smtpOptions['client'];
|
||||
} elseif (!empty($httpHost)) {
|
||||
$host = $httpHost;
|
||||
} else {
|
||||
$host = 'localhost';
|
||||
}
|
||||
|
||||
if (!$this->__smtpSend("HELO {$host}", '250')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($this->smtpOptions['username']) && isset($this->smtpOptions['password'])) {
|
||||
$authRequired = $this->__smtpSend('AUTH LOGIN', '334|503');
|
||||
if ($authRequired == '334') {
|
||||
if (!$this->__smtpSend(base64_encode($this->smtpOptions['username']), '334')) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->__smtpSend(base64_encode($this->smtpOptions['password']), '235')) {
|
||||
return false;
|
||||
}
|
||||
} elseif ($authRequired != '503') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->__smtpSend('MAIL FROM: ' . $this->__formatAddress($this->from, true))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($this->to, true))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->cc as $cc) {
|
||||
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($cc, true))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach ($this->bcc as $bcc) {
|
||||
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($bcc, true))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->__smtpSend('DATA', '354')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = implode("\r\n", $this->__header);
|
||||
$message = implode("\r\n", $this->__message);
|
||||
if (!$this->__smtpSend($header . "\r\n\r\n" . $message . "\r\n\r\n\r\n.")) {
|
||||
return false;
|
||||
}
|
||||
$this->__smtpSend('QUIT', false);
|
||||
|
||||
$this->__smtpConnection->disconnect();
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Private method for sending data to SMTP connection
|
||||
*
|
||||
* @param string $data data to be sent to SMTP server
|
||||
* @param mixed $checkCode code to check for in server response, false to skip
|
||||
* @return bool Success
|
||||
* @access private
|
||||
*/
|
||||
function __smtpSend($data, $checkCode = '250') {
|
||||
if (!is_null($data)) {
|
||||
$this->__smtpConnection->write($data . "\r\n");
|
||||
}
|
||||
if ($checkCode !== false) {
|
||||
$response = $this->__smtpConnection->read();
|
||||
|
||||
if (preg_match('/^(' . $checkCode . ')/', $response, $code)) {
|
||||
return $code[0];
|
||||
}
|
||||
$this->smtpError = $response;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Set as controller flash message a debug message showing current settings in component
|
||||
*
|
||||
* @return boolean Success
|
||||
* @access private
|
||||
*/
|
||||
function __debug() {
|
||||
$nl = "\n";
|
||||
$header = implode($nl, $this->__header);
|
||||
$message = implode($nl, $this->__message);
|
||||
$fm = '<pre>';
|
||||
|
||||
if ($this->delivery == 'smtp') {
|
||||
$fm .= sprintf('%s %s%s', 'Host:', $this->smtpOptions['host'], $nl);
|
||||
$fm .= sprintf('%s %s%s', 'Port:', $this->smtpOptions['port'], $nl);
|
||||
$fm .= sprintf('%s %s%s', 'Timeout:', $this->smtpOptions['timeout'], $nl);
|
||||
}
|
||||
$fm .= sprintf('%s %s%s', 'To:', $this->to, $nl);
|
||||
$fm .= sprintf('%s %s%s', 'From:', $this->from, $nl);
|
||||
$fm .= sprintf('%s %s%s', 'Subject:', $this->__encode($this->subject), $nl);
|
||||
$fm .= sprintf('%s%3$s%3$s%s', 'Header:', $header, $nl);
|
||||
$fm .= sprintf('%s%3$s%3$s%s', 'Parameters:', $this->additionalParams, $nl);
|
||||
$fm .= sprintf('%s%3$s%3$s%s', 'Message:', $message, $nl);
|
||||
$fm .= '</pre>';
|
||||
|
||||
$this->Controller->Session->setFlash($fm, 'default', null, 'email');
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
NameVirtualHost *:80
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/www/cmc-sales/app/webroot
|
||||
</VirtualHost>
|
||||
|
||||
<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
|
||||
|
||||
# Ensure PHP errors are also logged
|
||||
php_flag log_errors on
|
||||
php_value error_log /dev/stderr
|
||||
</VirtualHost>
|
||||
3
conf/mariadb-no-strict.cnf
Normal file
3
conf/mariadb-no-strict.cnf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[mysqld]
|
||||
# Custom sql_mode for legacy CakePHP compatibility
|
||||
sql_mode = ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
|
||||
152
conf/nginx-production.conf
Normal file
152
conf/nginx-production.conf
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Production environment configuration
|
||||
upstream cmc_php_production {
|
||||
server cmc-php-production:80;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream cmc_go_production {
|
||||
server cmc-go-production:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
|
||||
|
||||
server {
|
||||
server_name cmc.springupsoftware.com;
|
||||
|
||||
# Basic auth for production
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "CMC Sales - Restricted Access";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Hide server information
|
||||
server_tokens off;
|
||||
|
||||
# Request size limits
|
||||
client_max_body_size 50M;
|
||||
client_body_timeout 30s;
|
||||
client_header_timeout 30s;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# CakePHP legacy app routes
|
||||
location / {
|
||||
limit_req zone=api burst=10 nodelay;
|
||||
|
||||
proxy_pass http://cmc_php_production;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
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;
|
||||
|
||||
# Buffer settings for better performance
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Go API routes
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://cmc_go_production;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
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;
|
||||
|
||||
# Buffer settings for better performance
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Go page routes for emails
|
||||
location ~ ^/(emails|customers|products|purchase-orders|enquiries|documents) {
|
||||
limit_req zone=api burst=15 nodelay;
|
||||
|
||||
proxy_pass http://cmc_go_production;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
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;
|
||||
|
||||
# Buffer settings for better performance
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Static files from Go app with aggressive caching
|
||||
location /static/ {
|
||||
proxy_pass http://cmc_go_production;
|
||||
proxy_cache_valid 200 24h;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
expires 1d;
|
||||
}
|
||||
|
||||
# PDF files with caching
|
||||
location /pdf/ {
|
||||
proxy_pass http://cmc_go_production;
|
||||
proxy_cache_valid 200 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
expires 1h;
|
||||
}
|
||||
|
||||
# Health check endpoints (no rate limiting)
|
||||
location /health {
|
||||
proxy_pass http://cmc_go_production/api/v1/health;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Block common attack patterns
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ~$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
# Custom error page for rate limiting
|
||||
error_page 429 /429.html;
|
||||
location = /429.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
}
|
||||
137
conf/nginx-proxy.conf
Normal file
137
conf/nginx-proxy.conf
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=global:10m rate=10r/s;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Upstream servers
|
||||
upstream cmc_staging {
|
||||
server nginx-staging:80;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream cmc_production {
|
||||
server nginx-production:80;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name cmc.springupsoftware.com staging.cmc.springupsoftware.com;
|
||||
|
||||
# ACME challenge for Lego
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme-challenge;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Production HTTPS
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cmc.springupsoftware.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/cmc.springupsoftware.com.crt;
|
||||
ssl_certificate_key /etc/ssl/certs/cmc.springupsoftware.com.key;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=global burst=20 nodelay;
|
||||
|
||||
location / {
|
||||
proxy_pass http://cmc_production;
|
||||
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;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
|
||||
# Staging HTTPS
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name staging.cmc.springupsoftware.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/staging.cmc.springupsoftware.com.crt;
|
||||
ssl_certificate_key /etc/ssl/certs/staging.cmc.springupsoftware.com.key;
|
||||
|
||||
# Security headers (less strict for staging)
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Environment "STAGING";
|
||||
|
||||
# Rate limiting (more lenient for staging)
|
||||
limit_req zone=global burst=50 nodelay;
|
||||
|
||||
location / {
|
||||
proxy_pass http://cmc_staging;
|
||||
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;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,23 @@
|
|||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
server {
|
||||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
|
||||
location /go/ {
|
||||
set $upstream_go cmc-go:8080;
|
||||
proxy_pass http://$upstream_go;
|
||||
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;
|
||||
set $upstream_php cmc-php:80;
|
||||
proxy_pass http://$upstream_php;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
|
|||
27
conf/nginx-site.prod.conf
Normal file
27
conf/nginx-site.prod.conf
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
server {
|
||||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
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
|
||||
|
||||
}
|
||||
27
conf/nginx-site.stg.conf
Normal file
27
conf/nginx-site.stg.conf
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
server {
|
||||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
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
|
||||
|
||||
}
|
||||
89
conf/nginx-staging.conf
Normal file
89
conf/nginx-staging.conf
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Staging environment configuration
|
||||
upstream cmc_php_staging {
|
||||
server cmc-php-staging:80;
|
||||
}
|
||||
|
||||
upstream cmc_go_staging {
|
||||
server cmc-go-staging:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name staging.cmc.springupsoftware.com;
|
||||
|
||||
# Basic auth for staging
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "CMC Sales Staging - Restricted Access";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# Staging banner
|
||||
add_header X-Environment "STAGING";
|
||||
|
||||
# CakePHP legacy app routes
|
||||
location / {
|
||||
proxy_pass http://cmc_php_staging;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
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;
|
||||
proxy_set_header X-Environment "staging";
|
||||
}
|
||||
|
||||
# Go API routes
|
||||
location /api/ {
|
||||
proxy_pass http://cmc_go_staging;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
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;
|
||||
proxy_set_header X-Environment "staging";
|
||||
}
|
||||
|
||||
# Go page routes for emails
|
||||
location ~ ^/(emails|customers|products|purchase-orders|enquiries|documents) {
|
||||
proxy_pass http://cmc_go_staging;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
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;
|
||||
proxy_set_header X-Environment "staging";
|
||||
}
|
||||
|
||||
# Static files from Go app
|
||||
location /static/ {
|
||||
proxy_pass http://cmc_go_staging;
|
||||
proxy_cache_valid 200 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# PDF files
|
||||
location /pdf/ {
|
||||
proxy_pass http://cmc_go_staging;
|
||||
proxy_cache_valid 200 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# Health check endpoints
|
||||
location /health {
|
||||
proxy_pass http://cmc_go_staging/api/v1/health;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
}
|
||||
|
|
@ -633,7 +633,8 @@ html_errors = Off
|
|||
; empty.
|
||||
; http://php.net/error-log
|
||||
; Example:
|
||||
error_log = /var/log/php_errors.log
|
||||
; For Docker: Send errors to stderr so they appear in docker logs
|
||||
error_log = /dev/stderr
|
||||
; Log errors to syslog (Event Log on NT, not valid in Windows 95).
|
||||
;error_log = syslog
|
||||
|
||||
|
|
|
|||
118
docker-compose.prod.yml
Normal file
118
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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
|
||||
GO_APP_HOST: cmc-prod-go:8082
|
||||
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/attachments_files:/root/webroot/attachments_files
|
||||
- /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:
|
||||
126
docker-compose.stg.yml
Normal file
126
docker-compose.stg.yml
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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
|
||||
GO_APP_HOST: cmc-stg-go:8082
|
||||
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
|
||||
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
|
||||
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:
|
||||
|
|
@ -8,7 +8,10 @@ services:
|
|||
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
depends_on:
|
||||
- cmc-php
|
||||
cmc-php:
|
||||
condition: service_started
|
||||
cmc-go:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cmc-network
|
||||
|
|
@ -16,30 +19,32 @@ services:
|
|||
cmc-php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.local.php
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php
|
||||
environment:
|
||||
GO_APP_HOST: cmc-go:8080
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
networks:
|
||||
- cmc-network
|
||||
restart: unless-stopped
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./app
|
||||
path: ./php/app
|
||||
ignore:
|
||||
- ./app/webroot/pdf
|
||||
- ./app/webroot/attachments_files
|
||||
- ./app/tmp
|
||||
- ./php/app/webroot/pdf
|
||||
- ./php/app/webroot/attachments_files
|
||||
- ./php/app/tmp
|
||||
- action: sync
|
||||
path: ./app/webroot/css
|
||||
path: ./php/app/webroot/css
|
||||
target: /var/www/cmc-sales/app/webroot/css
|
||||
- action: sync
|
||||
path: ./app/webroot/js
|
||||
path: ./php/app/webroot/js
|
||||
target: /var/www/cmc-sales/app/webroot/js
|
||||
|
||||
db:
|
||||
|
|
@ -60,7 +65,7 @@ services:
|
|||
cmc-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go
|
||||
dockerfile: Dockerfile.local.go
|
||||
container_name: cmc-go
|
||||
environment:
|
||||
DB_HOST: db
|
||||
|
|
@ -75,25 +80,12 @@ services:
|
|||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./app/webroot/pdf:/root/webroot/pdf
|
||||
- ./go:/app
|
||||
- ./go/.air.toml:/root/.air.toml
|
||||
- ./go/.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:
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
# Database configuration
|
||||
DB_HOST=localhost
|
||||
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
|
||||
|
||||
# Server configuration
|
||||
PORT=8080
|
||||
33
go-app/.gitignore
vendored
33
go-app/.gitignore
vendored
|
|
@ -1,33 +0,0 @@
|
|||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go vendor directory
|
||||
vendor/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
# Generated sqlc files (optional - you may want to commit these)
|
||||
# internal/cmc/db/*.go
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install dependencies
|
||||
@echo "Setting up private module configuration..."
|
||||
go env -w GOPRIVATE=code.springupsoftware.com
|
||||
go mod download
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
|
||||
.PHONY: sqlc
|
||||
sqlc: ## Generate Go code from SQL queries
|
||||
sqlc generate
|
||||
|
||||
.PHONY: build
|
||||
build: sqlc ## Build the application
|
||||
go build -o bin/server cmd/server/main.go
|
||||
|
||||
.PHONY: run
|
||||
run: ## Run the application
|
||||
go run cmd/server/main.go
|
||||
|
||||
.PHONY: dev
|
||||
dev: sqlc ## Run the application with hot reload (requires air)
|
||||
air
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run tests
|
||||
go test -v ./...
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
rm -rf bin/
|
||||
rm -rf internal/cmc/db/*.go
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: ## Build Docker image
|
||||
docker build -t cmc-go:latest -f Dockerfile.go .
|
||||
|
||||
.PHONY: docker-run
|
||||
docker-run: ## Run application in Docker
|
||||
docker run --rm -p 8080:8080 --network=host cmc-go:latest
|
||||
|
||||
.PHONY: dbshell
|
||||
dbshell: ## Connect to MariaDB database interactively
|
||||
@echo "Connecting to MariaDB..."
|
||||
@if [ -z "$$DB_PASSWORD" ]; then \
|
||||
echo "Reading password from docker-compose environment..."; \
|
||||
docker compose exec db mariadb -u cmc -p cmc; \
|
||||
else \
|
||||
docker compose exec -e MYSQL_PWD="$$DB_PASSWORD" db mariadb -u cmc cmc; \
|
||||
fi
|
||||
|
||||
.PHONY: dbshell-root
|
||||
dbshell-root: ## Connect to MariaDB as root user
|
||||
@echo "Connecting to MariaDB as root..."
|
||||
@if [ -z "$$DB_ROOT_PASSWORD" ]; then \
|
||||
echo "Please set DB_ROOT_PASSWORD environment variable"; \
|
||||
exit 1; \
|
||||
else \
|
||||
docker compose exec -e MYSQL_PWD="$$DB_ROOT_PASSWORD" db mariadb -u root; \
|
||||
fi
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"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/templates"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found")
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
dbHost := getEnv("DB_HOST", "localhost")
|
||||
dbPort := getEnv("DB_PORT", "3306")
|
||||
dbUser := getEnv("DB_USER", "cmc")
|
||||
dbPass := getEnv("DB_PASSWORD", "xVRQI&cA?7AU=hqJ!%au")
|
||||
dbName := getEnv("DB_NAME", "cmc")
|
||||
|
||||
// Connect to database
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName)
|
||||
database, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Test database connection
|
||||
if err := database.Ping(); err != nil {
|
||||
log.Fatal("Failed to ping database:", err)
|
||||
}
|
||||
|
||||
log.Println("Connected to database successfully")
|
||||
|
||||
// Create queries instance
|
||||
queries := db.New(database)
|
||||
|
||||
// Initialize template manager
|
||||
tmpl, err := templates.NewTemplateManager("templates")
|
||||
if err != nil {
|
||||
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)
|
||||
addressHandler := handlers.NewAddressHandler(queries)
|
||||
attachmentHandler := handlers.NewAttachmentHandler(queries)
|
||||
countryHandler := handlers.NewCountryHandler(queries)
|
||||
statusHandler := handlers.NewStatusHandler(queries)
|
||||
lineItemHandler := handlers.NewLineItemHandler(queries)
|
||||
|
||||
// Setup routes
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// PDF files (matching CakePHP structure)
|
||||
r.PathPrefix("/pdf/").Handler(http.StripPrefix("/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
|
||||
|
||||
// API routes
|
||||
api := r.PathPrefix("/api/v1").Subrouter()
|
||||
|
||||
// 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")
|
||||
|
||||
// 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")
|
||||
|
||||
// Start server
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Starting server on port %s", port)
|
||||
if err := http.ListenAndServe(":"+port, r); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
module code.springupsoftware.com/cmc/cmc-sales
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentsType string
|
||||
|
||||
const (
|
||||
DocumentsTypeQuote DocumentsType = "quote"
|
||||
DocumentsTypeInvoice DocumentsType = "invoice"
|
||||
DocumentsTypePurchaseOrder DocumentsType = "purchaseOrder"
|
||||
DocumentsTypeOrderAck DocumentsType = "orderAck"
|
||||
DocumentsTypePackingList DocumentsType = "packingList"
|
||||
)
|
||||
|
||||
func (e *DocumentsType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = DocumentsType(s)
|
||||
case string:
|
||||
*e = DocumentsType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for DocumentsType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullDocumentsType struct {
|
||||
DocumentsType DocumentsType `json:"documents_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if DocumentsType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullDocumentsType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.DocumentsType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.DocumentsType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullDocumentsType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.DocumentsType), nil
|
||||
}
|
||||
|
||||
type UsersAccessLevel string
|
||||
|
||||
const (
|
||||
UsersAccessLevelAdmin UsersAccessLevel = "admin"
|
||||
UsersAccessLevelManager UsersAccessLevel = "manager"
|
||||
UsersAccessLevelUser UsersAccessLevel = "user"
|
||||
UsersAccessLevelNone UsersAccessLevel = "none"
|
||||
)
|
||||
|
||||
func (e *UsersAccessLevel) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = UsersAccessLevel(s)
|
||||
case string:
|
||||
*e = UsersAccessLevel(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for UsersAccessLevel: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullUsersAccessLevel struct {
|
||||
UsersAccessLevel UsersAccessLevel `json:"users_access_level"`
|
||||
Valid bool `json:"valid"` // Valid is true if UsersAccessLevel is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullUsersAccessLevel) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.UsersAccessLevel, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.UsersAccessLevel.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullUsersAccessLevel) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.UsersAccessLevel), nil
|
||||
}
|
||||
|
||||
type UsersType string
|
||||
|
||||
const (
|
||||
UsersTypePrinciple UsersType = "principle"
|
||||
UsersTypeContact UsersType = "contact"
|
||||
UsersTypeUser UsersType = "user"
|
||||
)
|
||||
|
||||
func (e *UsersType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = UsersType(s)
|
||||
case string:
|
||||
*e = UsersType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for UsersType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullUsersType struct {
|
||||
UsersType UsersType `json:"users_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if UsersType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullUsersType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.UsersType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.UsersType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullUsersType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.UsersType), nil
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
ID int32 `json:"id"`
|
||||
// Descriptive Name for this address
|
||||
Name string `json:"name"`
|
||||
// street or unit number and street name
|
||||
Address string `json:"address"`
|
||||
// Suburb / City
|
||||
City string `json:"city"`
|
||||
// State foreign Key
|
||||
StateID int32 `json:"state_id"`
|
||||
// Country foreign Key
|
||||
CountryID int32 `json:"country_id"`
|
||||
// Customer foreign key
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
// either bill / ship / both
|
||||
Type string `json:"type"`
|
||||
Postcode string `json:"postcode"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID int32 `json:"id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
Created time.Time `json:"created"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"filename"`
|
||||
File string `json:"file"`
|
||||
Type string `json:"type"`
|
||||
Size int32 `json:"size"`
|
||||
Description string `json:"description"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
type Box struct {
|
||||
ID int32 `json:"id"`
|
||||
ShipmentID int32 `json:"shipment_id"`
|
||||
Length string `json:"length"`
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
}
|
||||
|
||||
type Country struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID int32 `json:"id"`
|
||||
// Company Name
|
||||
Name string `json:"name"`
|
||||
TradingName string `json:"trading_name"`
|
||||
Abn sql.NullString `json:"abn"`
|
||||
Created time.Time `json:"created"`
|
||||
Notes string `json:"notes"`
|
||||
DiscountPricingPolicies string `json:"discount_pricing_policies"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
CustomerCategoryID int32 `json:"customer_category_id"`
|
||||
Url string `json:"url"`
|
||||
CountryID int32 `json:"country_id"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID int32 `json:"id"`
|
||||
Type DocumentsType `json:"type"`
|
||||
Created time.Time `json:"created"`
|
||||
UserID int32 `json:"user_id"`
|
||||
DocPageCount int32 `json:"doc_page_count"`
|
||||
// Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries
|
||||
CmcReference string `json:"cmc_reference"`
|
||||
PdfFilename string `json:"pdf_filename"`
|
||||
PdfCreatedAt time.Time `json:"pdf_created_at"`
|
||||
PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
|
||||
ShippingDetails sql.NullString `json:"shipping_details"`
|
||||
Revision int32 `json:"revision"`
|
||||
BillTo sql.NullString `json:"bill_to"`
|
||||
ShipTo sql.NullString `json:"ship_to"`
|
||||
EmailSentAt time.Time `json:"email_sent_at"`
|
||||
EmailSentByUserID int32 `json:"email_sent_by_user_id"`
|
||||
}
|
||||
|
||||
type Enquiry struct {
|
||||
ID int32 `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Submitted sql.NullTime `json:"submitted"`
|
||||
// enquirynumber
|
||||
Title string `json:"title"`
|
||||
UserID int32 `json:"user_id"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
ContactID int32 `json:"contact_id"`
|
||||
ContactUserID int32 `json:"contact_user_id"`
|
||||
StateID int32 `json:"state_id"`
|
||||
CountryID int32 `json:"country_id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
StatusID int32 `json:"status_id"`
|
||||
Comments string `json:"comments"`
|
||||
// Numeric Principle Code
|
||||
PrincipleCode int32 `json:"principle_code"`
|
||||
// GST applicable on this enquiry
|
||||
Gst bool `json:"gst"`
|
||||
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
|
||||
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
|
||||
// has the enquired been posted
|
||||
Posted bool `json:"posted"`
|
||||
EmailCount int32 `json:"email_count"`
|
||||
InvoiceCount int32 `json:"invoice_count"`
|
||||
JobCount int32 `json:"job_count"`
|
||||
QuoteCount int32 `json:"quote_count"`
|
||||
Archived int8 `json:"archived"`
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
ID int32 `json:"id"`
|
||||
// CMC Invoice Number String
|
||||
Title string `json:"title"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
ID int32 `json:"id"`
|
||||
ItemNumber string `json:"item_number"`
|
||||
Option bool `json:"option"`
|
||||
Quantity string `json:"quantity"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
ProductID sql.NullInt32 `json:"product_id"`
|
||||
HasTextPrices bool `json:"has_text_prices"`
|
||||
HasPrice int8 `json:"has_price"`
|
||||
UnitPriceString sql.NullString `json:"unit_price_string"`
|
||||
GrossPriceString sql.NullString `json:"gross_price_string"`
|
||||
CostingID sql.NullInt32 `json:"costing_id"`
|
||||
// Either fill this in or have a costing_id associated with this record
|
||||
GrossUnitPrice sql.NullString `json:"gross_unit_price"`
|
||||
NetUnitPrice sql.NullString `json:"net_unit_price"`
|
||||
DiscountPercent sql.NullString `json:"discount_percent"`
|
||||
DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
|
||||
DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
|
||||
GrossPrice sql.NullString `json:"gross_price"`
|
||||
NetPrice sql.NullString `json:"net_price"`
|
||||
}
|
||||
|
||||
type Principle struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName sql.NullString `json:"short_name"`
|
||||
Code int32 `json:"code"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int32 `json:"id"`
|
||||
// Principle FK
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
ProductCategoryID int32 `json:"product_category_id"`
|
||||
// This must match the Title in the Excel Costing File
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
// Part or model number principle uses to identify this product
|
||||
ModelNumber sql.NullString `json:"model_number"`
|
||||
// %1% - first item, %2% , second item etc
|
||||
ModelNumberFormat sql.NullString `json:"model_number_format"`
|
||||
// Any notes about this product. Note displayed on quotes
|
||||
Notes sql.NullString `json:"notes"`
|
||||
// Stock or Ident
|
||||
Stock bool `json:"stock"`
|
||||
ItemCode string `json:"item_code"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
}
|
||||
|
||||
type PurchaseOrder struct {
|
||||
ID int32 `json:"id"`
|
||||
IssueDate time.Time `json:"issue_date"`
|
||||
DispatchDate time.Time `json:"dispatch_date"`
|
||||
DateArrived time.Time `json:"date_arrived"`
|
||||
// CMC PONumber
|
||||
Title string `json:"title"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
PrincipleReference string `json:"principle_reference"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
CurrencyID sql.NullInt32 `json:"currency_id"`
|
||||
OrderedFrom string `json:"ordered_from"`
|
||||
Description string `json:"description"`
|
||||
DispatchBy string `json:"dispatch_by"`
|
||||
DeliverTo string `json:"deliver_to"`
|
||||
ShippingInstructions string `json:"shipping_instructions"`
|
||||
JobsText string `json:"jobs_text"`
|
||||
FreightForwarderText string `json:"freight_forwarder_text"`
|
||||
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Shortform sql.NullString `json:"shortform"`
|
||||
Enqform sql.NullString `json:"enqform"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color sql.NullString `json:"color"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
Type UsersType `json:"type"`
|
||||
AccessLevel UsersAccessLevel `json:"access_level"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
JobTitle string `json:"job_title"`
|
||||
Phone string `json:"phone"`
|
||||
Mobile string `json:"mobile"`
|
||||
Fax string `json:"fax"`
|
||||
PhoneExtension string `json:"phone_extension"`
|
||||
DirectPhone string `json:"direct_phone"`
|
||||
Notes string `json:"notes"`
|
||||
// Added by Vault. May or may not be a real person.
|
||||
ByVault bool `json:"by_vault"`
|
||||
// Disregard emails from this address in future.
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Archived sql.NullBool `json:"archived"`
|
||||
PrimaryContact bool `json:"primary_contact"`
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
ArchiveEnquiry(ctx context.Context, id int32) error
|
||||
ArchiveUser(ctx context.Context, id int32) error
|
||||
CountDocuments(ctx context.Context) (int64, error)
|
||||
CountDocumentsByType(ctx context.Context, type_ DocumentsType) (int64, error)
|
||||
CountEnquiries(ctx context.Context) (int64, error)
|
||||
CountEnquiriesByPrinciple(ctx context.Context, principleCode int32) (int64, error)
|
||||
CountEnquiriesByPrincipleAndState(ctx context.Context, arg CountEnquiriesByPrincipleAndStateParams) (int64, error)
|
||||
CountEnquiriesByStatus(ctx context.Context, statusID int32) (int64, error)
|
||||
CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error)
|
||||
CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error)
|
||||
CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error)
|
||||
CreateCountry(ctx context.Context, name string) (sql.Result, error)
|
||||
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
|
||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error)
|
||||
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
|
||||
CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error)
|
||||
CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error)
|
||||
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
|
||||
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
|
||||
CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error)
|
||||
CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
|
||||
DeleteAddress(ctx context.Context, id int32) error
|
||||
DeleteAttachment(ctx context.Context, id int32) error
|
||||
DeleteBox(ctx context.Context, id int32) error
|
||||
DeleteCountry(ctx context.Context, id int32) error
|
||||
DeleteCustomer(ctx context.Context, id int32) error
|
||||
DeleteDocument(ctx context.Context, id int32) error
|
||||
DeleteLineItem(ctx context.Context, id int32) error
|
||||
DeleteProduct(ctx context.Context, id int32) error
|
||||
DeletePurchaseOrder(ctx context.Context, id int32) error
|
||||
DeleteState(ctx context.Context, id int32) error
|
||||
DeleteStatus(ctx context.Context, id int32) error
|
||||
GetAddress(ctx context.Context, id int32) (Address, error)
|
||||
GetAllCountries(ctx context.Context) ([]Country, error)
|
||||
GetAllPrinciples(ctx context.Context) ([]Principle, error)
|
||||
GetAllStates(ctx context.Context) ([]State, error)
|
||||
GetAllStatuses(ctx context.Context) ([]GetAllStatusesRow, error)
|
||||
GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error)
|
||||
GetAttachment(ctx context.Context, id int32) (Attachment, error)
|
||||
GetBox(ctx context.Context, id int32) (Box, error)
|
||||
GetCountry(ctx context.Context, id int32) (Country, error)
|
||||
GetCustomer(ctx context.Context, id int32) (Customer, error)
|
||||
GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error)
|
||||
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error)
|
||||
GetDocument(ctx context.Context, id int32) (Document, error)
|
||||
GetDocumentWithUser(ctx context.Context, id int32) (GetDocumentWithUserRow, error)
|
||||
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
|
||||
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
|
||||
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, 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)
|
||||
GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error)
|
||||
GetPrinciple(ctx context.Context, id int32) (Principle, error)
|
||||
GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error)
|
||||
GetProduct(ctx context.Context, id int32) (Product, error)
|
||||
GetProductByItemCode(ctx context.Context, itemCode string) (Product, error)
|
||||
GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error)
|
||||
GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error)
|
||||
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
|
||||
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
|
||||
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
|
||||
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, 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)
|
||||
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
|
||||
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
|
||||
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
|
||||
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
|
||||
ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error)
|
||||
ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error)
|
||||
ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error)
|
||||
ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error)
|
||||
ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error)
|
||||
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]Document, error)
|
||||
ListDocumentsByType(ctx context.Context, arg ListDocumentsByTypeParams) ([]Document, error)
|
||||
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
|
||||
ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error)
|
||||
ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error)
|
||||
ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error)
|
||||
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
|
||||
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
|
||||
ListStates(ctx context.Context, arg ListStatesParams) ([]State, error)
|
||||
ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error)
|
||||
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
|
||||
SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error)
|
||||
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error)
|
||||
SearchEnquiries(ctx context.Context, arg SearchEnquiriesParams) ([]SearchEnquiriesRow, error)
|
||||
SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error)
|
||||
SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error)
|
||||
UnarchiveEnquiry(ctx context.Context, id int32) error
|
||||
UnarchiveUser(ctx context.Context, id int32) error
|
||||
UpdateAddress(ctx context.Context, arg UpdateAddressParams) error
|
||||
UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error
|
||||
UpdateBox(ctx context.Context, arg UpdateBoxParams) error
|
||||
UpdateCountry(ctx context.Context, arg UpdateCountryParams) error
|
||||
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
|
||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error
|
||||
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
|
||||
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
|
||||
UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error
|
||||
UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error
|
||||
UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error
|
||||
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
|
||||
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
|
||||
UpdateState(ctx context.Context, arg UpdateStateParams) error
|
||||
UpdateStatus(ctx context.Context, arg UpdateStatusParams) error
|
||||
UpdateUser(ctx context.Context, arg UpdateUserParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type AttachmentHandler struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewAttachmentHandler(queries *db.Queries) *AttachmentHandler {
|
||||
return &AttachmentHandler{queries: queries}
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if val, err := strconv.Atoi(o); err == nil {
|
||||
offset = val
|
||||
}
|
||||
}
|
||||
|
||||
attachments, err := h.queries.ListAttachments(r.Context(), db.ListAttachmentsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachments)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Archived(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if val, err := strconv.Atoi(o); err == nil {
|
||||
offset = val
|
||||
}
|
||||
}
|
||||
|
||||
attachments, err := h.queries.ListArchivedAttachments(r.Context(), db.ListArchivedAttachmentsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachments)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
attachment, err := h.queries.GetAttachment(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Attachment not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachment)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse multipart form
|
||||
err := r.ParseMultipartForm(32 << 20) // 32MB max
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file from form
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext)
|
||||
|
||||
// Create attachments directory if it doesn't exist
|
||||
attachDir := "webroot/attachments_files"
|
||||
if err := os.MkdirAll(attachDir, 0755); err != nil {
|
||||
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save file to disk
|
||||
filepath := filepath.Join(attachDir, filename)
|
||||
dst, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse principle_id
|
||||
principleID := 1 // Default
|
||||
if pid := r.FormValue("principle_id"); pid != "" {
|
||||
if id, err := strconv.Atoi(pid); err == nil {
|
||||
principleID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Create database record
|
||||
params := db.CreateAttachmentParams{
|
||||
PrincipleID: int32(principleID),
|
||||
Name: r.FormValue("name"),
|
||||
Filename: handler.Filename,
|
||||
File: filename,
|
||||
Type: handler.Header.Get("Content-Type"),
|
||||
Size: int32(handler.Size),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
if params.Name == "" {
|
||||
params.Name = handler.Filename
|
||||
}
|
||||
|
||||
result, err := h.queries.CreateAttachment(r.Context(), params)
|
||||
if err != nil {
|
||||
// Clean up file on error
|
||||
os.Remove(filepath)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If HTMX request, return success message
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<div class="notification is-success">Attachment uploaded successfully</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// JSON response for API
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var params db.UpdateAttachmentParams
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Handle form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
params = db.UpdateAttachmentParams{
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
}
|
||||
|
||||
params.ID = int32(id)
|
||||
|
||||
if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Soft delete (archive)
|
||||
if err := h.queries.DeleteAttachment(r.Context(), int32(id)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
@ -1,778 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type PageHandler struct {
|
||||
queries *db.Queries
|
||||
tmpl *templates.TemplateManager
|
||||
}
|
||||
|
||||
func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager) *PageHandler {
|
||||
return &PageHandler{
|
||||
queries: queries,
|
||||
tmpl: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
// Home page
|
||||
func (h *PageHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Title": "Dashboard",
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Customer pages
|
||||
func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if val, err := strconv.Atoi(p); err == nil && val > 0 {
|
||||
page = val
|
||||
}
|
||||
}
|
||||
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
customers, err := h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
|
||||
Limit: int32(limit + 1), // Get one extra to check if there are more
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hasMore := len(customers) > limit
|
||||
if hasMore {
|
||||
customers = customers[:limit]
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Customers": customers,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) CustomersNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Customer": db.Customer{},
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) CustomersEdit(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Customer not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Customer": customer,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) CustomersShow(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Customer not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Customer": customer,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "customers/show.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("search")
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if val, err := strconv.Atoi(p); err == nil && val > 0 {
|
||||
page = val
|
||||
}
|
||||
}
|
||||
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var customers []db.Customer
|
||||
var err error
|
||||
|
||||
if query == "" {
|
||||
customers, err = h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
} else {
|
||||
customers, err = h.queries.SearchCustomersByName(r.Context(), db.SearchCustomersByNameParams{
|
||||
CONCAT: query,
|
||||
CONCAT_2: query,
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hasMore := len(customers) > limit
|
||||
if hasMore {
|
||||
customers = customers[:limit]
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Customers": customers,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Product page handlers
|
||||
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
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) ProductsNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Product": db.Product{},
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) ProductsShow(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid product ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
product, err := h.queries.GetProduct(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Product not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Product": product,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/show.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) ProductsEdit(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid product ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
product, err := h.queries.GetProduct(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Product not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Product": product,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// Similar to CustomersSearch but for products
|
||||
data := map[string]interface{}{
|
||||
"Products": []db.Product{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.tmpl.Render(w, "products/table.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Purchase Order page handlers
|
||||
func (h *PageHandler) PurchaseOrdersIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrders": []db.PurchaseOrder{},
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) PurchaseOrdersNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": db.PurchaseOrder{},
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) PurchaseOrdersShow(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Purchase order not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": purchaseOrder,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/show.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Purchase order not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrder": purchaseOrder,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) PurchaseOrdersSearch(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"PurchaseOrders": []db.PurchaseOrder{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.tmpl.Render(w, "purchase-orders/table.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Enquiry page handlers
|
||||
func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if val, err := strconv.Atoi(p); err == nil && val > 0 {
|
||||
page = val
|
||||
}
|
||||
}
|
||||
|
||||
limit := 150
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var enquiries interface{}
|
||||
var err error
|
||||
var hasMore bool
|
||||
|
||||
// Check if we want archived enquiries
|
||||
if r.URL.Query().Get("archived") == "true" {
|
||||
archivedEnquiries, err := h.queries.ListArchivedEnquiries(r.Context(), db.ListArchivedEnquiriesParams{
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err == nil {
|
||||
hasMore = len(archivedEnquiries) > limit
|
||||
if hasMore {
|
||||
archivedEnquiries = archivedEnquiries[:limit]
|
||||
}
|
||||
enquiries = archivedEnquiries
|
||||
}
|
||||
} else {
|
||||
activeEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err == nil {
|
||||
hasMore = len(activeEnquiries) > limit
|
||||
if hasMore {
|
||||
activeEnquiries = activeEnquiries[:limit]
|
||||
}
|
||||
enquiries = activeEnquiries
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get status list for dropdown and CSS classes
|
||||
statuses, err := h.queries.GetAllStatuses(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Enquiries": enquiries,
|
||||
"Statuses": statuses,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) EnquiriesNew(w http.ResponseWriter, r *http.Request) {
|
||||
// Get required form data
|
||||
statuses, err := h.queries.GetAllStatuses(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
principles, err := h.queries.GetAllPrinciples(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
states, err := h.queries.GetAllStates(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
countries, err := h.queries.GetAllCountries(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Enquiry": db.Enquiry{},
|
||||
"Statuses": statuses,
|
||||
"Principles": principles,
|
||||
"States": states,
|
||||
"Countries": countries,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) EnquiriesShow(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Enquiry not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Enquiry": enquiry,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/show.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) EnquiriesEdit(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Enquiry not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get required form data
|
||||
statuses, err := h.queries.GetAllStatuses(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
principles, err := h.queries.GetAllPrinciples(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
states, err := h.queries.GetAllStates(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
countries, err := h.queries.GetAllCountries(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Enquiry": enquiry,
|
||||
"Statuses": statuses,
|
||||
"Principles": principles,
|
||||
"States": states,
|
||||
"Countries": countries,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("search")
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if val, err := strconv.Atoi(p); err == nil && val > 0 {
|
||||
page = val
|
||||
}
|
||||
}
|
||||
|
||||
limit := 150
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var enquiries interface{}
|
||||
var hasMore bool
|
||||
|
||||
if query == "" {
|
||||
// If no search query, return regular list
|
||||
regularEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hasMore = len(regularEnquiries) > limit
|
||||
if hasMore {
|
||||
regularEnquiries = regularEnquiries[:limit]
|
||||
}
|
||||
enquiries = regularEnquiries
|
||||
} else {
|
||||
searchResults, err := h.queries.SearchEnquiries(r.Context(), db.SearchEnquiriesParams{
|
||||
CONCAT: query,
|
||||
CONCAT_2: query,
|
||||
CONCAT_3: query,
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hasMore = len(searchResults) > limit
|
||||
if hasMore {
|
||||
searchResults = searchResults[:limit]
|
||||
}
|
||||
enquiries = searchResults
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Enquiries": enquiries,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Document page handlers
|
||||
func (h *PageHandler) DocumentsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if val, err := strconv.Atoi(p); err == nil && val > 0 {
|
||||
page = val
|
||||
}
|
||||
}
|
||||
|
||||
// Get document type filter
|
||||
docType := r.URL.Query().Get("type")
|
||||
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var documents interface{}
|
||||
var err error
|
||||
|
||||
if docType != "" {
|
||||
documents, err = h.queries.ListDocumentsByType(r.Context(), db.ListDocumentsByTypeParams{
|
||||
Type: db.DocumentsType(docType),
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
} else {
|
||||
documents, err = h.queries.ListDocuments(r.Context(), db.ListDocumentsParams{
|
||||
Limit: int32(limit + 1),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get users list for display names (if needed)
|
||||
users, err := h.queries.GetAllUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Documents": documents,
|
||||
"Users": users,
|
||||
"Page": page,
|
||||
"DocType": docType,
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "documents/index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) DocumentsShow(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid document ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.queries.GetDocumentWithUser(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
log.Printf("Error fetching document %d: %v", id, err)
|
||||
http.Error(w, "Document not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Document": document,
|
||||
}
|
||||
|
||||
if err := h.tmpl.Render(w, "documents/show.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) DocumentsSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// query := r.URL.Query().Get("search") // TODO: Use when search is implemented
|
||||
|
||||
var documents interface{}
|
||||
var err error
|
||||
|
||||
// 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),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Documents": documents,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid document ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.queries.GetDocumentWithUser(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
http.Error(w, "Document not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Load line items for this document
|
||||
lineItems, err := h.queries.ListLineItemsByDocument(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
log.Printf("Error loading line items for document %d: %v", id, err)
|
||||
// Don't fail the entire page if line items can't be loaded
|
||||
lineItems = []db.LineItem{}
|
||||
}
|
||||
|
||||
// Prepare data based on document type
|
||||
data := map[string]interface{}{
|
||||
"Document": document,
|
||||
"DocType": string(document.Type),
|
||||
"LineItems": lineItems,
|
||||
}
|
||||
|
||||
// Add document type specific data
|
||||
switch document.Type {
|
||||
case db.DocumentsTypeQuote:
|
||||
// For quotes, we might need to load enquiry data
|
||||
if document.CmcReference != "" {
|
||||
// The CmcReference for quotes is the enquiry title
|
||||
data["EnquiryTitle"] = document.CmcReference
|
||||
}
|
||||
|
||||
case db.DocumentsTypeInvoice:
|
||||
// For invoices, load job and customer data if needed
|
||||
data["ShowPaymentButton"] = true
|
||||
|
||||
case db.DocumentsTypePurchaseOrder:
|
||||
// For purchase orders, load principle data if needed
|
||||
|
||||
case db.DocumentsTypeOrderAck:
|
||||
// For order acknowledgements, load job data if needed
|
||||
|
||||
case db.DocumentsTypePackingList:
|
||||
// For packing lists, load job data if needed
|
||||
}
|
||||
|
||||
// Render the appropriate template
|
||||
if err := h.tmpl.Render(w, "documents/view.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
|
||||
tm := &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
|
||||
// Define template functions
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": formatDate,
|
||||
"truncate": truncate,
|
||||
"currency": formatCurrency,
|
||||
}
|
||||
|
||||
// Load all templates
|
||||
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts/*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templatesDir, "partials/*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load page templates
|
||||
pages := []string{
|
||||
"customers/index.html",
|
||||
"customers/show.html",
|
||||
"customers/form.html",
|
||||
"customers/table.html",
|
||||
"products/index.html",
|
||||
"products/show.html",
|
||||
"products/form.html",
|
||||
"products/table.html",
|
||||
"purchase-orders/index.html",
|
||||
"purchase-orders/show.html",
|
||||
"purchase-orders/form.html",
|
||||
"purchase-orders/table.html",
|
||||
"enquiries/index.html",
|
||||
"enquiries/show.html",
|
||||
"enquiries/form.html",
|
||||
"enquiries/table.html",
|
||||
"documents/index.html",
|
||||
"documents/show.html",
|
||||
"documents/table.html",
|
||||
"documents/view.html",
|
||||
"documents/quote-view.html",
|
||||
"documents/invoice-view.html",
|
||||
"documents/purchase-order-view.html",
|
||||
"documents/orderack-view.html",
|
||||
"documents/packinglist-view.html",
|
||||
"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)
|
||||
tablePath := filepath.Join(templatesDir, dir, "table.html")
|
||||
// Check if table file exists before adding it
|
||||
if _, err := os.Stat(tablePath); err == nil {
|
||||
files = append(files, tablePath)
|
||||
}
|
||||
}
|
||||
|
||||
// For documents view page, include all document type elements
|
||||
if page == "documents/view.html" {
|
||||
docElements := []string{
|
||||
"documents/quote-view.html",
|
||||
"documents/invoice-view.html",
|
||||
"documents/purchase-order-view.html",
|
||||
"documents/orderack-view.html",
|
||||
"documents/packinglist-view.html",
|
||||
}
|
||||
for _, elem := range docElements {
|
||||
elemPath := filepath.Join(templatesDir, elem)
|
||||
if _, err := os.Stat(elemPath); err == nil {
|
||||
files = append(files, elemPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := template.New(filepath.Base(page)).Funcs(funcMap).ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tm.templates[page] = tmpl
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
func (tm *TemplateManager) Render(w io.Writer, name string, data interface{}) error {
|
||||
tmpl, ok := tm.templates[name]
|
||||
if !ok {
|
||||
return template.New("error").Execute(w, "Template not found")
|
||||
}
|
||||
|
||||
return tmpl.ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
func (tm *TemplateManager) RenderPartial(w io.Writer, templateFile, templateName string, data interface{}) error {
|
||||
tmpl, ok := tm.templates[templateFile]
|
||||
if !ok {
|
||||
return template.New("error").Execute(w, "Template not found")
|
||||
}
|
||||
|
||||
return tmpl.ExecuteTemplate(w, templateName, data)
|
||||
}
|
||||
|
||||
// Template helper functions
|
||||
func formatDate(t interface{}) string {
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006-01-02")
|
||||
case string:
|
||||
if tm, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
|
||||
return tm.Format("2006-01-02")
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func formatCurrency(amount float64) string {
|
||||
return fmt.Sprintf("$%.2f", amount)
|
||||
}
|
||||
BIN
go-app/server
BIN
go-app/server
Binary file not shown.
|
|
@ -1,109 +0,0 @@
|
|||
/* Custom styles for CMC Sales */
|
||||
|
||||
/* Loading spinner */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.htmx-settling {
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Table hover effects */
|
||||
.table.is-hoverable tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Form improvements */
|
||||
.field:not(:last-child) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Notification animations */
|
||||
.notification {
|
||||
animation: slideIn 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Breadcrumb improvements */
|
||||
.breadcrumb {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.card:hover {
|
||||
box-shadow: 0 8px 16px rgba(10,10,10,.1);
|
||||
transition: box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
/* Button loading state */
|
||||
.button.is-loading::after {
|
||||
border-color: transparent transparent #fff #fff !important;
|
||||
}
|
||||
|
||||
/* Responsive improvements */
|
||||
@media screen and (max-width: 768px) {
|
||||
.level-left,
|
||||
.level-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.level-right {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer stick to bottom */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Search box improvements */
|
||||
.control.has-icons-left .input:focus ~ .icon {
|
||||
color: #3273dc;
|
||||
}
|
||||
|
||||
/* Table action buttons */
|
||||
.table td .buttons {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Error states */
|
||||
.input.is-danger:focus {
|
||||
border-color: #ff3860;
|
||||
box-shadow: 0 0 0 0.125em rgba(255,56,96,.25);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
|
||||
{{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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="content has-text-centered">
|
||||
<p>
|
||||
<strong>CMC Sales</strong> © 2024 CMC Technologies
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
|
||||
<!-- Navbar toggle script -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
||||
|
||||
$navbarBurgers.forEach( el => {
|
||||
el.addEventListener('click', () => {
|
||||
const target = el.dataset.target;
|
||||
const $target = document.getElementById(target);
|
||||
el.classList.toggle('is-active');
|
||||
$target.classList.toggle('is-active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
25
go/.air.toml
Normal file
25
go/.air.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Air configuration for Go hot reload
|
||||
root = "./"
|
||||
cmd = ["air"]
|
||||
|
||||
[build]
|
||||
cmd = "go build -o server cmd/server/main.go"
|
||||
bin = "server"
|
||||
include = ["cmd", "internal", "go.mod", "go.sum"]
|
||||
exclude = ["bin", "tmp", ".env"]
|
||||
delay = 1000
|
||||
log = "stdout"
|
||||
kill_on_error = true
|
||||
|
||||
color = true
|
||||
|
||||
[[watch]]
|
||||
path = "templates"
|
||||
reload = true
|
||||
[[watch]]
|
||||
path = "static"
|
||||
reload = true
|
||||
[[watch]]
|
||||
path = "tmp"
|
||||
reload = false
|
||||
mkdir = true
|
||||
12
go/.env.example
Normal file
12
go/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# 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
|
||||
|
||||
# Server configuration
|
||||
PORT=8080
|
||||
70
go/MIGRATIONS.md
Normal file
70
go/MIGRATIONS.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Database Migrations with Goose
|
||||
|
||||
This document explains how to use goose for database migrations in the CMC Sales Go application.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install goose**:
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
2. **Configure database connection**:
|
||||
```bash
|
||||
cp goose.env.example goose.env
|
||||
# Edit goose.env with your database credentials
|
||||
```
|
||||
|
||||
## Migration Commands
|
||||
|
||||
### Run Migrations
|
||||
```bash
|
||||
# Run all pending migrations
|
||||
make migrate
|
||||
|
||||
# Check migration status
|
||||
make migrate-status
|
||||
```
|
||||
|
||||
### Rollback Migrations
|
||||
```bash
|
||||
# Rollback the last migration
|
||||
make migrate-down
|
||||
```
|
||||
|
||||
### Create New Migrations
|
||||
```bash
|
||||
# Create a new migration file
|
||||
make migrate-create name=add_new_feature
|
||||
```
|
||||
|
||||
## Migration Structure
|
||||
|
||||
Migrations are stored in `sql/migrations/` and follow this naming convention:
|
||||
- `001_add_gmail_fields.sql`
|
||||
- `002_add_new_feature.sql`
|
||||
|
||||
Each migration file contains:
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- Your upgrade SQL here
|
||||
|
||||
-- +goose Down
|
||||
-- Your rollback SQL here
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `goose.env` - Database connection settings (gitignored)
|
||||
- `goose.env.example` - Template for goose.env
|
||||
|
||||
## Current Migrations
|
||||
|
||||
1. **001_add_gmail_fields.sql** - Adds Gmail integration fields to emails and email_attachments tables
|
||||
|
||||
## Tips
|
||||
|
||||
- Always test migrations on a backup database first
|
||||
- Use `make migrate-status` to check current state
|
||||
- Migrations are atomic - if one fails, none are applied
|
||||
- Each migration should be reversible with a corresponding Down section
|
||||
124
go/Makefile
Normal file
124
go/Makefile
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install dependencies
|
||||
@echo "Setting up private module configuration..."
|
||||
go env -w GOPRIVATE=code.springupsoftware.com
|
||||
go mod download
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
|
||||
.PHONY: sqlc
|
||||
sqlc: ## Generate Go code from SQL queries
|
||||
sqlc generate
|
||||
|
||||
.PHONY: build
|
||||
build: sqlc ## Build the application
|
||||
go build -o bin/server cmd/server/main.go
|
||||
go build -o bin/vault cmd/vault/main.go
|
||||
|
||||
.PHONY: build-server
|
||||
build-server: sqlc ## Build only the server
|
||||
go build -o bin/server cmd/server/main.go
|
||||
|
||||
.PHONY: build-vault
|
||||
build-vault: ## Build only the vault command
|
||||
go build -o bin/vault cmd/vault/main.go
|
||||
|
||||
.PHONY: run
|
||||
run: ## Run the application
|
||||
go run cmd/server/main.go
|
||||
|
||||
.PHONY: run-vault
|
||||
run-vault: ## Run the vault command
|
||||
go run cmd/vault/main.go
|
||||
|
||||
.PHONY: dev
|
||||
dev: sqlc ## Run the application with hot reload (requires air)
|
||||
air
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run tests
|
||||
go test -v ./...
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
rm -rf bin/
|
||||
rm -rf internal/cmc/db/*.go
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: ## Build Docker image
|
||||
docker build -t cmc-go:latest -f Dockerfile.go .
|
||||
|
||||
.PHONY: docker-run
|
||||
docker-run: ## Run application in Docker
|
||||
docker run --rm -p 8080:8080 --network=host cmc-go:latest
|
||||
|
||||
.PHONY: dbshell
|
||||
dbshell: ## Connect to MariaDB database interactively
|
||||
@echo "Connecting to MariaDB..."
|
||||
@if [ -z "$$DB_PASSWORD" ]; then \
|
||||
echo "Reading password from docker-compose environment..."; \
|
||||
docker compose exec db mariadb -u cmc -p cmc; \
|
||||
else \
|
||||
docker compose exec -e MYSQL_PWD="$$DB_PASSWORD" db mariadb -u cmc cmc; \
|
||||
fi
|
||||
|
||||
.PHONY: dbshell-root
|
||||
dbshell-root: ## Connect to MariaDB as root user
|
||||
@echo "Connecting to MariaDB as root..."
|
||||
@if [ -z "$$DB_ROOT_PASSWORD" ]; then \
|
||||
echo "Please set DB_ROOT_PASSWORD environment variable"; \
|
||||
exit 1; \
|
||||
else \
|
||||
docker compose exec -e MYSQL_PWD="$$DB_ROOT_PASSWORD" db mariadb -u root; \
|
||||
fi
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Run database migrations
|
||||
@echo "Running database migrations..."
|
||||
@if [ -f goose.env ]; then \
|
||||
export $$(cat goose.env | xargs) && goose up; \
|
||||
else \
|
||||
echo "Error: goose.env file not found"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: migrate-down
|
||||
migrate-down: ## Rollback last migration
|
||||
@echo "Rolling back last migration..."
|
||||
@if [ -f goose.env ]; then \
|
||||
export $$(cat goose.env | xargs) && goose down; \
|
||||
else \
|
||||
echo "Error: goose.env file not found"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: migrate-status
|
||||
migrate-status: ## Show migration status
|
||||
@echo "Migration status:"
|
||||
@if [ -f goose.env ]; then \
|
||||
export $$(cat goose.env | xargs) && goose status; \
|
||||
else \
|
||||
echo "Error: goose.env file not found"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: migrate-create
|
||||
migrate-create: ## Create a new migration file (use: make migrate-create name=add_new_table)
|
||||
@if [ -z "$(name)" ]; then \
|
||||
echo "Error: Please provide a migration name. Usage: make migrate-create name=add_new_table"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Creating new migration: $(name)"
|
||||
@if [ -f goose.env ]; then \
|
||||
export $$(cat goose.env | xargs) && goose create $(name) sql; \
|
||||
else \
|
||||
echo "Error: goose.env file not found"; \
|
||||
exit 1; \
|
||||
fi
|
||||
299
go/cmd/server/main.go
Normal file
299
go/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
|
||||
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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found")
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
dbHost := getEnv("DB_HOST", "localhost")
|
||||
dbPort := getEnv("DB_PORT", "3306")
|
||||
dbUser := getEnv("DB_USER", "cmc")
|
||||
dbPass := getEnv("DB_PASSWORD", "xVRQI&cA?7AU=hqJ!%au")
|
||||
dbName := getEnv("DB_NAME", "cmc")
|
||||
|
||||
// Connect to database
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName)
|
||||
database, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Test database connection
|
||||
if err := database.Ping(); err != nil {
|
||||
log.Fatal("Failed to ping database:", err)
|
||||
}
|
||||
|
||||
log.Println("Connected to database successfully")
|
||||
|
||||
// Create queries instance
|
||||
queries := db.New(database)
|
||||
|
||||
// Initialize template manager
|
||||
tmpl, err := templates.NewTemplateManager("templates")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize templates:", err)
|
||||
}
|
||||
|
||||
// Initialize email service
|
||||
emailService := email.GetEmailService()
|
||||
|
||||
// Load handlers
|
||||
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
|
||||
attachmentHandler := attachments.NewAttachmentHandler(queries)
|
||||
|
||||
// Setup routes
|
||||
r := mux.NewRouter()
|
||||
goRouter := r.PathPrefix("/go").Subrouter()
|
||||
|
||||
// Static files
|
||||
goRouter.PathPrefix("/static/").Handler(http.StripPrefix("/go/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// PDF files
|
||||
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
|
||||
|
||||
// Quote routes
|
||||
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
|
||||
goRouter.HandleFunc("/quotes/send-reminder", quoteHandler.SendManualReminder).Methods("POST")
|
||||
goRouter.HandleFunc("/quotes/disable-reminders", quoteHandler.DisableReminders).Methods("POST")
|
||||
goRouter.HandleFunc("/quotes/enable-reminders", quoteHandler.EnableReminders).Methods("POST")
|
||||
|
||||
// Attachment routes
|
||||
goRouter.HandleFunc("/attachments/upload", attachmentHandler.Create).Methods("POST")
|
||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
||||
|
||||
// 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")
|
||||
*/
|
||||
|
||||
// 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"))
|
||||
})
|
||||
|
||||
/* 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")
|
||||
log.Printf("Starting server on port %s", port)
|
||||
if err := http.ListenAndServe(":"+port, r); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
142
go/cmd/vault/README.md
Normal file
142
go/cmd/vault/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Vault Email Processor - Smart Proxy
|
||||
|
||||
This is a Go rewrite of the PHP vault.php script that processes emails for the CMC Sales system. It now supports three modes: local file processing, Gmail indexing, and HTTP streaming proxy.
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Gmail Integration**: Index Gmail emails without downloading
|
||||
2. **Smart Proxy**: Stream email content on-demand without storing to disk
|
||||
3. **No ripmime dependency**: Uses the enmime Go library for MIME parsing
|
||||
4. **Better error handling**: Proper error handling and database transactions
|
||||
5. **Type safety**: Strongly typed Go structures
|
||||
6. **Modern email parsing**: Uses enmime for robust email parsing
|
||||
|
||||
## Operating Modes
|
||||
|
||||
### 1. Local Mode (Original functionality)
|
||||
Processes emails from local filesystem directories.
|
||||
|
||||
```bash
|
||||
./vault --mode=local \
|
||||
--emaildir=/var/www/emails \
|
||||
--vaultdir=/var/www/vaultmsgs/new \
|
||||
--processeddir=/var/www/vaultmsgs/cur \
|
||||
--dbhost=127.0.0.1 \
|
||||
--dbuser=cmc \
|
||||
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
|
||||
--dbname=cmc
|
||||
```
|
||||
|
||||
**Note:** The `emaildir` should be set to `/var/www/emails` to match the legacy directory structure expected by the CakePHP application.
|
||||
|
||||
### 2. Gmail Index Mode
|
||||
Indexes Gmail emails without downloading content. Creates database references only.
|
||||
|
||||
```bash
|
||||
./vault --mode=index \
|
||||
--gmail-query="is:unread" \
|
||||
--credentials=credentials.json \
|
||||
--token=token.json \
|
||||
--dbhost=127.0.0.1 \
|
||||
--dbuser=cmc \
|
||||
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
|
||||
--dbname=cmc
|
||||
```
|
||||
|
||||
### 3. HTTP Server Mode
|
||||
Runs an HTTP server that streams Gmail content on-demand.
|
||||
|
||||
```bash
|
||||
./vault --mode=serve \
|
||||
--port=8080 \
|
||||
--credentials=credentials.json \
|
||||
--token=token.json \
|
||||
--dbhost=127.0.0.1 \
|
||||
--dbuser=cmc \
|
||||
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
|
||||
--dbname=cmc
|
||||
```
|
||||
|
||||
## Gmail Setup
|
||||
|
||||
1. Enable Gmail API in Google Cloud Console
|
||||
2. Create OAuth 2.0 credentials
|
||||
3. Download credentials as `credentials.json`
|
||||
4. Run vault in any Gmail mode - it will prompt for authorization
|
||||
5. Token will be saved as `token.json` for future use
|
||||
|
||||
## API Endpoints (Server Mode)
|
||||
|
||||
- `GET /api/emails` - List indexed emails (metadata only)
|
||||
- `GET /api/emails/:id` - Get email metadata
|
||||
- `GET /api/emails/:id/content` - Stream email HTML/text from Gmail
|
||||
- `GET /api/emails/:id/attachments` - List attachment metadata
|
||||
- `GET /api/emails/:id/attachments/:attachmentId` - Stream attachment from Gmail
|
||||
- `GET /api/emails/:id/raw` - Stream raw email (for email clients)
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
Required migrations for Gmail support:
|
||||
|
||||
```sql
|
||||
ALTER TABLE emails
|
||||
ADD COLUMN gmail_message_id VARCHAR(255) UNIQUE,
|
||||
ADD COLUMN gmail_thread_id VARCHAR(255),
|
||||
ADD COLUMN is_downloaded BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN raw_headers TEXT;
|
||||
|
||||
CREATE INDEX idx_gmail_message_id ON emails(gmail_message_id);
|
||||
|
||||
ALTER TABLE email_attachments
|
||||
ADD COLUMN gmail_attachment_id VARCHAR(255),
|
||||
ADD COLUMN gmail_message_id VARCHAR(255);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Smart Proxy Benefits
|
||||
- **No Disk Storage**: Emails/attachments streamed directly from Gmail
|
||||
- **Low Storage Footprint**: Only metadata stored in database
|
||||
- **Fresh Content**: Always serves latest version from Gmail
|
||||
- **Scalable**: No file management overhead
|
||||
- **On-Demand**: Content fetched only when requested
|
||||
|
||||
### Processing Flow
|
||||
1. **Index Mode**: Scans Gmail, stores metadata, creates associations
|
||||
2. **Server Mode**: Receives HTTP requests, fetches from Gmail, streams to client
|
||||
3. **Local Mode**: Original file-based processing (backwards compatible)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -o vault cmd/vault/main.go
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- github.com/jhillyerd/enmime - MIME email parsing
|
||||
- github.com/google/uuid - UUID generation
|
||||
- github.com/go-sql-driver/mysql - MySQL driver
|
||||
- github.com/gorilla/mux - HTTP router
|
||||
- golang.org/x/oauth2 - OAuth2 support
|
||||
- google.golang.org/api/gmail/v1 - Gmail API client
|
||||
|
||||
## Database Tables Used
|
||||
|
||||
- emails - Main email records with Gmail metadata
|
||||
- email_recipients - To/CC recipients
|
||||
- email_attachments - Attachment metadata (no file storage)
|
||||
- emails_enquiries - Email to enquiry associations
|
||||
- emails_invoices - Email to invoice associations
|
||||
- emails_purchase_orders - Email to PO associations
|
||||
- emails_jobs - Email to job associations
|
||||
- users - System users
|
||||
- enquiries, invoices, purchase_orders, jobs - For identifier matching
|
||||
|
||||
## Gmail Query Examples
|
||||
|
||||
- `is:unread` - Unread emails
|
||||
- `newer_than:1d` - Emails from last 24 hours
|
||||
- `from:customer@example.com` - From specific sender
|
||||
- `subject:invoice` - Subject contains "invoice"
|
||||
- `has:attachment` - Emails with attachments
|
||||
1300
go/cmd/vault/main.go
Normal file
1300
go/cmd/vault/main.go
Normal file
File diff suppressed because it is too large
Load diff
22
go/go.mod
Normal file
22
go/go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
module code.springupsoftware.com/cmc/cmc-sales
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
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/text v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
)
|
||||
73
go/go.sum
Normal file
73
go/go.sum
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
|
||||
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
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/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/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/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/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.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=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
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=
|
||||
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=
|
||||
3
go/goose.env.example
Normal file
3
go/goose.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
GOOSE_DRIVER=mysql
|
||||
GOOSE_DBSTRING=username:password@tcp(localhost:3306)/database?parseTime=true
|
||||
GOOSE_MIGRATION_DIR=sql/migrations
|
||||
109
go/internal/cmc/auth/auth.go
Normal file
109
go/internal/cmc/auth/auth.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContextKey is a type for context keys to avoid collisions
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
// ContextKeyUsername is the context key for storing the authenticated username
|
||||
ContextKeyUsername ContextKey = "username"
|
||||
// ContextKeyAuthUser is the context key for storing the raw auth username
|
||||
ContextKeyAuthUser ContextKey = "auth_user"
|
||||
// ContextKeyAuthPass is the context key for storing the raw auth password
|
||||
ContextKeyAuthPass ContextKey = "auth_pass"
|
||||
)
|
||||
|
||||
// Credentials holds authentication credentials
|
||||
type Credentials struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// GetCredentials extracts authentication credentials from the request
|
||||
// This is the single point where auth mechanism is defined
|
||||
func GetCredentials(r *http.Request) (*Credentials, bool) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" {
|
||||
return nil, false
|
||||
}
|
||||
return &Credentials{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}, true
|
||||
}
|
||||
|
||||
// GetUsername extracts and formats the username for display
|
||||
func GetUsername(r *http.Request) string {
|
||||
creds, ok := GetCredentials(r)
|
||||
if !ok {
|
||||
return "Guest"
|
||||
}
|
||||
// Capitalize the username for display
|
||||
return strings.Title(creds.Username)
|
||||
}
|
||||
|
||||
// GetUsernameFromContext retrieves the username from the request context
|
||||
func GetUsernameFromContext(ctx context.Context) string {
|
||||
if username, ok := ctx.Value(ContextKeyUsername).(string); ok {
|
||||
return username
|
||||
}
|
||||
return "Guest"
|
||||
}
|
||||
|
||||
// GetCredentialsFromContext retrieves credentials from the request context
|
||||
func GetCredentialsFromContext(ctx context.Context) (*Credentials, bool) {
|
||||
username, okUser := ctx.Value(ContextKeyAuthUser).(string)
|
||||
password, okPass := ctx.Value(ContextKeyAuthPass).(string)
|
||||
if !okUser || !okPass {
|
||||
return nil, false
|
||||
}
|
||||
return &Credentials{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AddAuthToRequest adds authentication credentials to an HTTP request
|
||||
// This should be used when making authenticated requests to internal services
|
||||
func AddAuthToRequest(req *http.Request, creds *Credentials) {
|
||||
if creds != nil {
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthenticatedRequest creates a new HTTP request with authentication from the source request
|
||||
// This is a convenience method that extracts auth from sourceReq and applies it to the new request
|
||||
func NewAuthenticatedRequest(method, url string, sourceReq *http.Request) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Copy authentication from source request
|
||||
if creds, ok := GetCredentials(sourceReq); ok {
|
||||
AddAuthToRequest(req, creds)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Middleware adds authentication information to the request context
|
||||
// This allows handlers to access auth info without parsing headers repeatedly
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
creds, ok := GetCredentials(r)
|
||||
if ok {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ContextKeyUsername, strings.Title(creds.Username))
|
||||
ctx = context.WithValue(ctx, ContextKeyAuthUser, creds.Username)
|
||||
ctx = context.WithValue(ctx, ContextKeyAuthPass, creds.Password)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
409
go/internal/cmc/db/models.go
Normal file
409
go/internal/cmc/db/models.go
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentsType string
|
||||
|
||||
const (
|
||||
DocumentsTypeQuote DocumentsType = "quote"
|
||||
DocumentsTypeInvoice DocumentsType = "invoice"
|
||||
DocumentsTypePurchaseOrder DocumentsType = "purchaseOrder"
|
||||
DocumentsTypeOrderAck DocumentsType = "orderAck"
|
||||
DocumentsTypePackingList DocumentsType = "packingList"
|
||||
)
|
||||
|
||||
func (e *DocumentsType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = DocumentsType(s)
|
||||
case string:
|
||||
*e = DocumentsType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for DocumentsType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullDocumentsType struct {
|
||||
DocumentsType DocumentsType `json:"documents_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if DocumentsType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullDocumentsType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.DocumentsType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.DocumentsType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullDocumentsType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.DocumentsType), nil
|
||||
}
|
||||
|
||||
type UsersAccessLevel string
|
||||
|
||||
const (
|
||||
UsersAccessLevelAdmin UsersAccessLevel = "admin"
|
||||
UsersAccessLevelManager UsersAccessLevel = "manager"
|
||||
UsersAccessLevelUser UsersAccessLevel = "user"
|
||||
UsersAccessLevelNone UsersAccessLevel = "none"
|
||||
)
|
||||
|
||||
func (e *UsersAccessLevel) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = UsersAccessLevel(s)
|
||||
case string:
|
||||
*e = UsersAccessLevel(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for UsersAccessLevel: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullUsersAccessLevel struct {
|
||||
UsersAccessLevel UsersAccessLevel `json:"users_access_level"`
|
||||
Valid bool `json:"valid"` // Valid is true if UsersAccessLevel is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullUsersAccessLevel) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.UsersAccessLevel, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.UsersAccessLevel.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullUsersAccessLevel) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.UsersAccessLevel), nil
|
||||
}
|
||||
|
||||
type UsersType string
|
||||
|
||||
const (
|
||||
UsersTypePrinciple UsersType = "principle"
|
||||
UsersTypeContact UsersType = "contact"
|
||||
UsersTypeUser UsersType = "user"
|
||||
)
|
||||
|
||||
func (e *UsersType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = UsersType(s)
|
||||
case string:
|
||||
*e = UsersType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for UsersType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullUsersType struct {
|
||||
UsersType UsersType `json:"users_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if UsersType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullUsersType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.UsersType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.UsersType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullUsersType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.UsersType), nil
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
ID int32 `json:"id"`
|
||||
// Descriptive Name for this address
|
||||
Name string `json:"name"`
|
||||
// street or unit number and street name
|
||||
Address string `json:"address"`
|
||||
// Suburb / City
|
||||
City string `json:"city"`
|
||||
// State foreign Key
|
||||
StateID int32 `json:"state_id"`
|
||||
// Country foreign Key
|
||||
CountryID int32 `json:"country_id"`
|
||||
// Customer foreign key
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
// either bill / ship / both
|
||||
Type string `json:"type"`
|
||||
Postcode string `json:"postcode"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID int32 `json:"id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
Created time.Time `json:"created"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"filename"`
|
||||
File string `json:"file"`
|
||||
Type string `json:"type"`
|
||||
Size int32 `json:"size"`
|
||||
Description string `json:"description"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
type Box struct {
|
||||
ID int32 `json:"id"`
|
||||
ShipmentID int32 `json:"shipment_id"`
|
||||
Length string `json:"length"`
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
}
|
||||
|
||||
type Country struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID int32 `json:"id"`
|
||||
// Company Name
|
||||
Name string `json:"name"`
|
||||
TradingName string `json:"trading_name"`
|
||||
Abn sql.NullString `json:"abn"`
|
||||
Created time.Time `json:"created"`
|
||||
Notes string `json:"notes"`
|
||||
DiscountPricingPolicies string `json:"discount_pricing_policies"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
CustomerCategoryID int32 `json:"customer_category_id"`
|
||||
Url string `json:"url"`
|
||||
CountryID int32 `json:"country_id"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID int32 `json:"id"`
|
||||
Type DocumentsType `json:"type"`
|
||||
Created time.Time `json:"created"`
|
||||
UserID int32 `json:"user_id"`
|
||||
DocPageCount int32 `json:"doc_page_count"`
|
||||
// Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries
|
||||
CmcReference string `json:"cmc_reference"`
|
||||
PdfFilename string `json:"pdf_filename"`
|
||||
PdfCreatedAt time.Time `json:"pdf_created_at"`
|
||||
PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
|
||||
ShippingDetails sql.NullString `json:"shipping_details"`
|
||||
Revision int32 `json:"revision"`
|
||||
BillTo sql.NullString `json:"bill_to"`
|
||||
ShipTo sql.NullString `json:"ship_to"`
|
||||
EmailSentAt time.Time `json:"email_sent_at"`
|
||||
EmailSentByUserID int32 `json:"email_sent_by_user_id"`
|
||||
}
|
||||
|
||||
type Enquiry struct {
|
||||
ID int32 `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Submitted sql.NullTime `json:"submitted"`
|
||||
// enquirynumber
|
||||
Title string `json:"title"`
|
||||
UserID int32 `json:"user_id"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
ContactID int32 `json:"contact_id"`
|
||||
ContactUserID int32 `json:"contact_user_id"`
|
||||
StateID int32 `json:"state_id"`
|
||||
CountryID int32 `json:"country_id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
StatusID int32 `json:"status_id"`
|
||||
Comments string `json:"comments"`
|
||||
// Numeric Principle Code
|
||||
PrincipleCode int32 `json:"principle_code"`
|
||||
// GST applicable on this enquiry
|
||||
Gst bool `json:"gst"`
|
||||
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
|
||||
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
|
||||
// has the enquired been posted
|
||||
Posted bool `json:"posted"`
|
||||
EmailCount int32 `json:"email_count"`
|
||||
InvoiceCount int32 `json:"invoice_count"`
|
||||
JobCount int32 `json:"job_count"`
|
||||
QuoteCount int32 `json:"quote_count"`
|
||||
Archived int8 `json:"archived"`
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
ID int32 `json:"id"`
|
||||
// CMC Invoice Number String
|
||||
Title string `json:"title"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
ID int32 `json:"id"`
|
||||
ItemNumber string `json:"item_number"`
|
||||
Option bool `json:"option"`
|
||||
Quantity string `json:"quantity"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
ProductID sql.NullInt32 `json:"product_id"`
|
||||
HasTextPrices bool `json:"has_text_prices"`
|
||||
HasPrice int8 `json:"has_price"`
|
||||
UnitPriceString sql.NullString `json:"unit_price_string"`
|
||||
GrossPriceString sql.NullString `json:"gross_price_string"`
|
||||
CostingID sql.NullInt32 `json:"costing_id"`
|
||||
// Either fill this in or have a costing_id associated with this record
|
||||
GrossUnitPrice sql.NullString `json:"gross_unit_price"`
|
||||
NetUnitPrice sql.NullString `json:"net_unit_price"`
|
||||
DiscountPercent sql.NullString `json:"discount_percent"`
|
||||
DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
|
||||
DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
|
||||
GrossPrice sql.NullString `json:"gross_price"`
|
||||
NetPrice sql.NullString `json:"net_price"`
|
||||
}
|
||||
|
||||
type Principle struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName sql.NullString `json:"short_name"`
|
||||
Code int32 `json:"code"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int32 `json:"id"`
|
||||
// Principle FK
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
ProductCategoryID int32 `json:"product_category_id"`
|
||||
// This must match the Title in the Excel Costing File
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
// Part or model number principle uses to identify this product
|
||||
ModelNumber sql.NullString `json:"model_number"`
|
||||
// %1% - first item, %2% , second item etc
|
||||
ModelNumberFormat sql.NullString `json:"model_number_format"`
|
||||
// Any notes about this product. Note displayed on quotes
|
||||
Notes sql.NullString `json:"notes"`
|
||||
// Stock or Ident
|
||||
Stock bool `json:"stock"`
|
||||
ItemCode string `json:"item_code"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
}
|
||||
|
||||
type PurchaseOrder struct {
|
||||
ID int32 `json:"id"`
|
||||
IssueDate time.Time `json:"issue_date"`
|
||||
DispatchDate time.Time `json:"dispatch_date"`
|
||||
DateArrived time.Time `json:"date_arrived"`
|
||||
// CMC PONumber
|
||||
Title string `json:"title"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
PrincipleReference string `json:"principle_reference"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
CurrencyID sql.NullInt32 `json:"currency_id"`
|
||||
OrderedFrom string `json:"ordered_from"`
|
||||
Description string `json:"description"`
|
||||
DispatchBy string `json:"dispatch_by"`
|
||||
DeliverTo string `json:"deliver_to"`
|
||||
ShippingInstructions string `json:"shipping_instructions"`
|
||||
JobsText string `json:"jobs_text"`
|
||||
FreightForwarderText string `json:"freight_forwarder_text"`
|
||||
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"`
|
||||
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
|
||||
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
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"`
|
||||
Shortform sql.NullString `json:"shortform"`
|
||||
Enqform sql.NullString `json:"enqform"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color sql.NullString `json:"color"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
PrincipleID int32 `json:"principle_id"`
|
||||
CustomerID int32 `json:"customer_id"`
|
||||
Type UsersType `json:"type"`
|
||||
AccessLevel UsersAccessLevel `json:"access_level"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
JobTitle string `json:"job_title"`
|
||||
Phone string `json:"phone"`
|
||||
Mobile string `json:"mobile"`
|
||||
Fax string `json:"fax"`
|
||||
PhoneExtension string `json:"phone_extension"`
|
||||
DirectPhone string `json:"direct_phone"`
|
||||
Notes string `json:"notes"`
|
||||
// Added by Vault. May or may not be a real person.
|
||||
ByVault bool `json:"by_vault"`
|
||||
// Disregard emails from this address in future.
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Archived sql.NullBool `json:"archived"`
|
||||
PrimaryContact bool `json:"primary_contact"`
|
||||
}
|
||||
136
go/internal/cmc/db/querier.go
Normal file
136
go/internal/cmc/db/querier.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
ArchiveEnquiry(ctx context.Context, id int32) error
|
||||
ArchiveUser(ctx context.Context, id int32) error
|
||||
CountDocuments(ctx context.Context) (int64, error)
|
||||
CountDocumentsByType(ctx context.Context, type_ DocumentsType) (int64, error)
|
||||
CountEnquiries(ctx context.Context) (int64, error)
|
||||
CountEnquiriesByPrinciple(ctx context.Context, principleCode int32) (int64, error)
|
||||
CountEnquiriesByPrincipleAndState(ctx context.Context, arg CountEnquiriesByPrincipleAndStateParams) (int64, error)
|
||||
CountEnquiriesByStatus(ctx context.Context, statusID int32) (int64, error)
|
||||
CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error)
|
||||
CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error)
|
||||
CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error)
|
||||
CreateCountry(ctx context.Context, name string) (sql.Result, error)
|
||||
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
|
||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error)
|
||||
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
|
||||
CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error)
|
||||
CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error)
|
||||
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
|
||||
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
|
||||
CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error)
|
||||
CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
|
||||
DeleteAddress(ctx context.Context, id int32) error
|
||||
DeleteAttachment(ctx context.Context, id int32) error
|
||||
DeleteBox(ctx context.Context, id int32) error
|
||||
DeleteCountry(ctx context.Context, id int32) error
|
||||
DeleteCustomer(ctx context.Context, id int32) error
|
||||
DeleteDocument(ctx context.Context, id int32) error
|
||||
DeleteLineItem(ctx context.Context, id int32) error
|
||||
DeleteProduct(ctx context.Context, id int32) error
|
||||
DeletePurchaseOrder(ctx context.Context, id int32) error
|
||||
DeleteState(ctx context.Context, id int32) error
|
||||
DeleteStatus(ctx context.Context, id int32) error
|
||||
DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error)
|
||||
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
|
||||
GetAddress(ctx context.Context, id int32) (Address, error)
|
||||
GetAllCountries(ctx context.Context) ([]Country, error)
|
||||
GetAllPrinciples(ctx context.Context) ([]Principle, error)
|
||||
GetAllStates(ctx context.Context) ([]State, error)
|
||||
GetAllStatuses(ctx context.Context) ([]GetAllStatusesRow, error)
|
||||
GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error)
|
||||
GetAttachment(ctx context.Context, id int32) (Attachment, error)
|
||||
GetBox(ctx context.Context, id int32) (Box, error)
|
||||
GetCountry(ctx context.Context, id int32) (Country, error)
|
||||
GetCustomer(ctx context.Context, id int32) (Customer, error)
|
||||
GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error)
|
||||
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error)
|
||||
GetDocument(ctx context.Context, id int32) (Document, error)
|
||||
GetDocumentWithUser(ctx context.Context, id int32) (GetDocumentWithUserRow, error)
|
||||
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, arg GetExpiringSoonQuotesOnDayParams) ([]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)
|
||||
GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error)
|
||||
GetPrinciple(ctx context.Context, id int32) (Principle, error)
|
||||
GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error)
|
||||
GetProduct(ctx context.Context, id int32) (Product, error)
|
||||
GetProductByItemCode(ctx context.Context, itemCode string) (Product, error)
|
||||
GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error)
|
||||
GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error)
|
||||
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)
|
||||
GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, 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)
|
||||
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
|
||||
ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error)
|
||||
ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error)
|
||||
ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error)
|
||||
ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error)
|
||||
ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error)
|
||||
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]Document, error)
|
||||
ListDocumentsByType(ctx context.Context, arg ListDocumentsByTypeParams) ([]Document, error)
|
||||
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
|
||||
ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error)
|
||||
ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error)
|
||||
ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error)
|
||||
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
|
||||
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
|
||||
ListStates(ctx context.Context, arg ListStatesParams) ([]State, error)
|
||||
ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error)
|
||||
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
|
||||
SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error)
|
||||
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error)
|
||||
SearchEnquiries(ctx context.Context, arg SearchEnquiriesParams) ([]SearchEnquiriesRow, error)
|
||||
SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error)
|
||||
SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error)
|
||||
UnarchiveEnquiry(ctx context.Context, id int32) error
|
||||
UnarchiveUser(ctx context.Context, id int32) error
|
||||
UpdateAddress(ctx context.Context, arg UpdateAddressParams) error
|
||||
UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error
|
||||
UpdateBox(ctx context.Context, arg UpdateBoxParams) error
|
||||
UpdateCountry(ctx context.Context, arg UpdateCountryParams) error
|
||||
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
|
||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error
|
||||
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
|
||||
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
|
||||
UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error
|
||||
UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error
|
||||
UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error
|
||||
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
|
||||
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
|
||||
UpdateState(ctx context.Context, arg UpdateStateParams) error
|
||||
UpdateStatus(ctx context.Context, arg UpdateStatusParams) error
|
||||
UpdateUser(ctx context.Context, arg UpdateUserParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
582
go/internal/cmc/db/quotes.sql.go
Normal file
582
go/internal/cmc/db/quotes.sql.go
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: quotes.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const disableQuoteReminders = `-- name: DisableQuoteReminders :execresult
|
||||
UPDATE quotes
|
||||
SET reminders_disabled = TRUE,
|
||||
reminders_disabled_at = NOW(),
|
||||
reminders_disabled_by = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type DisableQuoteRemindersParams struct {
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, disableQuoteReminders, arg.RemindersDisabledBy, arg.ID)
|
||||
}
|
||||
|
||||
const enableQuoteReminders = `-- name: EnableQuoteReminders :execresult
|
||||
UPDATE quotes
|
||||
SET reminders_disabled = FALSE,
|
||||
reminders_disabled_at = NULL,
|
||||
reminders_disabled_by = NULL
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, enableQuoteReminders, id)
|
||||
}
|
||||
|
||||
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
|
||||
WITH latest_revision AS (
|
||||
SELECT
|
||||
q.enquiry_id,
|
||||
MAX(d.revision) AS max_revision
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
GROUP BY q.enquiry_id
|
||||
),
|
||||
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,
|
||||
u.email as user_email,
|
||||
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,
|
||||
COALESCE(q.reminders_disabled, FALSE) AS reminders_disabled
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
LEFT 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
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
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
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
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"`
|
||||
RemindersDisabled bool `json:"reminders_disabled"`
|
||||
}
|
||||
|
||||
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.UserEmail,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
&i.CustomerEmail,
|
||||
&i.DateIssued,
|
||||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
&i.RemindersDisabled,
|
||||
); 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 latest_revision AS (
|
||||
SELECT
|
||||
q.enquiry_id,
|
||||
MAX(d.revision) AS max_revision
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
GROUP BY q.enquiry_id
|
||||
),
|
||||
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,
|
||||
u.email as user_email,
|
||||
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
|
||||
LEFT 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
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
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 q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesOnDayParams struct {
|
||||
DATEADD interface{} `json:"DATE_ADD"`
|
||||
DATEADD_2 interface{} `json:"DATE_ADD_2"`
|
||||
}
|
||||
|
||||
type GetExpiringSoonQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
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, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, arg.DATEADD, arg.DATEADD_2)
|
||||
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.UserEmail,
|
||||
&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 getQuoteRemindersDisabled = `-- name: GetQuoteRemindersDisabled :one
|
||||
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
|
||||
FROM quotes
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type GetQuoteRemindersDisabledRow struct {
|
||||
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
|
||||
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getQuoteRemindersDisabled, id)
|
||||
var i GetQuoteRemindersDisabledRow
|
||||
err := row.Scan(&i.RemindersDisabled, &i.RemindersDisabledAt, &i.RemindersDisabledBy)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
|
||||
WITH latest_revision AS (
|
||||
SELECT
|
||||
q.enquiry_id,
|
||||
MAX(d.revision) AS max_revision
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
GROUP BY q.enquiry_id
|
||||
),
|
||||
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,
|
||||
u.email as user_email,
|
||||
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
|
||||
LEFT 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
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
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
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
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.UserEmail,
|
||||
&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 latest_revision AS (
|
||||
SELECT
|
||||
q.enquiry_id,
|
||||
MAX(d.revision) AS max_revision
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
GROUP BY q.enquiry_id
|
||||
),
|
||||
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,
|
||||
u.email as user_email,
|
||||
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
|
||||
LEFT 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
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
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
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
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.UserEmail,
|
||||
&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,
|
||||
)
|
||||
}
|
||||
257
go/internal/cmc/email/email.go
Normal file
257
go/internal/cmc/email/email.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Attachment represents an email attachment
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
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 {
|
||||
return es.SendTemplateEmailWithAttachments(to, subject, templateName, data, ccs, bccs, nil)
|
||||
}
|
||||
|
||||
// SendTemplateEmailWithAttachments renders a template and sends an email with optional CC, BCC, and attachments.
|
||||
func (es *EmailService) SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error {
|
||||
// Convert interface{} attachments to []Attachment
|
||||
var typedAttachments []Attachment
|
||||
for _, att := range attachments {
|
||||
if a, ok := att.(Attachment); ok {
|
||||
typedAttachments = append(typedAttachments, a)
|
||||
} else if a, ok := att.(struct{ Filename, FilePath string }); ok {
|
||||
typedAttachments = append(typedAttachments, Attachment{Filename: a.Filename, FilePath: a.FilePath})
|
||||
}
|
||||
}
|
||||
defaultBccs := []string{"carpis@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)
|
||||
}
|
||||
|
||||
var msg bytes.Buffer
|
||||
|
||||
// If there are attachments, use multipart message
|
||||
if len(typedAttachments) > 0 {
|
||||
boundary := "boundary123456789"
|
||||
|
||||
// Write headers
|
||||
fmt.Fprintf(&msg, "From: %s\r\n", es.FromAddress)
|
||||
fmt.Fprintf(&msg, "To: %s\r\n", to)
|
||||
if len(ccs) > 0 {
|
||||
fmt.Fprintf(&msg, "Cc: %s\r\n", joinAddresses(ccs))
|
||||
}
|
||||
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
|
||||
fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n", boundary)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Write HTML body part
|
||||
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Write attachments
|
||||
for _, att := range typedAttachments {
|
||||
file, err := os.Open(att.FilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open attachment %s: %w", att.FilePath, err)
|
||||
}
|
||||
|
||||
fileData, err := io.ReadAll(file)
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read attachment %s: %w", att.FilePath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&msg, "Content-Type: application/pdf\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Transfer-Encoding: base64\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(fileData)
|
||||
// Split into lines of 76 characters for proper MIME formatting
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
msg.WriteString(encoded[i:end])
|
||||
msg.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&msg, "--%s--\r\n", boundary)
|
||||
} else {
|
||||
// Simple message without attachments
|
||||
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\""
|
||||
|
||||
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(", ")))
|
||||
}
|
||||
272
go/internal/cmc/handlers/attachments/attachments.go
Normal file
272
go/internal/cmc/handlers/attachments/attachments.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package attachments
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type AttachmentHandler struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewAttachmentHandler(queries *db.Queries) *AttachmentHandler {
|
||||
return &AttachmentHandler{queries: queries}
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if val, err := strconv.Atoi(o); err == nil {
|
||||
offset = val
|
||||
}
|
||||
}
|
||||
|
||||
attachments, err := h.queries.ListAttachments(r.Context(), db.ListAttachmentsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachments)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Archived(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if val, err := strconv.Atoi(o); err == nil {
|
||||
offset = val
|
||||
}
|
||||
}
|
||||
|
||||
attachments, err := h.queries.ListArchivedAttachments(r.Context(), db.ListArchivedAttachmentsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachments)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
attachment, err := h.queries.GetAttachment(r.Context(), int32(id))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Attachment not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachment)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse multipart form
|
||||
err := r.ParseMultipartForm(32 << 20) // 32MB max
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file from form - try both field names (CakePHP format and plain)
|
||||
file, handler, err := r.FormFile("data[Attachment][file]")
|
||||
if err != nil {
|
||||
// Try plain "file" field name as fallback
|
||||
file, handler, err = r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext)
|
||||
|
||||
// Create attachments directory if it doesn't exist
|
||||
attachDir := "webroot/attachments_files"
|
||||
if err := os.MkdirAll(attachDir, 0755); err != nil {
|
||||
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save file to disk
|
||||
filePath := filepath.Join(attachDir, filename)
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse principle_id - try CakePHP format first, then fallback
|
||||
principleID := 1 // Default
|
||||
pid := r.FormValue("data[Attachment][principle_id]")
|
||||
if pid == "" {
|
||||
pid = r.FormValue("principle_id")
|
||||
}
|
||||
if pid != "" {
|
||||
if id, err := strconv.Atoi(pid); err == nil {
|
||||
principleID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Get other form values - try CakePHP format first, then fallback
|
||||
name := r.FormValue("data[Attachment][name]")
|
||||
if name == "" {
|
||||
name = r.FormValue("name")
|
||||
}
|
||||
|
||||
description := r.FormValue("data[Attachment][description]")
|
||||
if description == "" {
|
||||
description = r.FormValue("description")
|
||||
}
|
||||
|
||||
// Create database record
|
||||
// Store path in PHP format: /var/www/cmc-sales/app/webroot/attachments_files/filename
|
||||
phpPath := "/var/www/cmc-sales/app/webroot/attachments_files/" + filename
|
||||
params := db.CreateAttachmentParams{
|
||||
PrincipleID: int32(principleID),
|
||||
Name: name,
|
||||
Filename: handler.Filename,
|
||||
File: phpPath, // Store PHP container path for compatibility
|
||||
Type: handler.Header.Get("Content-Type"),
|
||||
Size: int32(handler.Size),
|
||||
Description: description,
|
||||
}
|
||||
|
||||
if params.Name == "" {
|
||||
params.Name = handler.Filename
|
||||
}
|
||||
|
||||
result, err := h.queries.CreateAttachment(r.Context(), params)
|
||||
if err != nil {
|
||||
// Clean up file on error
|
||||
os.Remove(filePath)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If HTMX request, return success message
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<div class="notification is-success">Attachment uploaded successfully</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// JSON response for API
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var params db.UpdateAttachmentParams
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Handle form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
params = db.UpdateAttachmentParams{
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
}
|
||||
|
||||
params.ID = int32(id)
|
||||
|
||||
if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Soft delete (archive)
|
||||
if err := h.queries.DeleteAttachment(r.Context(), int32(id)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
37
go/internal/cmc/handlers/attachments/attachments_test.go
Normal file
37
go/internal/cmc/handlers/attachments/attachments_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package attachments
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
)
|
||||
|
||||
// TestNewAttachmentHandler tests that the handler is created correctly
|
||||
func TestNewAttachmentHandler(t *testing.T) {
|
||||
queries := &db.Queries{}
|
||||
handler := NewAttachmentHandler(queries)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("Expected handler to be created, got nil")
|
||||
}
|
||||
|
||||
if handler.queries != queries {
|
||||
t.Fatal("Expected handler.queries to match input queries")
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full integration tests with database mocking would require more setup.
|
||||
// For now, this provides basic structure validation.
|
||||
// To run full tests, you would need to:
|
||||
// 1. Create a test database or use an in-memory SQLite database
|
||||
// 2. Run migrations
|
||||
// 3. Test each handler method with actual database calls
|
||||
//
|
||||
// Example test structure for future expansion:
|
||||
// func TestListAttachments(t *testing.T) {
|
||||
// db := setupTestDB(t)
|
||||
// defer db.Close()
|
||||
// queries := db.New(db)
|
||||
// handler := NewAttachmentHandler(queries)
|
||||
// // ... test implementation
|
||||
// }
|
||||
870
go/internal/cmc/handlers/emails.go
Normal file
870
go/internal/cmc/handlers/emails.go
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type EmailHandler struct {
|
||||
queries *db.Queries
|
||||
db *sql.DB
|
||||
gmailService *gmail.Service
|
||||
}
|
||||
|
||||
type EmailResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
UserID int32 `json:"user_id"`
|
||||
Created time.Time `json:"created"`
|
||||
GmailMessageID *string `json:"gmail_message_id,omitempty"`
|
||||
AttachmentCount int32 `json:"attachment_count"`
|
||||
IsDownloaded *bool `json:"is_downloaded,omitempty"`
|
||||
}
|
||||
|
||||
type EmailDetailResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
UserID int32 `json:"user_id"`
|
||||
Created time.Time `json:"created"`
|
||||
GmailMessageID *string `json:"gmail_message_id,omitempty"`
|
||||
GmailThreadID *string `json:"gmail_thread_id,omitempty"`
|
||||
RawHeaders *string `json:"raw_headers,omitempty"`
|
||||
IsDownloaded *bool `json:"is_downloaded,omitempty"`
|
||||
Enquiries []int32 `json:"enquiries,omitempty"`
|
||||
Invoices []int32 `json:"invoices,omitempty"`
|
||||
PurchaseOrders []int32 `json:"purchase_orders,omitempty"`
|
||||
Jobs []int32 `json:"jobs,omitempty"`
|
||||
}
|
||||
|
||||
type EmailAttachmentResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int32 `json:"size"`
|
||||
Filename string `json:"filename"`
|
||||
IsMessageBody bool `json:"is_message_body"`
|
||||
GmailAttachmentID *string `json:"gmail_attachment_id,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
func NewEmailHandler(queries *db.Queries, database *sql.DB) *EmailHandler {
|
||||
// Try to initialize Gmail service
|
||||
gmailService, err := getGmailService("credentials.json", "token.json")
|
||||
if err != nil {
|
||||
// Log the error but continue without Gmail service
|
||||
fmt.Printf("Warning: Gmail service not available: %v\n", err)
|
||||
}
|
||||
|
||||
return &EmailHandler{
|
||||
queries: queries,
|
||||
db: database,
|
||||
gmailService: gmailService,
|
||||
}
|
||||
}
|
||||
|
||||
// List emails with pagination and filtering
|
||||
func (h *EmailHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse query parameters
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
offsetStr := r.URL.Query().Get("offset")
|
||||
search := r.URL.Query().Get("search")
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
// Set defaults
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
// Build query
|
||||
query := `
|
||||
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded
|
||||
FROM emails e`
|
||||
|
||||
var args []interface{}
|
||||
var conditions []string
|
||||
|
||||
if search != "" {
|
||||
conditions = append(conditions, "e.subject LIKE ?")
|
||||
args = append(args, "%"+search+"%")
|
||||
}
|
||||
|
||||
if userID != "" {
|
||||
conditions = append(conditions, "e.user_id = ?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + joinConditions(conditions, " AND ")
|
||||
}
|
||||
|
||||
query += " ORDER BY e.id DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := h.db.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var emails []EmailResponse
|
||||
for rows.Next() {
|
||||
var email EmailResponse
|
||||
var gmailMessageID sql.NullString
|
||||
var isDownloaded sql.NullBool
|
||||
|
||||
err := rows.Scan(
|
||||
&email.ID,
|
||||
&email.Subject,
|
||||
&email.UserID,
|
||||
&email.Created,
|
||||
&gmailMessageID,
|
||||
&email.AttachmentCount,
|
||||
&isDownloaded,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if gmailMessageID.Valid {
|
||||
email.GmailMessageID = &gmailMessageID.String
|
||||
}
|
||||
if isDownloaded.Valid {
|
||||
email.IsDownloaded = &isDownloaded.Bool
|
||||
}
|
||||
|
||||
emails = append(emails, email)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(emails)
|
||||
}
|
||||
|
||||
// Get a specific email with details
|
||||
func (h *EmailHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
emailID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid email ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get email details
|
||||
query := `
|
||||
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id,
|
||||
e.gmail_thread_id, e.raw_headers, e.is_downloaded
|
||||
FROM emails e
|
||||
WHERE e.id = ?`
|
||||
|
||||
var email EmailDetailResponse
|
||||
var gmailMessageID, gmailThreadID, rawHeaders sql.NullString
|
||||
var isDownloaded sql.NullBool
|
||||
|
||||
err = h.db.QueryRow(query, emailID).Scan(
|
||||
&email.ID,
|
||||
&email.Subject,
|
||||
&email.UserID,
|
||||
&email.Created,
|
||||
&gmailMessageID,
|
||||
&gmailThreadID,
|
||||
&rawHeaders,
|
||||
&isDownloaded,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Email not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if gmailMessageID.Valid {
|
||||
email.GmailMessageID = &gmailMessageID.String
|
||||
}
|
||||
if gmailThreadID.Valid {
|
||||
email.GmailThreadID = &gmailThreadID.String
|
||||
}
|
||||
if rawHeaders.Valid {
|
||||
email.RawHeaders = &rawHeaders.String
|
||||
}
|
||||
if isDownloaded.Valid {
|
||||
email.IsDownloaded = &isDownloaded.Bool
|
||||
}
|
||||
|
||||
// Get associated enquiries
|
||||
enquiryRows, err := h.db.Query("SELECT enquiry_id FROM emails_enquiries WHERE email_id = ?", emailID)
|
||||
if err == nil {
|
||||
defer enquiryRows.Close()
|
||||
for enquiryRows.Next() {
|
||||
var enquiryID int32
|
||||
if enquiryRows.Scan(&enquiryID) == nil {
|
||||
email.Enquiries = append(email.Enquiries, enquiryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get associated invoices
|
||||
invoiceRows, err := h.db.Query("SELECT invoice_id FROM emails_invoices WHERE email_id = ?", emailID)
|
||||
if err == nil {
|
||||
defer invoiceRows.Close()
|
||||
for invoiceRows.Next() {
|
||||
var invoiceID int32
|
||||
if invoiceRows.Scan(&invoiceID) == nil {
|
||||
email.Invoices = append(email.Invoices, invoiceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get associated purchase orders
|
||||
poRows, err := h.db.Query("SELECT purchase_order_id FROM emails_purchase_orders WHERE email_id = ?", emailID)
|
||||
if err == nil {
|
||||
defer poRows.Close()
|
||||
for poRows.Next() {
|
||||
var poID int32
|
||||
if poRows.Scan(&poID) == nil {
|
||||
email.PurchaseOrders = append(email.PurchaseOrders, poID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get associated jobs
|
||||
jobRows, err := h.db.Query("SELECT job_id FROM emails_jobs WHERE email_id = ?", emailID)
|
||||
if err == nil {
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var jobID int32
|
||||
if jobRows.Scan(&jobID) == nil {
|
||||
email.Jobs = append(email.Jobs, jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(email)
|
||||
}
|
||||
|
||||
// List attachments for an email
|
||||
func (h *EmailHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
emailID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid email ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// First check if attachments are already in database
|
||||
query := `
|
||||
SELECT id, name, type, size, filename, is_message_body, gmail_attachment_id, created
|
||||
FROM email_attachments
|
||||
WHERE email_id = ?
|
||||
ORDER BY is_message_body DESC, created ASC`
|
||||
|
||||
rows, err := h.db.Query(query, emailID)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var attachments []EmailAttachmentResponse
|
||||
hasStoredAttachments := false
|
||||
|
||||
for rows.Next() {
|
||||
hasStoredAttachments = true
|
||||
var attachment EmailAttachmentResponse
|
||||
var gmailAttachmentID sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&attachment.ID,
|
||||
&attachment.Name,
|
||||
&attachment.Type,
|
||||
&attachment.Size,
|
||||
&attachment.Filename,
|
||||
&attachment.IsMessageBody,
|
||||
&gmailAttachmentID,
|
||||
&attachment.Created,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if gmailAttachmentID.Valid {
|
||||
attachment.GmailAttachmentID = &gmailAttachmentID.String
|
||||
}
|
||||
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
|
||||
// If no stored attachments and this is a Gmail email, try to fetch from Gmail
|
||||
if !hasStoredAttachments && h.gmailService != nil {
|
||||
// Get Gmail message ID
|
||||
var gmailMessageID sql.NullString
|
||||
err := h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID)
|
||||
|
||||
if err == nil && gmailMessageID.Valid {
|
||||
// Fetch message metadata from Gmail
|
||||
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
|
||||
Format("FULL").Do()
|
||||
|
||||
if err == nil && message.Payload != nil {
|
||||
// Extract attachment info from Gmail message
|
||||
attachmentIndex := int32(1)
|
||||
h.extractGmailAttachments(message.Payload, &attachments, &attachmentIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an HTMX request
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// Return HTML for HTMX
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if len(attachments) == 0 {
|
||||
// No attachments found
|
||||
html := `<div class="notification is-light">
|
||||
<p class="has-text-grey">No attachments found for this email.</p>
|
||||
</div>`
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
// Build HTML table for attachments
|
||||
var htmlBuilder strings.Builder
|
||||
htmlBuilder.WriteString(`<div class="box">
|
||||
<h3 class="title is-5">Attachments</h3>
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`)
|
||||
|
||||
for _, att := range attachments {
|
||||
icon := `<i class="fas fa-paperclip"></i>`
|
||||
if att.IsMessageBody {
|
||||
icon = `<i class="fas fa-envelope"></i>`
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("/api/v1/emails/%d/attachments/%d", emailID, att.ID)
|
||||
if att.GmailAttachmentID != nil {
|
||||
downloadURL = fmt.Sprintf("/api/v1/emails/%d/attachments/%d/stream", emailID, att.ID)
|
||||
}
|
||||
|
||||
htmlBuilder.WriteString(fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>
|
||||
<span class="icon has-text-grey">%s</span>
|
||||
%s
|
||||
</td>
|
||||
<td><span class="tag is-light">%s</span></td>
|
||||
<td>%d bytes</td>
|
||||
<td>
|
||||
<a href="%s" target="_blank" class="button is-small is-info">
|
||||
<span class="icon"><i class="fas fa-download"></i></span>
|
||||
<span>Download</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`, icon, att.Name, att.Type, att.Size, downloadURL))
|
||||
}
|
||||
|
||||
htmlBuilder.WriteString(`
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`)
|
||||
|
||||
w.Write([]byte(htmlBuilder.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// Return JSON for API requests
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(attachments)
|
||||
}
|
||||
|
||||
// Helper function to extract attachment info from Gmail message parts
|
||||
func (h *EmailHandler) extractGmailAttachments(part *gmail.MessagePart, attachments *[]EmailAttachmentResponse, index *int32) {
|
||||
// Check if this part is an attachment
|
||||
// Some attachments may not have filenames or may be inline
|
||||
if part.Body != nil && part.Body.AttachmentId != "" {
|
||||
filename := part.Filename
|
||||
if filename == "" {
|
||||
// Try to generate a filename from content type
|
||||
switch part.MimeType {
|
||||
case "application/pdf":
|
||||
filename = "attachment.pdf"
|
||||
case "image/png":
|
||||
filename = "image.png"
|
||||
case "image/jpeg":
|
||||
filename = "image.jpg"
|
||||
case "text/plain":
|
||||
filename = "text.txt"
|
||||
default:
|
||||
filename = "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
attachment := EmailAttachmentResponse{
|
||||
ID: *index,
|
||||
Name: filename,
|
||||
Type: part.MimeType,
|
||||
Size: int32(part.Body.Size),
|
||||
Filename: filename,
|
||||
IsMessageBody: false,
|
||||
GmailAttachmentID: &part.Body.AttachmentId,
|
||||
Created: time.Now(), // Use current time as placeholder
|
||||
}
|
||||
*attachments = append(*attachments, attachment)
|
||||
*index++
|
||||
}
|
||||
|
||||
// Process sub-parts
|
||||
for _, subPart := range part.Parts {
|
||||
h.extractGmailAttachments(subPart, attachments, index)
|
||||
}
|
||||
}
|
||||
|
||||
// Search emails
|
||||
func (h *EmailHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "Search query is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional parameters
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 20
|
||||
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
// Search in subjects and headers
|
||||
sqlQuery := `
|
||||
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded
|
||||
FROM emails e
|
||||
WHERE e.subject LIKE ? OR e.raw_headers LIKE ?
|
||||
ORDER BY e.id DESC
|
||||
LIMIT ?`
|
||||
|
||||
searchTerm := "%" + query + "%"
|
||||
rows, err := h.db.Query(sqlQuery, searchTerm, searchTerm, limit)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var emails []EmailResponse
|
||||
for rows.Next() {
|
||||
var email EmailResponse
|
||||
var gmailMessageID sql.NullString
|
||||
var isDownloaded sql.NullBool
|
||||
|
||||
err := rows.Scan(
|
||||
&email.ID,
|
||||
&email.Subject,
|
||||
&email.UserID,
|
||||
&email.Created,
|
||||
&gmailMessageID,
|
||||
&email.AttachmentCount,
|
||||
&isDownloaded,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if gmailMessageID.Valid {
|
||||
email.GmailMessageID = &gmailMessageID.String
|
||||
}
|
||||
if isDownloaded.Valid {
|
||||
email.IsDownloaded = &isDownloaded.Bool
|
||||
}
|
||||
|
||||
emails = append(emails, email)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(emails)
|
||||
}
|
||||
|
||||
// Stream email content from Gmail
|
||||
func (h *EmailHandler) StreamContent(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
emailID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid email ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get email details to check if it's a Gmail email
|
||||
query := `
|
||||
SELECT e.gmail_message_id, e.subject, e.created, e.user_id
|
||||
FROM emails e
|
||||
WHERE e.id = ?`
|
||||
|
||||
var gmailMessageID sql.NullString
|
||||
var subject string
|
||||
var created time.Time
|
||||
var userID int32
|
||||
|
||||
err = h.db.QueryRow(query, emailID).Scan(&gmailMessageID, &subject, &created, &userID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Email not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !gmailMessageID.Valid {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := `
|
||||
<div class="notification is-warning">
|
||||
<strong>Local Email</strong><br>
|
||||
This email is not from Gmail and does not have stored content available for display.
|
||||
</div>`
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for stored message body content in attachments
|
||||
attachmentQuery := `
|
||||
SELECT id, name, type, size
|
||||
FROM email_attachments
|
||||
WHERE email_id = ? AND is_message_body = 1
|
||||
ORDER BY created ASC`
|
||||
|
||||
attachmentRows, err := h.db.Query(attachmentQuery, emailID)
|
||||
if err == nil {
|
||||
defer attachmentRows.Close()
|
||||
if attachmentRows.Next() {
|
||||
var attachmentID int32
|
||||
var name, attachmentType string
|
||||
var size int32
|
||||
|
||||
if attachmentRows.Scan(&attachmentID, &name, &attachmentType, &size) == nil {
|
||||
// Found stored message body - would normally read the content from file storage
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="content">
|
||||
<div class="notification is-success is-light">
|
||||
<strong>Stored Email Content</strong><br>
|
||||
Message body is stored locally as attachment: %s (%s, %d bytes)
|
||||
</div>
|
||||
<div class="box">
|
||||
<p><em>Content would be loaded from local storage here.</em></p>
|
||||
<p>Attachment ID: %d</p>
|
||||
</div>
|
||||
</div>`, name, attachmentType, size, attachmentID)
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to fetch from Gmail if service is available
|
||||
if h.gmailService != nil {
|
||||
// Fetch from Gmail
|
||||
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
|
||||
Format("RAW").Do()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="notification is-danger">
|
||||
<strong>Gmail API Error</strong><br>
|
||||
Failed to fetch email from Gmail: %v
|
||||
</div>`, err)
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
// Decode message
|
||||
rawEmail, err := base64.URLEncoding.DecodeString(message.Raw)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="notification is-danger">
|
||||
<strong>Decode Error</strong><br>
|
||||
Failed to decode email: %v
|
||||
</div>`, err)
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(rawEmail))
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="notification is-danger">
|
||||
<strong>Parse Error</strong><br>
|
||||
Failed to parse email: %v
|
||||
</div>`, err)
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream HTML or Text directly to client
|
||||
if env.HTML != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(env.HTML))
|
||||
} else if env.Text != "" {
|
||||
// Convert plain text to HTML for better display
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="content">
|
||||
<div class="notification is-info is-light">
|
||||
<strong>Plain Text Email</strong><br>
|
||||
This email contains only plain text content.
|
||||
</div>
|
||||
<div class="box">
|
||||
<pre style="white-space: pre-wrap; font-family: inherit;">%s</pre>
|
||||
</div>
|
||||
</div>`, env.Text)
|
||||
w.Write([]byte(html))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := `
|
||||
<div class="notification is-warning">
|
||||
<strong>No Content</strong><br>
|
||||
No HTML or text content found in this email.
|
||||
</div>`
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No Gmail service available - show error
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(`
|
||||
<div class="content">
|
||||
<div class="notification is-warning is-light">
|
||||
<strong>Gmail Service Unavailable</strong><br>
|
||||
<small>Subject: %s</small><br>
|
||||
<small>Date: %s</small><br>
|
||||
<small>Gmail Message ID: <code>%s</code></small>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="content">
|
||||
<h4>Integration Status</h4>
|
||||
<p>Gmail service is not available. To enable email content display:</p>
|
||||
<ol>
|
||||
<li>Ensure <code>credentials.json</code> and <code>token.json</code> files are present</li>
|
||||
<li>Configure Gmail API OAuth2 authentication</li>
|
||||
<li>Restart the application</li>
|
||||
</ol>
|
||||
<p class="has-text-grey-light is-size-7">
|
||||
<strong>Gmail Message ID:</strong> <code>%s</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
subject,
|
||||
created.Format("2006-01-02 15:04:05"),
|
||||
gmailMessageID.String,
|
||||
gmailMessageID.String)
|
||||
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// Stream attachment from Gmail
|
||||
func (h *EmailHandler) StreamAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
emailID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid email ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := vars["attachmentId"]
|
||||
|
||||
// Get email's Gmail message ID
|
||||
var gmailMessageID sql.NullString
|
||||
err = h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID)
|
||||
if err != nil || !gmailMessageID.Valid {
|
||||
http.Error(w, "Email not found or not a Gmail email", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if h.gmailService == nil {
|
||||
http.Error(w, "Gmail service not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// For dynamic attachments, we need to fetch the message and find the attachment
|
||||
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
|
||||
Format("FULL").Do()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch email from Gmail", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the attachment by index
|
||||
var targetAttachment *gmail.MessagePart
|
||||
attachmentIndex := 1
|
||||
findAttachment(message.Payload, attachmentID, &attachmentIndex, &targetAttachment)
|
||||
|
||||
if targetAttachment == nil || targetAttachment.Body == nil || targetAttachment.Body.AttachmentId == "" {
|
||||
http.Error(w, "Attachment not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch attachment data from Gmail
|
||||
attachment, err := h.gmailService.Users.Messages.Attachments.
|
||||
Get("me", gmailMessageID.String, targetAttachment.Body.AttachmentId).Do()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch attachment from Gmail", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
data, err := base64.URLEncoding.DecodeString(attachment.Data)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to decode attachment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers and stream
|
||||
filename := targetAttachment.Filename
|
||||
if filename == "" {
|
||||
// Generate filename from content type (same logic as extractGmailAttachments)
|
||||
switch targetAttachment.MimeType {
|
||||
case "application/pdf":
|
||||
filename = "attachment.pdf"
|
||||
case "image/png":
|
||||
filename = "image.png"
|
||||
case "image/jpeg":
|
||||
filename = "image.jpg"
|
||||
case "text/plain":
|
||||
filename = "text.txt"
|
||||
default:
|
||||
filename = "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", targetAttachment.MimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// Helper function to find attachment by index
|
||||
func findAttachment(part *gmail.MessagePart, targetID string, currentIndex *int, result **gmail.MessagePart) {
|
||||
// Check if this part is an attachment (same logic as extractGmailAttachments)
|
||||
if part.Body != nil && part.Body.AttachmentId != "" {
|
||||
fmt.Printf("Checking attachment %d (looking for %s): %s\n", *currentIndex, targetID, part.Filename)
|
||||
if strconv.Itoa(*currentIndex) == targetID {
|
||||
fmt.Printf("Found matching attachment!\n")
|
||||
*result = part
|
||||
return
|
||||
}
|
||||
*currentIndex++
|
||||
}
|
||||
|
||||
for _, subPart := range part.Parts {
|
||||
findAttachment(subPart, targetID, currentIndex, result)
|
||||
if *result != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to join conditions
|
||||
func joinConditions(conditions []string, separator string) string {
|
||||
if len(conditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
|
||||
result := conditions[0]
|
||||
for i := 1; i < len(conditions); i++ {
|
||||
result += separator + conditions[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Gmail OAuth2 functions
|
||||
func getGmailService(credentialsFile, tokenFile string) (*gmail.Service, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
b, err := ioutil.ReadFile(credentialsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read client secret file: %v", err)
|
||||
}
|
||||
|
||||
config, err := google.ConfigFromJSON(b, gmail.GmailReadonlyScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse client secret file to config: %v", err)
|
||||
}
|
||||
|
||||
client := getClient(config, tokenFile)
|
||||
srv, err := gmail.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve Gmail client: %v", err)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func getClient(config *oauth2.Config, tokFile string) *http.Client {
|
||||
tok, err := tokenFromFile(tokFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return config.Client(context.Background(), tok)
|
||||
}
|
||||
|
||||
func tokenFromFile(file string) (*oauth2.Token, error) {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
tok := &oauth2.Token{}
|
||||
err = json.NewDecoder(f).Decode(tok)
|
||||
return tok, err
|
||||
}
|
||||
1214
go/internal/cmc/handlers/pages.go
Normal file
1214
go/internal/cmc/handlers/pages.go
Normal file
File diff suppressed because it is too large
Load diff
841
go/internal/cmc/handlers/quotes/quotes.go
Normal file
841
go/internal/cmc/handlers/quotes/quotes.go
Normal file
|
|
@ -0,0 +1,841 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/auth"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
)
|
||||
|
||||
// getUsername is a wrapper around auth.GetUsername for backwards compatibility
|
||||
func getUsername(r *http.Request) string {
|
||||
return auth.GetUsername(r)
|
||||
}
|
||||
|
||||
// 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
|
||||
GetUserEmail() 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 {
|
||||
if q.Username.Valid {
|
||||
return q.Username.String
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
func (q ExpiringSoonQuoteRowWrapper) GetUserEmail() string {
|
||||
if q.UserEmail.Valid {
|
||||
return q.UserEmail.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
if q.Username.Valid {
|
||||
return q.Username.String
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
func (q RecentlyExpiredQuoteRowWrapper) GetUserEmail() string {
|
||||
if q.UserEmail.Valid {
|
||||
return q.UserEmail.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
if q.Username.Valid {
|
||||
return q.Username.String
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUserEmail() string {
|
||||
if q.UserEmail.Valid {
|
||||
return q.UserEmail.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
if q.Username.Valid {
|
||||
return q.Username.String
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUserEmail() string {
|
||||
if q.UserEmail.Valid {
|
||||
return q.UserEmail.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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()),
|
||||
"UserEmail": q.GetUserEmail(),
|
||||
"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, arg db.GetExpiringSoonQuotesOnDayParams) ([]db.GetExpiringSoonQuotesOnDayRow, error)
|
||||
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
|
||||
DisableQuoteReminders(ctx context.Context, params db.DisableQuoteRemindersParams) (sql.Result, error)
|
||||
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
|
||||
SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) 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, db.GetExpiringSoonQuotesOnDayParams{
|
||||
DATEADD: day,
|
||||
DATEADD_2: 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
|
||||
}
|
||||
|
||||
// getReminderDetails returns the subject and template for a given reminder type
|
||||
func getReminderDetails(reminderType QuoteReminderType) (subject, template string) {
|
||||
switch reminderType {
|
||||
case FirstReminder:
|
||||
return "Reminder: Quote %s Expires Soon", "first_reminder.html"
|
||||
case SecondReminder:
|
||||
return "Follow-Up: Quote %s Expired", "second_reminder.html"
|
||||
case ThirdReminder:
|
||||
return "Final Reminder: Quote %s Closed", "final_reminder.html"
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
// getReminderDetailsManual returns the subject and template for a manually sent reminder (generic templates without time references)
|
||||
func getReminderDetailsManual(reminderType QuoteReminderType) (subject, template string) {
|
||||
switch reminderType {
|
||||
case FirstReminder:
|
||||
return "Reminder: Quote %s Expires Soon", "manual_first_reminder.html"
|
||||
case SecondReminder:
|
||||
return "Follow-Up: Quote %s Expired", "manual_second_reminder.html"
|
||||
case ThirdReminder:
|
||||
return "Final Reminder: Quote %s Closed", "manual_final_reminder.html"
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatQuoteDates formats ISO date strings to DD/MM/YYYY format
|
||||
func formatQuoteDates(dateIssuedStr, validUntilStr string) (submissionDate, expiryDate string) {
|
||||
if dateIssuedStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, dateIssuedStr); err == nil {
|
||||
submissionDate = t.Format("02/01/2006")
|
||||
} else {
|
||||
submissionDate = dateIssuedStr
|
||||
}
|
||||
}
|
||||
if validUntilStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, validUntilStr); err == nil {
|
||||
expiryDate = t.Format("02/01/2006")
|
||||
} else {
|
||||
expiryDate = validUntilStr
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// prepareReminderEmail prepares all data needed to send a reminder email
|
||||
func prepareReminderEmail(reminderType QuoteReminderType, customerName, dateIssuedStr, validUntilStr, enquiryRef, userEmail string) (subject, template string, templateData map[string]interface{}, ccs []string) {
|
||||
subject, template = getReminderDetails(reminderType)
|
||||
subject = fmt.Sprintf(subject, enquiryRef)
|
||||
|
||||
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
|
||||
|
||||
templateData = map[string]interface{}{
|
||||
"CustomerName": customerName,
|
||||
"SubmissionDate": submissionDate,
|
||||
"ExpiryDate": expiryDate,
|
||||
"QuoteRef": enquiryRef,
|
||||
}
|
||||
|
||||
ccs = []string{"sales@cmctechnologies.com.au"}
|
||||
if userEmail != "" {
|
||||
ccs = append(ccs, userEmail)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 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},
|
||||
{-7, SecondReminder},
|
||||
{-60, ThirdReminder},
|
||||
}
|
||||
|
||||
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 {
|
||||
subject, template, templateData, ccs := prepareReminderEmail(
|
||||
job.ReminderType,
|
||||
q["CustomerName"].(string),
|
||||
q["DateIssued"].(string),
|
||||
q["ValidUntil"].(string),
|
||||
q["EnquiryRef"].(string),
|
||||
q["UserEmail"].(string),
|
||||
)
|
||||
|
||||
// Construct PDF path from filesystem
|
||||
enquiryRef := q["EnquiryRef"].(string)
|
||||
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
|
||||
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
|
||||
|
||||
err := h.SendQuoteReminderEmailWithPDF(
|
||||
context.Background(),
|
||||
q["ID"].(int32),
|
||||
job.ReminderType,
|
||||
q["CustomerEmail"].(string),
|
||||
subject,
|
||||
template,
|
||||
templateData,
|
||||
ccs,
|
||||
nil,
|
||||
pdfPath,
|
||||
pdfFilename,
|
||||
)
|
||||
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{}, ccs []string, username *string, allowDuplicate bool) 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 (only if duplicates not allowed)
|
||||
if !allowDuplicate {
|
||||
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,
|
||||
ccs, 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
|
||||
}
|
||||
|
||||
// SendQuoteReminderEmailWithPDF sends a reminder email with PDF attachment loaded from filesystem
|
||||
func (h *QuotesHandler) SendQuoteReminderEmailWithPDF(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename 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
|
||||
}
|
||||
|
||||
// Prepare PDF attachment if file exists
|
||||
var attachments []interface{}
|
||||
if _, err := os.Stat(pdfPath); err == nil {
|
||||
attachments = []interface{}{
|
||||
struct {
|
||||
Filename string
|
||||
FilePath string
|
||||
}{
|
||||
Filename: pdfFilename,
|
||||
FilePath: pdfPath,
|
||||
},
|
||||
}
|
||||
log.Printf("Attaching PDF for quote %d: %s", quoteID, pdfPath)
|
||||
} else {
|
||||
log.Printf("PDF not found for quote %d at %s, sending without attachment", quoteID, pdfPath)
|
||||
}
|
||||
|
||||
// Send the email (with or without attachment)
|
||||
err = h.emailService.SendTemplateEmailWithAttachments(
|
||||
recipient,
|
||||
subject,
|
||||
templateName,
|
||||
templateData,
|
||||
ccs,
|
||||
nil,
|
||||
attachments,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
// SendManualReminder handles POST requests to manually send a quote reminder
|
||||
func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
quoteIDStr := r.FormValue("quote_id")
|
||||
reminderTypeStr := r.FormValue("reminder_type")
|
||||
customerEmail := r.FormValue("customer_email")
|
||||
userEmail := r.FormValue("user_email")
|
||||
enquiryRef := r.FormValue("enquiry_ref")
|
||||
customerName := r.FormValue("customer_name")
|
||||
dateIssuedStr := r.FormValue("date_issued")
|
||||
validUntilStr := r.FormValue("valid_until")
|
||||
|
||||
// Validate required fields
|
||||
if quoteIDStr == "" || reminderTypeStr == "" || customerEmail == "" {
|
||||
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reminderTypeInt, err := strconv.Atoi(reminderTypeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid reminder type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reminderType := QuoteReminderType(reminderTypeInt)
|
||||
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
|
||||
http.Error(w, "Invalid reminder type value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use manual template for manually sent reminders (generic without time references)
|
||||
subject, template := getReminderDetailsManual(reminderType)
|
||||
subject = fmt.Sprintf(subject, enquiryRef)
|
||||
|
||||
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
|
||||
|
||||
templateData := map[string]interface{}{
|
||||
"CustomerName": customerName,
|
||||
"SubmissionDate": submissionDate,
|
||||
"ExpiryDate": expiryDate,
|
||||
"QuoteRef": enquiryRef,
|
||||
}
|
||||
|
||||
ccs := []string{"sales@cmctechnologies.com.au"}
|
||||
if userEmail != "" {
|
||||
ccs = append(ccs, userEmail)
|
||||
}
|
||||
|
||||
// Attach PDF quote from filesystem
|
||||
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
|
||||
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
|
||||
|
||||
// Get username from request
|
||||
username := getUsername(r)
|
||||
usernamePtr := &username
|
||||
|
||||
// Send the reminder with attachment
|
||||
err = h.SendQuoteReminderEmailWithPDF(
|
||||
r.Context(),
|
||||
int32(quoteID),
|
||||
reminderType,
|
||||
customerEmail,
|
||||
subject,
|
||||
template,
|
||||
templateData,
|
||||
ccs,
|
||||
usernamePtr,
|
||||
pdfPath,
|
||||
pdfFilename,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to send manual reminder for quote %d: %v", quoteID, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to send reminder: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Reminder sent successfully",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to quotes page for non-AJAX requests
|
||||
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// DisableReminders handles POST requests to disable automatic reminders for a quote
|
||||
func (h *QuotesHandler) DisableReminders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quoteIDStr := r.FormValue("quote_id")
|
||||
if quoteIDStr == "" {
|
||||
http.Error(w, "Missing quote_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get username from request
|
||||
username := getUsername(r)
|
||||
|
||||
// Update the database to disable reminders
|
||||
_, err = h.queries.DisableQuoteReminders(r.Context(), db.DisableQuoteRemindersParams{
|
||||
RemindersDisabledBy: sql.NullString{String: username, Valid: true},
|
||||
ID: int32(quoteID),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to disable reminders for quote %d: %v", quoteID, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to disable reminders: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Reminders disabled successfully",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to quotes page for non-AJAX requests
|
||||
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// EnableReminders handles POST requests to re-enable automatic reminders for a quote
|
||||
func (h *QuotesHandler) EnableReminders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quoteIDStr := r.FormValue("quote_id")
|
||||
if quoteIDStr == "" {
|
||||
http.Error(w, "Missing quote_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the database to enable reminders
|
||||
_, err = h.queries.EnableQuoteReminders(r.Context(), int32(quoteID))
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to enable reminders for quote %d: %v", quoteID, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to enable reminders: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Reminders enabled successfully",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to quotes page for non-AJAX requests
|
||||
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// Helper: get reminder type as string
|
||||
func reminderTypeString(reminderType int) string {
|
||||
switch reminderType {
|
||||
case 0:
|
||||
return "No Reminder"
|
||||
case 1:
|
||||
return "First Reminder"
|
||||
case 2:
|
||||
return "Second Reminder"
|
||||
case 3:
|
||||
return "Final Reminder"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
359
go/internal/cmc/handlers/quotes/quotes_test.go
Normal file
359
go/internal/cmc/handlers/quotes/quotes_test.go
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
)
|
||||
|
||||
// Mocks
|
||||
|
||||
type mockQuoteRow struct {
|
||||
reminderType int32
|
||||
reminderSent time.Time
|
||||
designatedDay time.Weekday
|
||||
emailSent bool
|
||||
}
|
||||
|
||||
func (m *mockQuoteRow) GetReminderType() int32 { return m.reminderType }
|
||||
func (m *mockQuoteRow) GetReminderSent() time.Time { return m.reminderSent }
|
||||
func (m *mockQuoteRow) GetCustomerEmail() string { return "test@example.com" }
|
||||
|
||||
// Realistic mock for db.Queries
|
||||
|
||||
type mockQueries struct {
|
||||
reminders []db.QuoteReminder
|
||||
insertCalled bool
|
||||
}
|
||||
|
||||
func (m *mockQueries) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
|
||||
var filtered []db.QuoteReminder
|
||||
for _, r := range m.reminders {
|
||||
if r.QuoteID == params.QuoteID && r.ReminderType == params.ReminderType {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *mockQueries) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
|
||||
m.insertCalled = true
|
||||
m.reminders = append(m.reminders, db.QuoteReminder{
|
||||
QuoteID: params.QuoteID,
|
||||
ReminderType: params.ReminderType,
|
||||
DateSent: params.DateSent,
|
||||
Username: params.Username,
|
||||
})
|
||||
return &mockResult{}, nil
|
||||
}
|
||||
|
||||
func (m *mockQueries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Mock sql.Result for InsertQuoteReminder
|
||||
|
||||
type mockResult struct{}
|
||||
|
||||
func (m *mockResult) LastInsertId() (int64, error) { return 1, nil }
|
||||
func (m *mockResult) RowsAffected() (int64, error) { return 1, nil }
|
||||
|
||||
// Realistic mock for email.EmailService
|
||||
|
||||
type mockEmailService struct {
|
||||
sent bool
|
||||
sentReminders map[int32]map[int32]bool // quoteID -> reminderType -> sent
|
||||
}
|
||||
|
||||
func (m *mockEmailService) SendTemplateEmail(to, subject, templateName string, data interface{}, ccs, bccs []string) error {
|
||||
m.sent = true
|
||||
if m.sentReminders == nil {
|
||||
m.sentReminders = make(map[int32]map[int32]bool)
|
||||
}
|
||||
var quoteID int32
|
||||
var reminderType int32
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if id, ok := dataMap["QuoteID"].(int32); ok {
|
||||
quoteID = id
|
||||
} else if id, ok := dataMap["QuoteID"].(int); ok {
|
||||
quoteID = int32(id)
|
||||
}
|
||||
if rt, ok := dataMap["ReminderType"].(int32); ok {
|
||||
reminderType = rt
|
||||
} else if rt, ok := dataMap["ReminderType"].(int); ok {
|
||||
reminderType = int32(rt)
|
||||
}
|
||||
}
|
||||
if quoteID == 0 {
|
||||
quoteID = 123
|
||||
}
|
||||
if reminderType == 0 {
|
||||
reminderType = 1
|
||||
}
|
||||
if m.sentReminders[quoteID] == nil {
|
||||
m.sentReminders[quoteID] = make(map[int32]bool)
|
||||
}
|
||||
m.sentReminders[quoteID][reminderType] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mock for db.Queries with error simulation
|
||||
|
||||
type mockQueriesError struct{}
|
||||
|
||||
func (m *mockQueriesError) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
|
||||
return nil, errors.New("db error")
|
||||
}
|
||||
func (m *mockQueriesError) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
|
||||
return &mockResult{}, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockQueriesError) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Test: Should send email on designated day if not already sent
|
||||
// Description: Verifies that a reminder email is sent and recorded when no previous reminder exists for the quote. Should pass unless the handler logic is broken.
|
||||
func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
me := &mockEmailService{}
|
||||
h := &QuotesHandler{
|
||||
queries: mq,
|
||||
emailService: me,
|
||||
}
|
||||
// Simulate designated day logic by calling SendQuoteReminderEmail
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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, nil, false)
|
||||
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))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue