Compare commits

...

53 commits
prod ... master

Author SHA1 Message Date
Karl Cordes a251da2d95 Maybe this will actually fix the emojibake problem 2025-12-23 17:05:32 +11:00
Karl Cordes b3b0c040f3 Hopefully fix emojibake errors 2025-12-23 16:56:21 +11:00
Karl Cordes 7f3cb5cbaa Add scripts to fix emojibake corruption 2025-12-23 15:54:26 +11:00
Karl Cordes bc958b6acf Scripts to fix encoding problems #137 2025-12-10 07:27:36 +11:00
Finley Ghosh 5db8451f2c Adding force recreate when no-cache 2025-12-08 21:47:54 +11:00
Finley Ghosh 34747336dd Changing attachments to use php path 2025-12-08 21:41:22 +11:00
Finley Ghosh a99b818f6b Adding new file mounts 2025-12-08 21:31:11 +11:00
Finley Ghosh a5ccdd8472 Reworking attachment logic in Go, bumping Nginx max file upload size, fixing local dev env, changes to .gitignore 2025-12-08 20:39:45 +11:00
Karl Cordes 52d50e8b57 Remove print_r from PO revision 2025-12-08 17:16:55 +11:00
Finley Ghosh bb5d48a0a0 Quick fix 2025-12-08 00:04:40 +11:00
Finley Ghosh 3950cef4c9 Ensuring automatic quote reminder emails have pdfs 2025-12-07 22:15:09 +11:00
Finley Ghosh 57e7f7fe24 Making re-enable modal the same width 2025-12-07 21:27:48 +11:00
Finley Ghosh 2459df569e Fixing bug where quote revisions would not set the user, fixing quotes not showing up if they don't have a valid user 2025-12-07 21:18:35 +11:00
Finley Ghosh e11013bd9f Fixing some quotes not showing up 2025-12-07 19:04:20 +11:00
Finley Ghosh a579380905 Adding prune to deploy scripts to prevent docker resource overflowing 2025-12-07 18:10:33 +11:00
Finley Ghosh 016f77a2c9 Streamlining quote reminder logging 2025-12-07 18:08:35 +11:00
Finley Ghosh e94cabfc3e Fixing disable automatic reminders, adding better visuals 2025-12-07 18:06:23 +11:00
Finley Ghosh 793b8b4a7d fixing disable future error 2025-12-07 17:51:08 +11:00
Finley Ghosh b62877d1bd Adding interface reference, fixing manual quote reminders dropdown width / anchor 2025-12-07 17:35:21 +11:00
Finley Ghosh d8526f12b6 Using document version to get latest quotes 2025-12-07 17:18:04 +11:00
Finley Ghosh b62d7fdb17 Better modals and visuals, adding method to re-enabled disabled reminders 2025-12-07 16:53:59 +11:00
Finley Ghosh c4d60bc1c9 Fixing automated goose script 2025-12-07 16:21:46 +11:00
Finley Ghosh 6668e1af4f Adding check for existing ssh tunnel 2025-12-07 16:15:36 +11:00
Finley Ghosh 942522458f Moving fully to goose migration 2025-12-07 16:13:40 +11:00
Finley Ghosh 8de7fc675b Checking a different way 2025-12-07 15:56:50 +11:00
Finley Ghosh e6d444c4af Updating migration script 2025-12-07 15:55:14 +11:00
Finley Ghosh f6bbfb3c83 Adding new migrations script supporting goose 2025-12-07 15:53:41 +11:00
Finley Ghosh b0a09c159d Adding endpoint to disable reminders, fixing pdf attachment 2025-12-07 12:50:28 +11:00
Finley Ghosh bbdd035d04 Adding ability to disable automatic reminders 2025-12-07 12:44:30 +11:00
Finley Ghosh 57047e3b32 Centralising auth, making pdf retrieval via authenticated request 2025-12-07 12:27:41 +11:00
Finley Ghosh 46cf098dfa Updating pdf attachment logic 2025-12-05 22:17:38 +11:00
Finley Ghosh d898149810 Adding manual email templates, adding support for pdf attachments in emails, attaching quote to reminder if possible 2025-12-04 00:35:03 +11:00
Finley Ghosh 8dbf221908 adding back .env.example 2025-12-04 00:08:27 +11:00
Finley Ghosh 97d7420f17 Improving modal text 2025-12-04 00:03:49 +11:00
Finley Ghosh 1d8753764f Adding ability to manually send reminders 2025-12-03 23:59:57 +11:00
Finley Ghosh c496e7232f Refurbishing db refresher script for local use 2025-12-03 22:37:44 +11:00
Finley Ghosh 23853455f4 Getting local development running again 2025-12-03 22:21:12 +11:00
Finley Ghosh 9eb7747c45 Changing repo in deploy scripts 2025-12-02 22:09:49 +11:00
Finley Ghosh ee70c11431 Removing mcarpis from default bcc 2025-12-02 22:08:33 +11:00
Karl Cordes ba770cb87d Fix timezone 2025-11-26 07:23:19 +11:00
Karl Cordes e4c8fa8a57 Maybe fix PDF generation failing 2025-11-25 14:06:24 +11:00
Karl Cordes 1b5a23b3c0 Fix vault attachment directory 2025-11-24 21:59:54 +11:00
Karl Cordes ce5d44ae6b Fix Jonathan password 2025-11-24 13:35:16 +11:00
Karl Cordes 50d2541600 Set mariadb sql_mode for CakePHP compatibility 2025-11-24 10:28:07 +11:00
Karl Cordes ee182f3c6e Hopefully fix attachments directory misconfig 2025-11-23 16:09:25 +11:00
Karl Cordes e7babb7523 Merge branch 'prod' 2025-11-23 13:52:48 +11:00
Finley Ghosh 40e9f98fef Adding -p flag to mkdir 2025-11-23 13:08:20 +11:00
Finley Ghosh f6b3d90297 Merge branch 'stg' 2025-11-23 13:03:23 +11:00
Finley Ghosh 1f116c09ba Adding README 2025-11-23 13:03:06 +11:00
finley 8c118098bf Merge pull request 'prod' (#123) from prod into master
Reviewed-on: cmc/cmc-sales#123
Reviewed-by: kzrl <karl@cordes.com.au>
2025-11-22 17:52:39 -08:00
finley 352ee4cfcd Merge pull request 'migration/reorg' (#124) from migration/reorg into stg
Reviewed-on: cmc/cmc-sales#124
2025-11-22 17:36:31 -08:00
Finley Ghosh 88ffe7bcd9 Moving php and go into separate dirs, moving scripts into a central dir, updating files as necessary 2025-11-23 12:30:24 +11:00
Karl Cordes ff2c48a289 Add Jonathan to userpasswd 2025-11-19 18:26:22 +11:00
2764 changed files with 7161 additions and 1111 deletions

7
.gitignore vendored
View file

@ -14,3 +14,10 @@ app/cake_eclipse_helper.php
app/webroot/pdf/* app/webroot/pdf/*
app/webroot/attachments_files/* app/webroot/attachments_files/*
backups/* backups/*
# Go binaries
go/server
go/vault
go/go.mod
go/go.sum
go/goose.env

View file

@ -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: 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 - **Legacy CakePHP 1.2.5 application** (in `/php/`) - Primary business logic
- **Modern Go API** (in `/go-app/`) - New development using sqlc and Gorilla Mux - **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. **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 ### Go Application Development
```bash ```bash
# Navigate to Go app directory # Navigate to Go app directory
cd go-app cd go
# Configure private module access (first time only) # Configure private module access (first time only)
go env -w GOPRIVATE=code.springupsoftware.com go env -w GOPRIVATE=code.springupsoftware.com
@ -80,7 +80,7 @@ make build
### Go Application (Modern) ### Go Application (Modern)
- **Framework**: Gorilla Mux (HTTP router) - **Framework**: Gorilla Mux (HTTP router)
- **Database**: sqlc for type-safe SQL queries - **Database**: sqlc for type-safe SQL queries
- **Location**: `/go-app/` - **Location**: `/go/`
- **Structure**: - **Structure**:
- `cmd/server/` - Main application entry point - `cmd/server/` - Main application entry point
- `internal/cmc/handlers/` - HTTP request handlers - `internal/cmc/handlers/` - HTTP request handlers

27
Dockerfile.local.go Normal file
View 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
View file

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

View file

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

View file

@ -2,9 +2,9 @@ FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
COPY go-app/go.mod go-app/go.sum ./ COPY go/go.mod go/go.sum ./
RUN go mod download RUN go mod download
COPY go-app/ . COPY go/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate RUN sqlc generate
RUN go mod tidy RUN go mod tidy
@ -16,8 +16,8 @@ RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/ WORKDIR /root/
COPY --from=builder /app/server . COPY --from=builder /app/server .
COPY --from=builder /app/vault . COPY --from=builder /app/vault .
COPY go-app/templates ./templates COPY go/templates ./templates
COPY go-app/static ./static COPY go/static ./static
COPY go-app/.env.example .env COPY go/.env.example .env
EXPOSE 8082 EXPOSE 8082
CMD ["./server"] CMD ["./server"]

View file

@ -35,10 +35,10 @@ RUN chmod +x /bin/ripmime \
&& a2enmod headers && a2enmod headers
# Copy site into place. # Copy site into place.
ADD . /var/www/cmc-sales ADD php/ /var/www/cmc-sales
ADD app/config/database.php /var/www/cmc-sales/app/config/database.php ADD php/app/config/database.php /var/www/cmc-sales/app/config/database.php
RUN mkdir /var/www/cmc-sales/app/tmp RUN mkdir -p /var/www/cmc-sales/app/tmp
RUN mkdir /var/www/cmc-sales/app/tmp/logs RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server # Ensure CakePHP tmp directory is writable by web server

View file

@ -2,9 +2,9 @@ FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
COPY go-app/go.mod go-app/go.sum ./ COPY go/go.mod go/go.sum ./
RUN go mod download RUN go mod download
COPY go-app/ . COPY go/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate RUN sqlc generate
RUN go mod tidy RUN go mod tidy
@ -14,8 +14,8 @@ FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/ WORKDIR /root/
COPY --from=builder /app/server . COPY --from=builder /app/server .
COPY go-app/templates ./templates COPY go/templates ./templates
COPY go-app/static ./static COPY go/static ./static
COPY go-app/.env.example .env COPY go/.env.example .env
EXPOSE 8082 EXPOSE 8082
CMD ["./server"] CMD ["./server"]

View file

@ -54,8 +54,8 @@ RUN chmod +x /bin/ripmime \
&& a2enmod headers && a2enmod headers
# Copy site into place. # Copy site into place.
ADD . /var/www/cmc-sales ADD php/ /var/www/cmc-sales
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php 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
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp RUN chmod -R 755 /var/www/cmc-sales/app/tmp

View file

@ -78,7 +78,7 @@ Deploy to staging or production using the scripts in the `deploy/` directory:
**Deploy to Staging:** **Deploy to Staging:**
```bash ```bash
./deploy/deploy-stg.sh ./scripts/deploy/deploy-stg.sh
``` ```
**Deploy to Production:** **Deploy to Production:**
@ -88,8 +88,8 @@ Deploy to staging or production using the scripts in the `deploy/` directory:
**Rebuild without cache (useful after dependency changes):** **Rebuild without cache (useful after dependency changes):**
```bash ```bash
./deploy/deploy-prod.sh --no-cache ./scripts/deploy/deploy-prod.sh --no-cache
./deploy/deploy-stg.sh --no-cache ./scripts/deploy/deploy-stg.sh --no-cache
``` ```
### How Deployment Works ### How Deployment Works

View file

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

View file

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

View 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

View file

@ -1,9 +1,14 @@
resolver 127.0.0.11 valid=10s;
server { server {
server_name cmclocal; server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd; auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted"; auth_basic "Restricted";
client_max_body_size 200M;
location /go/ { location /go/ {
proxy_pass http://cmc-go:8080; set $upstream_go cmc-go:8080;
proxy_pass http://$upstream_go;
proxy_read_timeout 300s; proxy_read_timeout 300s;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -11,7 +16,8 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location / { location / {
proxy_pass http://cmc-php:80; set $upstream_php cmc-php:80;
proxy_pass http://$upstream_php;
proxy_read_timeout 300s; proxy_read_timeout 300s;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View file

@ -2,6 +2,7 @@ server {
server_name cmclocal; server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd; auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted"; auth_basic "Restricted";
client_max_body_size 200M;
location /go/ { location /go/ {
proxy_pass http://cmc-prod-go:8082; proxy_pass http://cmc-prod-go:8082;
proxy_read_timeout 300s; proxy_read_timeout 300s;

View file

@ -2,6 +2,7 @@ server {
server_name cmclocal; server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd; auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted"; auth_basic "Restricted";
client_max_body_size 200M;
location /go/ { location /go/ {
proxy_pass http://cmc-stg-go:8082; proxy_pass http://cmc-stg-go:8082;
proxy_read_timeout 300s; proxy_read_timeout 300s;

View file

@ -47,6 +47,7 @@ services:
DB_USER: cmc DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc DB_NAME: cmc
GO_APP_HOST: cmc-prod-go:8082
volumes: volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf - /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/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
@ -81,8 +82,8 @@ services:
- "8083:8082" - "8083:8082"
volumes: volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro - /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/emails:/var/www/emails
- /home/cmc/files/attachments_files:/var/www/attachments_files
- /home/cmc/files/vault:/var/www/vault - /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs - /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
networks: networks:

View file

@ -45,6 +45,7 @@ services:
DB_USER: cmc DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc DB_NAME: cmc
GO_APP_HOST: cmc-stg-go:8082
volumes: volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf - /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/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
@ -79,6 +80,7 @@ services:
- "8082:8082" - "8082:8082"
volumes: volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro - /home/cmc/files/pdf:/root/webroot/pdf:ro
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
networks: networks:
- cmc-stg-network - cmc-stg-network
restart: unless-stopped restart: unless-stopped

View file

@ -8,7 +8,10 @@ services:
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf - ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro - ./userpasswd:/etc/nginx/userpasswd:ro
depends_on: depends_on:
- cmc-php cmc-php:
condition: service_started
cmc-go:
condition: service_started
restart: unless-stopped restart: unless-stopped
networks: networks:
- cmc-network - cmc-network
@ -16,30 +19,32 @@ services:
cmc-php: cmc-php:
build: build:
context: . context: .
dockerfile: Dockerfile.stg.php dockerfile: Dockerfile.local.php
platform: linux/amd64 platform: linux/amd64
container_name: cmc-php container_name: cmc-php
environment:
GO_APP_HOST: cmc-go:8080
depends_on: depends_on:
- db - db
volumes: volumes:
- ./app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf - ./php/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/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
networks: networks:
- cmc-network - cmc-network
restart: unless-stopped restart: unless-stopped
develop: develop:
watch: watch:
- action: rebuild - action: rebuild
path: ./app path: ./php/app
ignore: ignore:
- ./app/webroot/pdf - ./php/app/webroot/pdf
- ./app/webroot/attachments_files - ./php/app/webroot/attachments_files
- ./app/tmp - ./php/app/tmp
- action: sync - action: sync
path: ./app/webroot/css path: ./php/app/webroot/css
target: /var/www/cmc-sales/app/webroot/css target: /var/www/cmc-sales/app/webroot/css
- action: sync - action: sync
path: ./app/webroot/js path: ./php/app/webroot/js
target: /var/www/cmc-sales/app/webroot/js target: /var/www/cmc-sales/app/webroot/js
db: db:
@ -75,9 +80,9 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./go-app:/app - ./go:/app
- ./go-app/.air.toml:/root/.air.toml - ./go/.air.toml:/root/.air.toml
- ./go-app/.env.example:/root/.env - ./go/.env.example:/root/.env
networks: networks:
- cmc-network - cmc-network
restart: unless-stopped restart: unless-stopped

40
go-app/.gitignore vendored
View file

@ -1,40 +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
# Goose database migration config
goose.env
# Gmail OAuth credentials - NEVER commit these!
credentials.json
token.json

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import (
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "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/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes" quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/go-co-op/gocron" "github.com/go-co-op/gocron"
@ -60,6 +61,7 @@ func main() {
// Load handlers // Load handlers
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService) quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
attachmentHandler := attachments.NewAttachmentHandler(queries)
// Setup routes // Setup routes
r := mux.NewRouter() r := mux.NewRouter()
@ -73,6 +75,14 @@ func main() {
// Quote routes // Quote routes
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET") 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: // The following routes are currently disabled:
/* /*

View file

@ -27,6 +27,8 @@ Processes emails from local filesystem directories.
--dbname=cmc --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 ### 2. Gmail Index Mode
Indexes Gmail emails without downloading content. Creates database references only. Indexes Gmail emails without downloading content. Creates database references only.

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

View file

@ -342,17 +342,20 @@ type Quote struct {
// limited at 5 digits. Really, you're not going to have more revisions of a single quote than that // limited at 5 digits. Really, you're not going to have more revisions of a single quote than that
Revision int32 `json:"revision"` Revision int32 `json:"revision"`
// estimated delivery time for quote // estimated delivery time for quote
DeliveryTime string `json:"delivery_time"` DeliveryTime string `json:"delivery_time"`
DeliveryTimeFrame string `json:"delivery_time_frame"` DeliveryTimeFrame string `json:"delivery_time_frame"`
PaymentTerms string `json:"payment_terms"` PaymentTerms string `json:"payment_terms"`
DaysValid int32 `json:"days_valid"` DaysValid int32 `json:"days_valid"`
DateIssued time.Time `json:"date_issued"` DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"` ValidUntil time.Time `json:"valid_until"`
DeliveryPoint string `json:"delivery_point"` RemindersDisabled sql.NullBool `json:"reminders_disabled"`
ExchangeRate string `json:"exchange_rate"` RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
CustomsDuty string `json:"customs_duty"` RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
DocumentID int32 `json:"document_id"` DeliveryPoint string `json:"delivery_point"`
CommercialComments sql.NullString `json:"commercial_comments"` ExchangeRate string `json:"exchange_rate"`
CustomsDuty string `json:"customs_duty"`
DocumentID int32 `json:"document_id"`
CommercialComments sql.NullString `json:"commercial_comments"`
} }
type QuoteReminder struct { type QuoteReminder struct {

View file

@ -43,6 +43,8 @@ type Querier interface {
DeletePurchaseOrder(ctx context.Context, id int32) error DeletePurchaseOrder(ctx context.Context, id int32) error
DeleteState(ctx context.Context, id int32) error DeleteState(ctx context.Context, id int32) error
DeleteStatus(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) GetAddress(ctx context.Context, id int32) (Address, error)
GetAllCountries(ctx context.Context) ([]Country, error) GetAllCountries(ctx context.Context) ([]Country, error)
GetAllPrinciples(ctx context.Context) ([]Principle, error) GetAllPrinciples(ctx context.Context) ([]Principle, error)
@ -61,7 +63,7 @@ type Querier interface {
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error) GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error) GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error)
GetLineItem(ctx context.Context, id int32) (LineItem, error) GetLineItem(ctx context.Context, id int32) (LineItem, error)
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error) GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error) GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
@ -76,6 +78,7 @@ type Querier interface {
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error) GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error) GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, 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) GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)

View file

@ -11,8 +11,45 @@ import (
"time" "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 const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -35,10 +72,10 @@ latest_quote_reminder AS (
FROM ranked_reminders FROM ranked_reminders
WHERE rn = 1 WHERE rn = 1
) )
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -46,13 +83,15 @@ SELECT
q.date_issued, q.date_issued,
q.valid_until, q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type, 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(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 FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -60,21 +99,24 @@ WHERE
q.valid_until >= CURRENT_DATE 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 e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until ORDER BY q.valid_until
` `
type GetExpiringSoonQuotesRow struct { type GetExpiringSoonQuotesRow struct {
DocumentID int32 `json:"document_id"` DocumentID int32 `json:"document_id"`
Username string `json:"username"` Username sql.NullString `json:"username"`
EnquiryID int32 `json:"enquiry_id"` UserEmail sql.NullString `json:"user_email"`
EnquiryRef string `json:"enquiry_ref"` EnquiryID int32 `json:"enquiry_id"`
CustomerName string `json:"customer_name"` EnquiryRef string `json:"enquiry_ref"`
CustomerEmail string `json:"customer_email"` CustomerName string `json:"customer_name"`
DateIssued time.Time `json:"date_issued"` CustomerEmail string `json:"customer_email"`
ValidUntil time.Time `json:"valid_until"` DateIssued time.Time `json:"date_issued"`
LatestReminderType int32 `json:"latest_reminder_type"` ValidUntil time.Time `json:"valid_until"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"` 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) { func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
@ -89,6 +131,7 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
if err := rows.Scan( if err := rows.Scan(
&i.DocumentID, &i.DocumentID,
&i.Username, &i.Username,
&i.UserEmail,
&i.EnquiryID, &i.EnquiryID,
&i.EnquiryRef, &i.EnquiryRef,
&i.CustomerName, &i.CustomerName,
@ -97,6 +140,7 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
&i.ValidUntil, &i.ValidUntil,
&i.LatestReminderType, &i.LatestReminderType,
&i.LatestReminderSentTime, &i.LatestReminderSentTime,
&i.RemindersDisabled,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -112,7 +156,15 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
} }
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -139,6 +191,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -150,35 +203,44 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE WHERE
q.valid_until >= CURRENT_DATE 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 q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until ORDER BY q.valid_until
` `
type GetExpiringSoonQuotesOnDayRow struct { type GetExpiringSoonQuotesOnDayParams struct {
DocumentID int32 `json:"document_id"` DATEADD interface{} `json:"DATE_ADD"`
Username string `json:"username"` DATEADD_2 interface{} `json:"DATE_ADD_2"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
} }
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) { type GetExpiringSoonQuotesOnDayRow struct {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD) 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 { if err != nil {
return nil, err return nil, err
} }
@ -189,6 +251,7 @@ func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interf
if err := rows.Scan( if err := rows.Scan(
&i.DocumentID, &i.DocumentID,
&i.Username, &i.Username,
&i.UserEmail,
&i.EnquiryID, &i.EnquiryID,
&i.EnquiryRef, &i.EnquiryRef,
&i.CustomerName, &i.CustomerName,
@ -252,8 +315,35 @@ func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemin
return items, nil 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 const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -280,6 +370,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -291,9 +382,10 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -301,21 +393,23 @@ WHERE
q.valid_until < CURRENT_DATE q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC ORDER BY q.valid_until DESC
` `
type GetRecentlyExpiredQuotesRow struct { type GetRecentlyExpiredQuotesRow struct {
DocumentID int32 `json:"document_id"` DocumentID int32 `json:"document_id"`
Username string `json:"username"` Username sql.NullString `json:"username"`
EnquiryID int32 `json:"enquiry_id"` UserEmail sql.NullString `json:"user_email"`
EnquiryRef string `json:"enquiry_ref"` EnquiryID int32 `json:"enquiry_id"`
CustomerName string `json:"customer_name"` EnquiryRef string `json:"enquiry_ref"`
CustomerEmail string `json:"customer_email"` CustomerName string `json:"customer_name"`
DateIssued time.Time `json:"date_issued"` CustomerEmail string `json:"customer_email"`
ValidUntil time.Time `json:"valid_until"` DateIssued time.Time `json:"date_issued"`
LatestReminderType int32 `json:"latest_reminder_type"` ValidUntil time.Time `json:"valid_until"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"` 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) { func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) {
@ -330,6 +424,7 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
if err := rows.Scan( if err := rows.Scan(
&i.DocumentID, &i.DocumentID,
&i.Username, &i.Username,
&i.UserEmail,
&i.EnquiryID, &i.EnquiryID,
&i.EnquiryRef, &i.EnquiryRef,
&i.CustomerName, &i.CustomerName,
@ -353,7 +448,15 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
} }
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -380,6 +483,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -391,9 +495,10 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -401,21 +506,23 @@ WHERE
q.valid_until < CURRENT_DATE q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC ORDER BY q.valid_until DESC
` `
type GetRecentlyExpiredQuotesOnDayRow struct { type GetRecentlyExpiredQuotesOnDayRow struct {
DocumentID int32 `json:"document_id"` DocumentID int32 `json:"document_id"`
Username string `json:"username"` Username sql.NullString `json:"username"`
EnquiryID int32 `json:"enquiry_id"` UserEmail sql.NullString `json:"user_email"`
EnquiryRef string `json:"enquiry_ref"` EnquiryID int32 `json:"enquiry_id"`
CustomerName string `json:"customer_name"` EnquiryRef string `json:"enquiry_ref"`
CustomerEmail string `json:"customer_email"` CustomerName string `json:"customer_name"`
DateIssued time.Time `json:"date_issued"` CustomerEmail string `json:"customer_email"`
ValidUntil time.Time `json:"valid_until"` DateIssued time.Time `json:"date_issued"`
LatestReminderType int32 `json:"latest_reminder_type"` ValidUntil time.Time `json:"valid_until"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"` 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) { func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
@ -430,6 +537,7 @@ func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB int
if err := rows.Scan( if err := rows.Scan(
&i.DocumentID, &i.DocumentID,
&i.Username, &i.Username,
&i.UserEmail,
&i.EnquiryID, &i.EnquiryID,
&i.EnquiryRef, &i.EnquiryRef,
&i.CustomerName, &i.CustomerName,

View file

@ -3,14 +3,22 @@ package email
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/base64"
"fmt" "fmt"
"html/template" "html/template"
"io"
"net/smtp" "net/smtp"
"os" "os"
"strconv" "strconv"
"sync" "sync"
) )
// Attachment represents an email attachment
type Attachment struct {
Filename string
FilePath string
}
var ( var (
emailServiceInstance *EmailService emailServiceInstance *EmailService
once sync.Once once sync.Once
@ -50,7 +58,21 @@ func GetEmailService() *EmailService {
// SendTemplateEmail renders a template and sends an email with optional CC and BCC. // 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 { func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
defaultBccs := []string{"carpis@cmctechnologies.com.au", "mcarpis@cmctechnologies.com.au"} 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...) bccs = append(defaultBccs, bccs...)
const templateDir = "templates/quotes" const templateDir = "templates/quotes"
@ -65,22 +87,80 @@ func (es *EmailService) SendTemplateEmail(to string, subject string, templateNam
return fmt.Errorf("failed to execute template: %w", err) return fmt.Errorf("failed to execute template: %w", err)
} }
headers := make(map[string]string)
headers["From"] = es.FromAddress
headers["To"] = to
if len(ccs) > 0 {
headers["Cc"] = joinAddresses(ccs)
}
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
var msg bytes.Buffer var msg bytes.Buffer
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v) // 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())
} }
msg.WriteString("\r\n")
msg.Write(body.Bytes())
recipients := []string{to} recipients := []string{to}
recipients = append(recipients, ccs...) recipients = append(recipients, ccs...)

View file

@ -1,4 +1,4 @@
package handlers package attachments
import ( import (
"database/sql" "database/sql"
@ -111,18 +111,22 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get file from form // Get file from form - try both field names (CakePHP format and plain)
file, handler, err := r.FormFile("file") file, handler, err := r.FormFile("data[Attachment][file]")
if err != nil { if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest) // Try plain "file" field name as fallback
return file, handler, err = r.FormFile("file")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
} }
defer file.Close() defer file.Close()
// Generate unique filename // Generate unique filename
ext := filepath.Ext(handler.Filename) ext := filepath.Ext(handler.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext) 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 // Create attachments directory if it doesn't exist
attachDir := "webroot/attachments_files" attachDir := "webroot/attachments_files"
if err := os.MkdirAll(attachDir, 0755); err != nil { if err := os.MkdirAll(attachDir, 0755); err != nil {
@ -131,8 +135,8 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
// Save file to disk // Save file to disk
filepath := filepath.Join(attachDir, filename) filePath := filepath.Join(attachDir, filename)
dst, err := os.Create(filepath) dst, err := os.Create(filePath)
if err != nil { if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError) http.Error(w, "Failed to save file", http.StatusInternalServerError)
return return
@ -144,23 +148,40 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse principle_id // Parse principle_id - try CakePHP format first, then fallback
principleID := 1 // Default principleID := 1 // Default
if pid := r.FormValue("principle_id"); pid != "" { pid := r.FormValue("data[Attachment][principle_id]")
if pid == "" {
pid = r.FormValue("principle_id")
}
if pid != "" {
if id, err := strconv.Atoi(pid); err == nil { if id, err := strconv.Atoi(pid); err == nil {
principleID = id 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 // 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{ params := db.CreateAttachmentParams{
PrincipleID: int32(principleID), PrincipleID: int32(principleID),
Name: r.FormValue("name"), Name: name,
Filename: handler.Filename, Filename: handler.Filename,
File: filename, File: phpPath, // Store PHP container path for compatibility
Type: handler.Header.Get("Content-Type"), Type: handler.Header.Get("Content-Type"),
Size: int32(handler.Size), Size: int32(handler.Size),
Description: r.FormValue("description"), Description: description,
} }
if params.Name == "" { if params.Name == "" {
@ -170,7 +191,7 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
result, err := h.queries.CreateAttachment(r.Context(), params) result, err := h.queries.CreateAttachment(r.Context(), params)
if err != nil { if err != nil {
// Clean up file on error // Clean up file on error
os.Remove(filepath) os.Remove(filePath)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -216,13 +237,13 @@ func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
params = db.UpdateAttachmentParams{ params = db.UpdateAttachmentParams{
Name: r.FormValue("name"), Name: r.FormValue("name"),
Description: r.FormValue("description"), Description: r.FormValue("description"),
} }
} }
params.ID = int32(id) params.ID = int32(id)
if err := h.queries.UpdateAttachment(r.Context(), params); err != nil { if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
@ -248,4 +269,4 @@ func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View 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
// }

View file

@ -7,11 +7,10 @@ import (
"strconv" "strconv"
"time" "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/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"golang.org/x/text/cases"
"golang.org/x/text/language"
) )
type PageHandler struct { type PageHandler struct {
@ -30,12 +29,7 @@ func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager, databa
// Helper function to get the username from the request // Helper function to get the username from the request
func getUsername(r *http.Request) string { func getUsername(r *http.Request) string {
username, _, ok := r.BasicAuth() return auth.GetUsername(r)
if ok && username != "" {
caser := cases.Title(language.English)
return caser.String(username) // Capitalise the username for display
}
return "Guest"
} }
// Home page // Home page
@ -839,7 +833,7 @@ func (h *PageHandler) EmailsIndex(w http.ResponseWriter, r *http.Request) {
u.email as user_email, u.first_name, u.last_name u.email as user_email, u.first_name, u.last_name
FROM emails e FROM emails e
LEFT JOIN users u ON e.user_id = u.id` LEFT JOIN users u ON e.user_id = u.id`
var args []interface{} var args []interface{}
var conditions []string var conditions []string
@ -882,16 +876,16 @@ func (h *PageHandler) EmailsIndex(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
type EmailWithUser struct { type EmailWithUser struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Subject string `json:"subject"` Subject string `json:"subject"`
UserID int32 `json:"user_id"` UserID int32 `json:"user_id"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
GmailMessageID *string `json:"gmail_message_id"` GmailMessageID *string `json:"gmail_message_id"`
AttachmentCount int32 `json:"attachment_count"` AttachmentCount int32 `json:"attachment_count"`
IsDownloaded *bool `json:"is_downloaded"` IsDownloaded *bool `json:"is_downloaded"`
UserEmail *string `json:"user_email"` UserEmail *string `json:"user_email"`
FirstName *string `json:"first_name"` FirstName *string `json:"first_name"`
LastName *string `json:"last_name"` LastName *string `json:"last_name"`
} }
var emails []EmailWithUser var emails []EmailWithUser
@ -1080,7 +1074,7 @@ func (h *PageHandler) EmailsShow(w http.ResponseWriter, r *http.Request) {
var attachments []EmailAttachment var attachments []EmailAttachment
hasStoredAttachments := false hasStoredAttachments := false
if attachmentRows != nil { if attachmentRows != nil {
defer attachmentRows.Close() defer attachmentRows.Close()
for attachmentRows.Next() { for attachmentRows.Next() {
@ -1179,7 +1173,7 @@ func (h *PageHandler) EmailsShow(w http.ResponseWriter, r *http.Request) {
func (h *PageHandler) EmailsSearch(w http.ResponseWriter, r *http.Request) { func (h *PageHandler) EmailsSearch(w http.ResponseWriter, r *http.Request) {
_ = r.URL.Query().Get("search") // TODO: Implement search functionality _ = r.URL.Query().Get("search") // TODO: Implement search functionality
// Empty result for now - would need proper implementation // Empty result for now - would need proper implementation
emails := []interface{}{} emails := []interface{}{}

View 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 ""
}
}

View file

@ -143,7 +143,7 @@ func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
emailService: me, emailService: me,
} }
// Simulate designated day logic by calling SendQuoteReminderEmail // Simulate designated day logic by calling SendQuoteReminderEmail
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil { if err != nil {
t.Fatalf("Expected no error, got %v", err) t.Fatalf("Expected no error, got %v", err)
} }
@ -164,7 +164,7 @@ func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
queries: mq, queries: mq,
emailService: me, emailService: me,
} }
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil) 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 { if err == nil {
t.Error("Expected error for already sent reminder") t.Error("Expected error for already sent reminder")
} }
@ -186,7 +186,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
emailService: me, emailService: me,
} }
// First call should succeed // First call should succeed
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil) 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 { if err1 != nil {
t.Fatalf("Expected first call to succeed, got %v", err1) t.Fatalf("Expected first call to succeed, got %v", err1)
} }
@ -197,7 +197,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders)) t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
} }
// Second call should fail // Second call should fail
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil) 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 { if err2 == nil {
t.Error("Expected error for already sent reminder on second call") t.Error("Expected error for already sent reminder on second call")
} }
@ -215,7 +215,7 @@ func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
queries: mq, queries: mq,
emailService: me, emailService: me,
} }
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil { if err != nil {
t.Fatalf("Expected no error, got %v", err) t.Fatalf("Expected no error, got %v", err)
} }
@ -236,7 +236,7 @@ func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
queries: mq, queries: mq,
emailService: me, emailService: me,
} }
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil) 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 { if err == nil {
t.Error("Expected error for already sent reminder") t.Error("Expected error for already sent reminder")
} }
@ -252,7 +252,7 @@ func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled. // 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) { func TestSendQuoteReminderEmail_DBError(t *testing.T) {
h := &QuotesHandler{queries: &mockQueriesError{}} h := &QuotesHandler{queries: &mockQueriesError{}}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil { if err == nil {
t.Error("Expected error when DB fails, got nil") t.Error("Expected error when DB fails, got nil")
} }
@ -264,7 +264,7 @@ func TestSendQuoteReminderEmail_DBError(t *testing.T) {
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) { func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}} mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq} h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil { if err == nil {
t.Error("Expected error for nil recipient, got nil") t.Error("Expected error for nil recipient, got nil")
} }
@ -276,7 +276,7 @@ func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) { func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}} mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq} h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil, nil, false)
if err == nil { if err == nil {
t.Error("Expected error for missing template data, got nil") t.Error("Expected error for missing template data, got nil")
} }
@ -288,7 +288,7 @@ func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) { func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}} mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq} h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil) err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil { if err == nil {
t.Error("Expected error for invalid reminder type, got nil") t.Error("Expected error for invalid reminder type, got nil")
} }
@ -302,7 +302,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
h := &QuotesHandler{queries: mq, emailService: me} h := &QuotesHandler{queries: mq, emailService: me}
// First reminder type // First reminder type
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil) 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 { if err1 != nil {
t.Fatalf("Expected first reminder to be sent, got %v", err1) t.Fatalf("Expected first reminder to be sent, got %v", err1)
} }
@ -314,7 +314,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
} }
// Second reminder type // Second reminder type
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil) 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 { if err2 != nil {
t.Fatalf("Expected second reminder to be sent, got %v", err2) t.Fatalf("Expected second reminder to be sent, got %v", err2)
} }
@ -334,7 +334,7 @@ func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
h := &QuotesHandler{queries: mq, emailService: me} h := &QuotesHandler{queries: mq, emailService: me}
// Send reminder for quote 123 // 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) 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 { if err1 != nil {
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1) t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
} }
@ -346,7 +346,7 @@ func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
} }
// Send reminder for quote 456 // 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) 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 { if err2 != nil {
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2) t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
} }

View file

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

View file

@ -0,0 +1,22 @@
-- +goose Up
-- Add reminders_disabled field to quotes table
ALTER TABLE quotes
ADD COLUMN reminders_disabled BOOLEAN DEFAULT FALSE AFTER valid_until,
ADD COLUMN reminders_disabled_at DATETIME DEFAULT NULL AFTER reminders_disabled,
ADD COLUMN reminders_disabled_by VARCHAR(100) DEFAULT NULL AFTER reminders_disabled_at;
-- +goose StatementBegin
CREATE INDEX idx_reminders_disabled ON quotes(reminders_disabled);
-- +goose StatementEnd
-- +goose Down
-- Remove index
-- +goose StatementBegin
DROP INDEX idx_reminders_disabled ON quotes;
-- +goose StatementEnd
-- Remove columns from quotes
ALTER TABLE quotes
DROP COLUMN reminders_disabled_by,
DROP COLUMN reminders_disabled_at,
DROP COLUMN reminders_disabled;

View file

@ -1,5 +1,13 @@
-- name: GetExpiringSoonQuotes :many -- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -22,10 +30,10 @@ latest_quote_reminder AS (
FROM ranked_reminders FROM ranked_reminders
WHERE rn = 1 WHERE rn = 1
) )
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -33,13 +41,15 @@ SELECT
q.date_issued, q.date_issued,
q.valid_until, q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type, 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(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 FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -47,11 +57,20 @@ WHERE
q.valid_until >= CURRENT_DATE 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 e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until; ORDER BY q.valid_until;
-- name: GetExpiringSoonQuotesOnDay :many -- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -78,6 +97,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -89,21 +109,32 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE WHERE
q.valid_until >= CURRENT_DATE 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 q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until; ORDER BY q.valid_until;
-- name: GetRecentlyExpiredQuotes :many -- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -130,6 +161,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -141,9 +173,10 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -151,11 +184,20 @@ WHERE
q.valid_until < CURRENT_DATE q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC; ORDER BY q.valid_until DESC;
-- name: GetRecentlyExpiredQuotesOnDay :many -- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS ( 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 SELECT
id, id,
quote_id, quote_id,
@ -182,6 +224,7 @@ latest_quote_reminder AS (
SELECT SELECT
d.id AS document_id, d.id AS document_id,
u.username, u.username,
u.email as user_email,
e.id AS enquiry_id, e.id AS enquiry_id,
e.title as enquiry_ref, e.title as enquiry_ref,
uu.first_name as customer_name, uu.first_name as customer_name,
@ -193,9 +236,10 @@ SELECT
FROM quotes q FROM quotes q
JOIN documents d ON d.id = q.document_id JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_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 LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
@ -203,6 +247,7 @@ WHERE
q.valid_until < CURRENT_DATE q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5 AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC; ORDER BY q.valid_until DESC;
@ -214,4 +259,23 @@ ORDER BY date_sent;
-- name: InsertQuoteReminder :execresult -- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username) INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?); VALUES (?, ?, ?, ?);
-- name: DisableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = TRUE,
reminders_disabled_at = NOW(),
reminders_disabled_by = ?
WHERE id = ?;
-- name: EnableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = FALSE,
reminders_disabled_at = NULL,
reminders_disabled_by = NULL
WHERE id = ?;
-- name: GetQuoteRemindersDisabled :one
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
FROM quotes
WHERE id = ?;

View file

@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS `quotes` (
`days_valid` int(3) NOT NULL, `days_valid` int(3) NOT NULL,
`date_issued` date NOT NULL, `date_issued` date NOT NULL,
`valid_until` date NOT NULL, `valid_until` date NOT NULL,
`reminders_disabled` tinyint(1) DEFAULT 0,
`reminders_disabled_at` datetime DEFAULT NULL,
`reminders_disabled_by` varchar(100) DEFAULT NULL,
`delivery_point` varchar(400) NOT NULL, `delivery_point` varchar(400) NOT NULL,
`exchange_rate` varchar(255) NOT NULL, `exchange_rate` varchar(255) NOT NULL,
`customs_duty` varchar(255) NOT NULL, `customs_duty` varchar(255) NOT NULL,

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