Compare commits
53 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a251da2d95 | ||
|
|
b3b0c040f3 | ||
|
|
7f3cb5cbaa | ||
|
|
bc958b6acf | ||
|
|
5db8451f2c | ||
|
|
34747336dd | ||
|
|
a99b818f6b | ||
|
|
a5ccdd8472 | ||
|
|
52d50e8b57 | ||
|
|
bb5d48a0a0 | ||
|
|
3950cef4c9 | ||
|
|
57e7f7fe24 | ||
|
|
2459df569e | ||
|
|
e11013bd9f | ||
|
|
a579380905 | ||
|
|
016f77a2c9 | ||
|
|
e94cabfc3e | ||
|
|
793b8b4a7d | ||
|
|
b62877d1bd | ||
|
|
d8526f12b6 | ||
|
|
b62d7fdb17 | ||
|
|
c4d60bc1c9 | ||
|
|
6668e1af4f | ||
|
|
942522458f | ||
|
|
8de7fc675b | ||
|
|
e6d444c4af | ||
|
|
f6bbfb3c83 | ||
|
|
b0a09c159d | ||
|
|
bbdd035d04 | ||
|
|
57047e3b32 | ||
|
|
46cf098dfa | ||
|
|
d898149810 | ||
|
|
8dbf221908 | ||
|
|
97d7420f17 | ||
|
|
1d8753764f | ||
|
|
c496e7232f | ||
|
|
23853455f4 | ||
|
|
9eb7747c45 | ||
|
|
ee70c11431 | ||
|
|
ba770cb87d | ||
|
|
e4c8fa8a57 | ||
|
|
1b5a23b3c0 | ||
|
|
ce5d44ae6b | ||
|
|
50d2541600 | ||
|
|
ee182f3c6e | ||
|
|
e7babb7523 | ||
|
|
40e9f98fef | ||
|
|
f6b3d90297 | ||
|
|
1f116c09ba | ||
|
|
8c118098bf | ||
|
|
352ee4cfcd | ||
|
|
88ffe7bcd9 | ||
|
|
ff2c48a289 |
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
27
Dockerfile.local.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM golang:1.24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum first
|
||||||
|
COPY go/go.mod go/go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Install Air for hot reload (pinned to v1.52.3 for Go 1.24 compatibility)
|
||||||
|
RUN go install github.com/air-verse/air@v1.52.3
|
||||||
|
# Install sqlc for SQL code generation
|
||||||
|
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY go/ .
|
||||||
|
|
||||||
|
# Generate sqlc code
|
||||||
|
RUN sqlc generate
|
||||||
|
|
||||||
|
# Copy Air config
|
||||||
|
COPY go/.air.toml .air.toml
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["air", "-c", ".air.toml"]
|
||||||
47
Dockerfile.local.php
Normal file
47
Dockerfile.local.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Use the official PHP 5.6 Apache image for classic mod_php
|
||||||
|
FROM php:5.6-apache
|
||||||
|
|
||||||
|
# Install required system libraries and PHP extensions for CakePHP
|
||||||
|
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
|
||||||
|
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
|
||||||
|
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
|
||||||
|
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
|
||||||
|
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
|
||||||
|
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
|
||||||
|
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
|
||||||
|
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
|
||||||
|
|
||||||
|
# Set environment variables.
|
||||||
|
ENV HOME /root
|
||||||
|
|
||||||
|
# Define working directory.
|
||||||
|
WORKDIR /root
|
||||||
|
|
||||||
|
ARG COMMIT
|
||||||
|
ENV COMMIT_SHA=${COMMIT}
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Copy vhost config to Apache's sites-available
|
||||||
|
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
|
||||||
|
ADD conf/ripmime /bin/ripmime
|
||||||
|
|
||||||
|
RUN chmod +x /bin/ripmime \
|
||||||
|
&& a2ensite cmc-sales \
|
||||||
|
&& a2dissite 000-default \
|
||||||
|
&& a2enmod rewrite \
|
||||||
|
&& a2enmod headers
|
||||||
|
|
||||||
|
# Copy site into place.
|
||||||
|
ADD php/ /var/www/cmc-sales
|
||||||
|
ADD php/app/config/database_local.php /var/www/cmc-sales/app/config/database.php
|
||||||
|
RUN mkdir -p /var/www/cmc-sales/app/tmp
|
||||||
|
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
|
||||||
|
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||||
|
|
||||||
|
# Ensure CakePHP tmp directory is writable by web server
|
||||||
|
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
|
||||||
|
# By default, simply start apache.
|
||||||
|
CMD /usr/sbin/apache2ctl -D FOREGROUND
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<div class="attachments form">
|
|
||||||
<?php echo $form->create('Attachment', array('type'=>'file'));?>
|
|
||||||
<fieldset>
|
|
||||||
<legend><?php __('Add Attachment');?></legend>
|
|
||||||
<?php
|
|
||||||
echo $form->input('principle_id');
|
|
||||||
echo $form->input('name');
|
|
||||||
echo $form->file('file');
|
|
||||||
echo $form->input('description');
|
|
||||||
echo $form->input('archived');
|
|
||||||
?>
|
|
||||||
</fieldset>
|
|
||||||
<?php echo $form->end('Submit');?>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,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"]
|
|
||||||
3
conf/mariadb-no-strict.cnf
Normal file
3
conf/mariadb-no-strict.cnf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[mysqld]
|
||||||
|
# Custom sql_mode for legacy CakePHP compatibility
|
||||||
|
sql_mode = ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
40
go-app/.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
@ -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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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:
|
||||||
/*
|
/*
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
109
go/internal/cmc/auth/auth.go
Normal file
109
go/internal/cmc/auth/auth.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextKey is a type for context keys to avoid collisions
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ContextKeyUsername is the context key for storing the authenticated username
|
||||||
|
ContextKeyUsername ContextKey = "username"
|
||||||
|
// ContextKeyAuthUser is the context key for storing the raw auth username
|
||||||
|
ContextKeyAuthUser ContextKey = "auth_user"
|
||||||
|
// ContextKeyAuthPass is the context key for storing the raw auth password
|
||||||
|
ContextKeyAuthPass ContextKey = "auth_pass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials holds authentication credentials
|
||||||
|
type Credentials struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentials extracts authentication credentials from the request
|
||||||
|
// This is the single point where auth mechanism is defined
|
||||||
|
func GetCredentials(r *http.Request) (*Credentials, bool) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &Credentials{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsername extracts and formats the username for display
|
||||||
|
func GetUsername(r *http.Request) string {
|
||||||
|
creds, ok := GetCredentials(r)
|
||||||
|
if !ok {
|
||||||
|
return "Guest"
|
||||||
|
}
|
||||||
|
// Capitalize the username for display
|
||||||
|
return strings.Title(creds.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsernameFromContext retrieves the username from the request context
|
||||||
|
func GetUsernameFromContext(ctx context.Context) string {
|
||||||
|
if username, ok := ctx.Value(ContextKeyUsername).(string); ok {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return "Guest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentialsFromContext retrieves credentials from the request context
|
||||||
|
func GetCredentialsFromContext(ctx context.Context) (*Credentials, bool) {
|
||||||
|
username, okUser := ctx.Value(ContextKeyAuthUser).(string)
|
||||||
|
password, okPass := ctx.Value(ContextKeyAuthPass).(string)
|
||||||
|
if !okUser || !okPass {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &Credentials{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAuthToRequest adds authentication credentials to an HTTP request
|
||||||
|
// This should be used when making authenticated requests to internal services
|
||||||
|
func AddAuthToRequest(req *http.Request, creds *Credentials) {
|
||||||
|
if creds != nil {
|
||||||
|
req.SetBasicAuth(creds.Username, creds.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthenticatedRequest creates a new HTTP request with authentication from the source request
|
||||||
|
// This is a convenience method that extracts auth from sourceReq and applies it to the new request
|
||||||
|
func NewAuthenticatedRequest(method, url string, sourceReq *http.Request) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(method, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy authentication from source request
|
||||||
|
if creds, ok := GetCredentials(sourceReq); ok {
|
||||||
|
AddAuthToRequest(req, creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware adds authentication information to the request context
|
||||||
|
// This allows handlers to access auth info without parsing headers repeatedly
|
||||||
|
func Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
creds, ok := GetCredentials(r)
|
||||||
|
if ok {
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = context.WithValue(ctx, ContextKeyUsername, strings.Title(creds.Username))
|
||||||
|
ctx = context.WithValue(ctx, ContextKeyAuthUser, creds.Username)
|
||||||
|
ctx = context.WithValue(ctx, ContextKeyAuthPass, creds.Password)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -348,6 +348,9 @@ type Quote struct {
|
||||||
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"`
|
||||||
|
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
|
||||||
|
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
|
||||||
|
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||||
DeliveryPoint string `json:"delivery_point"`
|
DeliveryPoint string `json:"delivery_point"`
|
||||||
ExchangeRate string `json:"exchange_rate"`
|
ExchangeRate string `json:"exchange_rate"`
|
||||||
CustomsDuty string `json:"customs_duty"`
|
CustomsDuty string `json:"customs_duty"`
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,13 +99,15 @@ 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"`
|
||||||
|
UserEmail sql.NullString `json:"user_email"`
|
||||||
EnquiryID int32 `json:"enquiry_id"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
EnquiryRef string `json:"enquiry_ref"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
|
|
@ -75,6 +116,7 @@ type GetExpiringSoonQuotesRow struct {
|
||||||
ValidUntil time.Time `json:"valid_until"`
|
ValidUntil time.Time `json:"valid_until"`
|
||||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
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,23 +203,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
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type GetExpiringSoonQuotesOnDayParams struct {
|
||||||
|
DATEADD interface{} `json:"DATE_ADD"`
|
||||||
|
DATEADD_2 interface{} `json:"DATE_ADD_2"`
|
||||||
|
}
|
||||||
|
|
||||||
type GetExpiringSoonQuotesOnDayRow struct {
|
type GetExpiringSoonQuotesOnDayRow struct {
|
||||||
DocumentID int32 `json:"document_id"`
|
DocumentID int32 `json:"document_id"`
|
||||||
Username string `json:"username"`
|
Username sql.NullString `json:"username"`
|
||||||
|
UserEmail sql.NullString `json:"user_email"`
|
||||||
EnquiryID int32 `json:"enquiry_id"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
EnquiryRef string `json:"enquiry_ref"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
|
|
@ -177,8 +239,8 @@ type GetExpiringSoonQuotesOnDayRow struct {
|
||||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD)
|
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,13 +393,15 @@ 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"`
|
||||||
|
UserEmail sql.NullString `json:"user_email"`
|
||||||
EnquiryID int32 `json:"enquiry_id"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
EnquiryRef string `json:"enquiry_ref"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
|
|
@ -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,13 +506,15 @@ 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"`
|
||||||
|
UserEmail sql.NullString `json:"user_email"`
|
||||||
EnquiryID int32 `json:"enquiry_id"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
EnquiryRef string `json:"enquiry_ref"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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,6 +87,64 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var msg bytes.Buffer
|
||||||
|
|
||||||
|
// If there are attachments, use multipart message
|
||||||
|
if len(typedAttachments) > 0 {
|
||||||
|
boundary := "boundary123456789"
|
||||||
|
|
||||||
|
// Write headers
|
||||||
|
fmt.Fprintf(&msg, "From: %s\r\n", es.FromAddress)
|
||||||
|
fmt.Fprintf(&msg, "To: %s\r\n", to)
|
||||||
|
if len(ccs) > 0 {
|
||||||
|
fmt.Fprintf(&msg, "Cc: %s\r\n", joinAddresses(ccs))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
|
||||||
|
fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
|
||||||
|
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n", boundary)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Write HTML body part
|
||||||
|
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||||
|
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
msg.Write(body.Bytes())
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Write attachments
|
||||||
|
for _, att := range typedAttachments {
|
||||||
|
file, err := os.Open(att.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open attachment %s: %w", att.FilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileData, err := io.ReadAll(file)
|
||||||
|
file.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read attachment %s: %w", att.FilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||||
|
fmt.Fprintf(&msg, "Content-Type: application/pdf\r\n")
|
||||||
|
fmt.Fprintf(&msg, "Content-Transfer-Encoding: base64\r\n")
|
||||||
|
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(fileData)
|
||||||
|
// Split into lines of 76 characters for proper MIME formatting
|
||||||
|
for i := 0; i < len(encoded); i += 76 {
|
||||||
|
end := i + 76
|
||||||
|
if end > len(encoded) {
|
||||||
|
end = len(encoded)
|
||||||
|
}
|
||||||
|
msg.WriteString(encoded[i:end])
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&msg, "--%s--\r\n", boundary)
|
||||||
|
} else {
|
||||||
|
// Simple message without attachments
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
headers["From"] = es.FromAddress
|
headers["From"] = es.FromAddress
|
||||||
headers["To"] = to
|
headers["To"] = to
|
||||||
|
|
@ -75,12 +155,12 @@ func (es *EmailService) SendTemplateEmail(to string, subject string, templateNam
|
||||||
headers["MIME-Version"] = "1.0"
|
headers["MIME-Version"] = "1.0"
|
||||||
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
|
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
|
||||||
|
|
||||||
var msg bytes.Buffer
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
|
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
|
||||||
}
|
}
|
||||||
msg.WriteString("\r\n")
|
msg.WriteString("\r\n")
|
||||||
msg.Write(body.Bytes())
|
msg.Write(body.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
recipients := []string{to}
|
recipients := []string{to}
|
||||||
recipients = append(recipients, ccs...)
|
recipients = append(recipients, ccs...)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package handlers
|
package attachments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
@ -111,12 +111,16 @@ 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 {
|
||||||
|
// Try plain "file" field name as fallback
|
||||||
|
file, handler, err = r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
37
go/internal/cmc/handlers/attachments/attachments_test.go
Normal file
37
go/internal/cmc/handlers/attachments/attachments_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package attachments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewAttachmentHandler tests that the handler is created correctly
|
||||||
|
func TestNewAttachmentHandler(t *testing.T) {
|
||||||
|
queries := &db.Queries{}
|
||||||
|
handler := NewAttachmentHandler(queries)
|
||||||
|
|
||||||
|
if handler == nil {
|
||||||
|
t.Fatal("Expected handler to be created, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler.queries != queries {
|
||||||
|
t.Fatal("Expected handler.queries to match input queries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Full integration tests with database mocking would require more setup.
|
||||||
|
// For now, this provides basic structure validation.
|
||||||
|
// To run full tests, you would need to:
|
||||||
|
// 1. Create a test database or use an in-memory SQLite database
|
||||||
|
// 2. Run migrations
|
||||||
|
// 3. Test each handler method with actual database calls
|
||||||
|
//
|
||||||
|
// Example test structure for future expansion:
|
||||||
|
// func TestListAttachments(t *testing.T) {
|
||||||
|
// db := setupTestDB(t)
|
||||||
|
// defer db.Close()
|
||||||
|
// queries := db.New(db)
|
||||||
|
// handler := NewAttachmentHandler(queries)
|
||||||
|
// // ... test implementation
|
||||||
|
// }
|
||||||
|
|
@ -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
|
||||||
841
go/internal/cmc/handlers/quotes/quotes.go
Normal file
841
go/internal/cmc/handlers/quotes/quotes.go
Normal file
|
|
@ -0,0 +1,841 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/auth"
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getUsername is a wrapper around auth.GetUsername for backwards compatibility
|
||||||
|
func getUsername(r *http.Request) string {
|
||||||
|
return auth.GetUsername(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: returns date string or empty if zero
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
if t.IsZero() || t.Year() == 1970 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
|
||||||
|
func isValidDBTime(t time.Time) bool {
|
||||||
|
return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
// calcExpiryInfo is a helper to calculate expiry info for a quote
|
||||||
|
func calcExpiryInfo(validUntil time.Time) (string, int, int) {
|
||||||
|
now := time.Now()
|
||||||
|
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
validUntilDate := time.Date(validUntil.Year(), validUntil.Month(), validUntil.Day(), 0, 0, 0, 0, validUntil.Location())
|
||||||
|
daysUntil := int(validUntilDate.Sub(nowDate).Hours() / 24)
|
||||||
|
daysSince := int(nowDate.Sub(validUntilDate).Hours() / 24)
|
||||||
|
var relative string
|
||||||
|
if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) {
|
||||||
|
switch daysUntil {
|
||||||
|
case 0:
|
||||||
|
relative = "expires today"
|
||||||
|
case 1:
|
||||||
|
relative = "expires tomorrow"
|
||||||
|
default:
|
||||||
|
relative = "expires in " + strconv.Itoa(daysUntil) + " days"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch daysSince {
|
||||||
|
case 0:
|
||||||
|
relative = "expired today"
|
||||||
|
case 1:
|
||||||
|
relative = "expired yesterday"
|
||||||
|
default:
|
||||||
|
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return relative, daysUntil, daysSince
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteRow interface for all quote row types
|
||||||
|
// (We use wrapper types since sqlc structs can't be modified directly)
|
||||||
|
type QuoteRow interface {
|
||||||
|
GetID() int32
|
||||||
|
GetUsername() string
|
||||||
|
GetUserEmail() string
|
||||||
|
GetEnquiryID() int32
|
||||||
|
GetEnquiryRef() string
|
||||||
|
GetDateIssued() time.Time
|
||||||
|
GetValidUntil() time.Time
|
||||||
|
GetReminderType() int32
|
||||||
|
GetReminderSent() time.Time
|
||||||
|
GetCustomerName() string
|
||||||
|
GetCustomerEmail() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper types for each DB row struct
|
||||||
|
|
||||||
|
type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow }
|
||||||
|
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetUsername() string {
|
||||||
|
if q.Username.Valid {
|
||||||
|
return q.Username.String
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetUserEmail() string {
|
||||||
|
if q.UserEmail.Valid {
|
||||||
|
return q.UserEmail.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||||
|
func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||||
|
|
||||||
|
type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow }
|
||||||
|
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string {
|
||||||
|
if q.Username.Valid {
|
||||||
|
return q.Username.String
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetUserEmail() string {
|
||||||
|
if q.UserEmail.Valid {
|
||||||
|
return q.UserEmail.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||||
|
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||||
|
|
||||||
|
type ExpiringSoonQuoteOnDayRowWrapper struct {
|
||||||
|
db.GetExpiringSoonQuotesOnDayRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string {
|
||||||
|
if q.Username.Valid {
|
||||||
|
return q.Username.String
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUserEmail() string {
|
||||||
|
if q.UserEmail.Valid {
|
||||||
|
return q.UserEmail.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time {
|
||||||
|
return q.LatestReminderSentTime
|
||||||
|
}
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||||
|
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||||
|
|
||||||
|
type RecentlyExpiredQuoteOnDayRowWrapper struct {
|
||||||
|
db.GetRecentlyExpiredQuotesOnDayRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string {
|
||||||
|
if q.Username.Valid {
|
||||||
|
return q.Username.String
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUserEmail() string {
|
||||||
|
if q.UserEmail.Valid {
|
||||||
|
return q.UserEmail.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time {
|
||||||
|
return q.LatestReminderSentTime
|
||||||
|
}
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
|
||||||
|
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
|
||||||
|
|
||||||
|
// Helper: formats a quote row for output (generic)
|
||||||
|
func formatQuoteRow(q QuoteRow) map[string]interface{} {
|
||||||
|
relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil())
|
||||||
|
return map[string]interface{}{
|
||||||
|
"ID": q.GetID(),
|
||||||
|
"Username": strings.Title(q.GetUsername()),
|
||||||
|
"UserEmail": q.GetUserEmail(),
|
||||||
|
"EnquiryID": q.GetEnquiryID(),
|
||||||
|
"EnquiryRef": q.GetEnquiryRef(),
|
||||||
|
"CustomerName": strings.TrimSpace(q.GetCustomerName()),
|
||||||
|
"CustomerEmail": q.GetCustomerEmail(),
|
||||||
|
"DateIssued": formatDate(q.GetDateIssued()),
|
||||||
|
"ValidUntil": formatDate(q.GetValidUntil()),
|
||||||
|
"ValidUntilRelative": relative,
|
||||||
|
"DaysUntilExpiry": daysUntil,
|
||||||
|
"DaysSinceExpiry": daysSince,
|
||||||
|
"LatestReminderSent": formatDate(q.GetReminderSent()),
|
||||||
|
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteQueries interface {
|
||||||
|
GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error)
|
||||||
|
InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error)
|
||||||
|
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error)
|
||||||
|
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error)
|
||||||
|
GetExpiringSoonQuotesOnDay(ctx context.Context, arg db.GetExpiringSoonQuotesOnDayParams) ([]db.GetExpiringSoonQuotesOnDayRow, error)
|
||||||
|
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
|
||||||
|
DisableQuoteReminders(ctx context.Context, params db.DisableQuoteRemindersParams) (sql.Result, error)
|
||||||
|
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSender interface {
|
||||||
|
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
|
||||||
|
SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuotesHandler struct {
|
||||||
|
queries QuoteQueries
|
||||||
|
tmpl *templates.TemplateManager
|
||||||
|
emailService EmailSender
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuotesHandler(queries QuoteQueries, tmpl *templates.TemplateManager, emailService EmailSender) *QuotesHandler {
|
||||||
|
return &QuotesHandler{
|
||||||
|
queries: queries,
|
||||||
|
tmpl: tmpl,
|
||||||
|
emailService: emailService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Days to look ahead and behind for expiring quotes
|
||||||
|
days := 7
|
||||||
|
|
||||||
|
// Show all quotes that are expiring in the next 7 days
|
||||||
|
expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all quotes that have expired in the last 60 days
|
||||||
|
recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
|
||||||
|
"ExpiringSoonQuotes": expiringSoonQuotes,
|
||||||
|
"User": getUsername(r),
|
||||||
|
}
|
||||||
|
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOutstandingQuotes returns outstanding quotes based on daysUntilExpiry.
|
||||||
|
func (h *QuotesHandler) GetOutstandingQuotes(r *http.Request, daysUntilExpiry int) ([]map[string]interface{}, error) {
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
ctx := r.Context()
|
||||||
|
// If daysUntilExpiry is positive, get quotes expiring soon; if negative, get recently expired quotes
|
||||||
|
if daysUntilExpiry >= 0 {
|
||||||
|
quotes, err := h.queries.GetExpiringSoonQuotes(ctx, daysUntilExpiry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, q := range quotes {
|
||||||
|
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteRowWrapper{q}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
days := -daysUntilExpiry
|
||||||
|
quotes, err := h.queries.GetRecentlyExpiredQuotes(ctx, days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, q := range quotes {
|
||||||
|
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteRowWrapper{q}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOutstandingQuotesOnDay returns quotes expiring exactly N days from today (if day >= 0), or exactly N days ago (if day < 0).
|
||||||
|
func (h *QuotesHandler) GetOutstandingQuotesOnDay(r *http.Request, day int) ([]map[string]interface{}, error) {
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
ctx := r.Context()
|
||||||
|
// If day is positive, get quotes expiring on that day; if negative, get recently expired quotes on that day in the past
|
||||||
|
if day >= 0 {
|
||||||
|
quotes, err := h.queries.GetExpiringSoonQuotesOnDay(ctx, db.GetExpiringSoonQuotesOnDayParams{
|
||||||
|
DATEADD: day,
|
||||||
|
DATEADD_2: day,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, q := range quotes {
|
||||||
|
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteOnDayRowWrapper{q}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
days := -day
|
||||||
|
quotes, err := h.queries.GetRecentlyExpiredQuotesOnDay(ctx, days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, q := range quotes {
|
||||||
|
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteOnDayRowWrapper{q}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteReminderType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FirstReminder QuoteReminderType = 1
|
||||||
|
SecondReminder QuoteReminderType = 2
|
||||||
|
ThirdReminder QuoteReminderType = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t QuoteReminderType) String() string {
|
||||||
|
switch t {
|
||||||
|
case FirstReminder:
|
||||||
|
return "FirstReminder"
|
||||||
|
case SecondReminder:
|
||||||
|
return "SecondReminder"
|
||||||
|
case ThirdReminder:
|
||||||
|
return "ThirdReminder"
|
||||||
|
default:
|
||||||
|
return "UnknownReminder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteReminderJob struct {
|
||||||
|
DayOffset int
|
||||||
|
ReminderType QuoteReminderType
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReminderDetails returns the subject and template for a given reminder type
|
||||||
|
func getReminderDetails(reminderType QuoteReminderType) (subject, template string) {
|
||||||
|
switch reminderType {
|
||||||
|
case FirstReminder:
|
||||||
|
return "Reminder: Quote %s Expires Soon", "first_reminder.html"
|
||||||
|
case SecondReminder:
|
||||||
|
return "Follow-Up: Quote %s Expired", "second_reminder.html"
|
||||||
|
case ThirdReminder:
|
||||||
|
return "Final Reminder: Quote %s Closed", "final_reminder.html"
|
||||||
|
default:
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReminderDetailsManual returns the subject and template for a manually sent reminder (generic templates without time references)
|
||||||
|
func getReminderDetailsManual(reminderType QuoteReminderType) (subject, template string) {
|
||||||
|
switch reminderType {
|
||||||
|
case FirstReminder:
|
||||||
|
return "Reminder: Quote %s Expires Soon", "manual_first_reminder.html"
|
||||||
|
case SecondReminder:
|
||||||
|
return "Follow-Up: Quote %s Expired", "manual_second_reminder.html"
|
||||||
|
case ThirdReminder:
|
||||||
|
return "Final Reminder: Quote %s Closed", "manual_final_reminder.html"
|
||||||
|
default:
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatQuoteDates formats ISO date strings to DD/MM/YYYY format
|
||||||
|
func formatQuoteDates(dateIssuedStr, validUntilStr string) (submissionDate, expiryDate string) {
|
||||||
|
if dateIssuedStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, dateIssuedStr); err == nil {
|
||||||
|
submissionDate = t.Format("02/01/2006")
|
||||||
|
} else {
|
||||||
|
submissionDate = dateIssuedStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validUntilStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, validUntilStr); err == nil {
|
||||||
|
expiryDate = t.Format("02/01/2006")
|
||||||
|
} else {
|
||||||
|
expiryDate = validUntilStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareReminderEmail prepares all data needed to send a reminder email
|
||||||
|
func prepareReminderEmail(reminderType QuoteReminderType, customerName, dateIssuedStr, validUntilStr, enquiryRef, userEmail string) (subject, template string, templateData map[string]interface{}, ccs []string) {
|
||||||
|
subject, template = getReminderDetails(reminderType)
|
||||||
|
subject = fmt.Sprintf(subject, enquiryRef)
|
||||||
|
|
||||||
|
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
|
||||||
|
|
||||||
|
templateData = map[string]interface{}{
|
||||||
|
"CustomerName": customerName,
|
||||||
|
"SubmissionDate": submissionDate,
|
||||||
|
"ExpiryDate": expiryDate,
|
||||||
|
"QuoteRef": enquiryRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
ccs = []string{"sales@cmctechnologies.com.au"}
|
||||||
|
if userEmail != "" {
|
||||||
|
ccs = append(ccs, userEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyQuoteExpirationCheck checks quotes for reminders and expiry notices (callable as a cron job from main)
|
||||||
|
func (h *QuotesHandler) DailyQuoteExpirationCheck() {
|
||||||
|
fmt.Println("Running DailyQuoteExpirationCheck...")
|
||||||
|
|
||||||
|
reminderJobs := []quoteReminderJob{
|
||||||
|
{7, FirstReminder},
|
||||||
|
{-7, SecondReminder},
|
||||||
|
{-60, ThirdReminder},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, job := range reminderJobs {
|
||||||
|
quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(quotes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, q := range quotes {
|
||||||
|
subject, template, templateData, ccs := prepareReminderEmail(
|
||||||
|
job.ReminderType,
|
||||||
|
q["CustomerName"].(string),
|
||||||
|
q["DateIssued"].(string),
|
||||||
|
q["ValidUntil"].(string),
|
||||||
|
q["EnquiryRef"].(string),
|
||||||
|
q["UserEmail"].(string),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Construct PDF path from filesystem
|
||||||
|
enquiryRef := q["EnquiryRef"].(string)
|
||||||
|
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
|
||||||
|
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
|
||||||
|
|
||||||
|
err := h.SendQuoteReminderEmailWithPDF(
|
||||||
|
context.Background(),
|
||||||
|
q["ID"].(int32),
|
||||||
|
job.ReminderType,
|
||||||
|
q["CustomerEmail"].(string),
|
||||||
|
subject,
|
||||||
|
template,
|
||||||
|
templateData,
|
||||||
|
ccs,
|
||||||
|
nil,
|
||||||
|
pdfPath,
|
||||||
|
pdfFilename,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
|
||||||
|
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, allowDuplicate bool) error {
|
||||||
|
// Safeguard: check for valid recipient
|
||||||
|
if strings.TrimSpace(recipient) == "" {
|
||||||
|
return fmt.Errorf("recipient email is required")
|
||||||
|
}
|
||||||
|
// Safeguard: check for valid template data
|
||||||
|
if templateData == nil {
|
||||||
|
return fmt.Errorf("template data is required")
|
||||||
|
}
|
||||||
|
// Safeguard: check for valid reminder type
|
||||||
|
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
|
||||||
|
return fmt.Errorf("invalid reminder type: %v", reminderType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reminder already sent (only if duplicates not allowed)
|
||||||
|
if !allowDuplicate {
|
||||||
|
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
|
||||||
|
QuoteID: quoteID,
|
||||||
|
ReminderType: int32(reminderType),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing reminders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if the email has already been sent
|
||||||
|
if len(reminders) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
err := h.emailService.SendTemplateEmail(
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
templateName,
|
||||||
|
templateData,
|
||||||
|
ccs, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the reminder
|
||||||
|
var user sql.NullString
|
||||||
|
if username != nil {
|
||||||
|
user = sql.NullString{String: *username, Valid: true}
|
||||||
|
} else {
|
||||||
|
user = sql.NullString{Valid: false}
|
||||||
|
}
|
||||||
|
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
|
||||||
|
QuoteID: quoteID,
|
||||||
|
ReminderType: int32(reminderType),
|
||||||
|
DateSent: time.Now().UTC(),
|
||||||
|
Username: user,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to record reminder: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendQuoteReminderEmailWithPDF sends a reminder email with PDF attachment loaded from filesystem
|
||||||
|
func (h *QuotesHandler) SendQuoteReminderEmailWithPDF(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename string) error {
|
||||||
|
// Safeguard: check for valid recipient
|
||||||
|
if strings.TrimSpace(recipient) == "" {
|
||||||
|
return fmt.Errorf("recipient email is required")
|
||||||
|
}
|
||||||
|
// Safeguard: check for valid template data
|
||||||
|
if templateData == nil {
|
||||||
|
return fmt.Errorf("template data is required")
|
||||||
|
}
|
||||||
|
// Safeguard: check for valid reminder type
|
||||||
|
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
|
||||||
|
return fmt.Errorf("invalid reminder type: %v", reminderType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reminder already sent
|
||||||
|
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
|
||||||
|
QuoteID: quoteID,
|
||||||
|
ReminderType: int32(reminderType),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing reminders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if the email has already been sent
|
||||||
|
if len(reminders) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare PDF attachment if file exists
|
||||||
|
var attachments []interface{}
|
||||||
|
if _, err := os.Stat(pdfPath); err == nil {
|
||||||
|
attachments = []interface{}{
|
||||||
|
struct {
|
||||||
|
Filename string
|
||||||
|
FilePath string
|
||||||
|
}{
|
||||||
|
Filename: pdfFilename,
|
||||||
|
FilePath: pdfPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log.Printf("Attaching PDF for quote %d: %s", quoteID, pdfPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("PDF not found for quote %d at %s, sending without attachment", quoteID, pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email (with or without attachment)
|
||||||
|
err = h.emailService.SendTemplateEmailWithAttachments(
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
templateName,
|
||||||
|
templateData,
|
||||||
|
ccs,
|
||||||
|
nil,
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the reminder
|
||||||
|
var user sql.NullString
|
||||||
|
if username != nil {
|
||||||
|
user = sql.NullString{String: *username, Valid: true}
|
||||||
|
} else {
|
||||||
|
user = sql.NullString{Valid: false}
|
||||||
|
}
|
||||||
|
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
|
||||||
|
QuoteID: quoteID,
|
||||||
|
ReminderType: int32(reminderType),
|
||||||
|
DateSent: time.Now().UTC(),
|
||||||
|
Username: user,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to record reminder: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendManualReminder handles POST requests to manually send a quote reminder
|
||||||
|
func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parameters
|
||||||
|
quoteIDStr := r.FormValue("quote_id")
|
||||||
|
reminderTypeStr := r.FormValue("reminder_type")
|
||||||
|
customerEmail := r.FormValue("customer_email")
|
||||||
|
userEmail := r.FormValue("user_email")
|
||||||
|
enquiryRef := r.FormValue("enquiry_ref")
|
||||||
|
customerName := r.FormValue("customer_name")
|
||||||
|
dateIssuedStr := r.FormValue("date_issued")
|
||||||
|
validUntilStr := r.FormValue("valid_until")
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if quoteIDStr == "" || reminderTypeStr == "" || customerEmail == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reminderTypeInt, err := strconv.Atoi(reminderTypeStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid reminder type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reminderType := QuoteReminderType(reminderTypeInt)
|
||||||
|
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
|
||||||
|
http.Error(w, "Invalid reminder type value", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use manual template for manually sent reminders (generic without time references)
|
||||||
|
subject, template := getReminderDetailsManual(reminderType)
|
||||||
|
subject = fmt.Sprintf(subject, enquiryRef)
|
||||||
|
|
||||||
|
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
|
||||||
|
|
||||||
|
templateData := map[string]interface{}{
|
||||||
|
"CustomerName": customerName,
|
||||||
|
"SubmissionDate": submissionDate,
|
||||||
|
"ExpiryDate": expiryDate,
|
||||||
|
"QuoteRef": enquiryRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
ccs := []string{"sales@cmctechnologies.com.au"}
|
||||||
|
if userEmail != "" {
|
||||||
|
ccs = append(ccs, userEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach PDF quote from filesystem
|
||||||
|
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
|
||||||
|
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
|
||||||
|
|
||||||
|
// Get username from request
|
||||||
|
username := getUsername(r)
|
||||||
|
usernamePtr := &username
|
||||||
|
|
||||||
|
// Send the reminder with attachment
|
||||||
|
err = h.SendQuoteReminderEmailWithPDF(
|
||||||
|
r.Context(),
|
||||||
|
int32(quoteID),
|
||||||
|
reminderType,
|
||||||
|
customerEmail,
|
||||||
|
subject,
|
||||||
|
template,
|
||||||
|
templateData,
|
||||||
|
ccs,
|
||||||
|
usernamePtr,
|
||||||
|
pdfPath,
|
||||||
|
pdfFilename,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send manual reminder for quote %d: %v", quoteID, err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to send reminder: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an AJAX request
|
||||||
|
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Reminder sent successfully",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to quotes page for non-AJAX requests
|
||||||
|
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableReminders handles POST requests to disable automatic reminders for a quote
|
||||||
|
func (h *QuotesHandler) DisableReminders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteIDStr := r.FormValue("quote_id")
|
||||||
|
if quoteIDStr == "" {
|
||||||
|
http.Error(w, "Missing quote_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get username from request
|
||||||
|
username := getUsername(r)
|
||||||
|
|
||||||
|
// Update the database to disable reminders
|
||||||
|
_, err = h.queries.DisableQuoteReminders(r.Context(), db.DisableQuoteRemindersParams{
|
||||||
|
RemindersDisabledBy: sql.NullString{String: username, Valid: true},
|
||||||
|
ID: int32(quoteID),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to disable reminders for quote %d: %v", quoteID, err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to disable reminders: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an AJAX request
|
||||||
|
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Reminders disabled successfully",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to quotes page for non-AJAX requests
|
||||||
|
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableReminders handles POST requests to re-enable automatic reminders for a quote
|
||||||
|
func (h *QuotesHandler) EnableReminders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteIDStr := r.FormValue("quote_id")
|
||||||
|
if quoteIDStr == "" {
|
||||||
|
http.Error(w, "Missing quote_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the database to enable reminders
|
||||||
|
_, err = h.queries.EnableQuoteReminders(r.Context(), int32(quoteID))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to enable reminders for quote %d: %v", quoteID, err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to enable reminders: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an AJAX request
|
||||||
|
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Reminders enabled successfully",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to quotes page for non-AJAX requests
|
||||||
|
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: get reminder type as string
|
||||||
|
func reminderTypeString(reminderType int) string {
|
||||||
|
switch reminderType {
|
||||||
|
case 0:
|
||||||
|
return "No Reminder"
|
||||||
|
case 1:
|
||||||
|
return "First Reminder"
|
||||||
|
case 2:
|
||||||
|
return "Second Reminder"
|
||||||
|
case 3:
|
||||||
|
return "Final Reminder"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
42
go/sql/migrations/002_create_quotes_tables.sql
Normal file
42
go/sql/migrations/002_create_quotes_tables.sql
Normal 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`;
|
||||||
22
go/sql/migrations/003_add_reminders_disabled_to_quotes.sql
Normal file
22
go/sql/migrations/003_add_reminders_disabled_to_quotes.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -215,3 +260,22 @@ 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 = ?;
|
||||||
|
|
@ -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
Loading…
Reference in a new issue