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/attachments_files/*
|
||||
backups/*
|
||||
|
||||
# Go binaries
|
||||
go/server
|
||||
go/vault
|
||||
go/go.mod
|
||||
go/go.sum
|
||||
go/goose.env
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
CMC Sales is a B2B sales management system for CMC Technologies. The codebase consists of:
|
||||
|
||||
- **Legacy CakePHP 1.2.5 application** (2008-era) - Primary business logic
|
||||
- **Modern Go API** (in `/go-app/`) - New development using sqlc and Gorilla Mux
|
||||
- **Legacy CakePHP 1.2.5 application** (in `/php/`) - Primary business logic
|
||||
- **Modern Go API** (in `/go/`) - New development using sqlc and Gorilla Mux
|
||||
|
||||
**Note**: Documentation also references a Django application that is not present in the current codebase.
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
|
|||
### Go Application Development
|
||||
```bash
|
||||
# Navigate to Go app directory
|
||||
cd go-app
|
||||
cd go
|
||||
|
||||
# Configure private module access (first time only)
|
||||
go env -w GOPRIVATE=code.springupsoftware.com
|
||||
|
|
@ -80,7 +80,7 @@ make build
|
|||
### Go Application (Modern)
|
||||
- **Framework**: Gorilla Mux (HTTP router)
|
||||
- **Database**: sqlc for type-safe SQL queries
|
||||
- **Location**: `/go-app/`
|
||||
- **Location**: `/go/`
|
||||
- **Structure**:
|
||||
- `cmd/server/` - Main application entry point
|
||||
- `internal/cmc/handlers/` - HTTP request handlers
|
||||
|
|
|
|||
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
|
||||
|
||||
# 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
|
||||
WORKDIR /app
|
||||
COPY go-app/go.mod go-app/go.sum ./
|
||||
COPY go/go.mod go/go.sum ./
|
||||
RUN go mod download
|
||||
COPY go-app/ .
|
||||
COPY go/ .
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
RUN sqlc generate
|
||||
RUN go mod tidy
|
||||
|
|
@ -16,8 +16,8 @@ RUN apk --no-cache add ca-certificates tzdata
|
|||
WORKDIR /root/
|
||||
COPY --from=builder /app/server .
|
||||
COPY --from=builder /app/vault .
|
||||
COPY go-app/templates ./templates
|
||||
COPY go-app/static ./static
|
||||
COPY go-app/.env.example .env
|
||||
COPY go/templates ./templates
|
||||
COPY go/static ./static
|
||||
COPY go/.env.example .env
|
||||
EXPOSE 8082
|
||||
CMD ["./server"]
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ RUN chmod +x /bin/ripmime \
|
|||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir /var/www/cmc-sales/app/tmp/logs
|
||||
ADD php/ /var/www/cmc-sales
|
||||
ADD php/app/config/database.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
|
||||
# Ensure CakePHP tmp directory is writable by web server
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ FROM golang:1.24-alpine AS builder
|
|||
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY go-app/go.mod go-app/go.sum ./
|
||||
COPY go/go.mod go/go.sum ./
|
||||
RUN go mod download
|
||||
COPY go-app/ .
|
||||
COPY go/ .
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
RUN sqlc generate
|
||||
RUN go mod tidy
|
||||
|
|
@ -14,8 +14,8 @@ FROM alpine:latest
|
|||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/server .
|
||||
COPY go-app/templates ./templates
|
||||
COPY go-app/static ./static
|
||||
COPY go-app/.env.example .env
|
||||
COPY go/templates ./templates
|
||||
COPY go/static ./static
|
||||
COPY go/.env.example .env
|
||||
EXPOSE 8082
|
||||
CMD ["./server"]
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ RUN chmod +x /bin/ripmime \
|
|||
&& a2enmod headers
|
||||
|
||||
# Copy site into place.
|
||||
ADD . /var/www/cmc-sales
|
||||
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
|
||||
ADD php/ /var/www/cmc-sales
|
||||
ADD php/app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp
|
||||
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
|
||||
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ Deploy to staging or production using the scripts in the `deploy/` directory:
|
|||
|
||||
**Deploy to Staging:**
|
||||
```bash
|
||||
./deploy/deploy-stg.sh
|
||||
./scripts/deploy/deploy-stg.sh
|
||||
```
|
||||
|
||||
**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):**
|
||||
```bash
|
||||
./deploy/deploy-prod.sh --no-cache
|
||||
./deploy/deploy-stg.sh --no-cache
|
||||
./scripts/deploy/deploy-prod.sh --no-cache
|
||||
./scripts/deploy/deploy-stg.sh --no-cache
|
||||
```
|
||||
|
||||
### 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_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-go:8080;
|
||||
set $upstream_go cmc-go:8080;
|
||||
proxy_pass http://$upstream_go;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -11,7 +16,8 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://cmc-php:80;
|
||||
set $upstream_php cmc-php:80;
|
||||
proxy_pass http://$upstream_php;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ server {
|
|||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-prod-go:8082;
|
||||
proxy_read_timeout 300s;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ server {
|
|||
server_name cmclocal;
|
||||
auth_basic_user_file /etc/nginx/userpasswd;
|
||||
auth_basic "Restricted";
|
||||
client_max_body_size 200M;
|
||||
location /go/ {
|
||||
proxy_pass http://cmc-stg-go:8082;
|
||||
proxy_read_timeout 300s;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ services:
|
|||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
GO_APP_HOST: cmc-prod-go:8082
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
|
|
@ -81,8 +82,8 @@ services:
|
|||
- "8083:8082"
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/root/webroot/pdf:ro
|
||||
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
|
||||
- /home/cmc/files/emails:/var/www/emails
|
||||
- /home/cmc/files/attachments_files:/var/www/attachments_files
|
||||
- /home/cmc/files/vault:/var/www/vault
|
||||
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ services:
|
|||
DB_USER: cmc
|
||||
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
|
||||
DB_NAME: cmc
|
||||
GO_APP_HOST: cmc-stg-go:8082
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
|
|
@ -79,6 +80,7 @@ services:
|
|||
- "8082:8082"
|
||||
volumes:
|
||||
- /home/cmc/files/pdf:/root/webroot/pdf:ro
|
||||
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
|
||||
networks:
|
||||
- cmc-stg-network
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ services:
|
|||
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
|
||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||
depends_on:
|
||||
- cmc-php
|
||||
cmc-php:
|
||||
condition: service_started
|
||||
cmc-go:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cmc-network
|
||||
|
|
@ -16,30 +19,32 @@ services:
|
|||
cmc-php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.stg.php
|
||||
dockerfile: Dockerfile.local.php
|
||||
platform: linux/amd64
|
||||
container_name: cmc-php
|
||||
environment:
|
||||
GO_APP_HOST: cmc-go:8080
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||
networks:
|
||||
- cmc-network
|
||||
restart: unless-stopped
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./app
|
||||
path: ./php/app
|
||||
ignore:
|
||||
- ./app/webroot/pdf
|
||||
- ./app/webroot/attachments_files
|
||||
- ./app/tmp
|
||||
- ./php/app/webroot/pdf
|
||||
- ./php/app/webroot/attachments_files
|
||||
- ./php/app/tmp
|
||||
- action: sync
|
||||
path: ./app/webroot/css
|
||||
path: ./php/app/webroot/css
|
||||
target: /var/www/cmc-sales/app/webroot/css
|
||||
- action: sync
|
||||
path: ./app/webroot/js
|
||||
path: ./php/app/webroot/js
|
||||
target: /var/www/cmc-sales/app/webroot/js
|
||||
|
||||
db:
|
||||
|
|
@ -75,9 +80,9 @@ services:
|
|||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./go-app:/app
|
||||
- ./go-app/.air.toml:/root/.air.toml
|
||||
- ./go-app/.env.example:/root/.env
|
||||
- ./go:/app
|
||||
- ./go/.air.toml:/root/.air.toml
|
||||
- ./go/.env.example:/root/.env
|
||||
networks:
|
||||
- cmc-network
|
||||
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/email"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
|
||||
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||
"github.com/go-co-op/gocron"
|
||||
|
|
@ -60,6 +61,7 @@ func main() {
|
|||
|
||||
// Load handlers
|
||||
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
|
||||
attachmentHandler := attachments.NewAttachmentHandler(queries)
|
||||
|
||||
// Setup routes
|
||||
r := mux.NewRouter()
|
||||
|
|
@ -73,6 +75,14 @@ func main() {
|
|||
|
||||
// Quote routes
|
||||
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
|
||||
goRouter.HandleFunc("/quotes/send-reminder", quoteHandler.SendManualReminder).Methods("POST")
|
||||
goRouter.HandleFunc("/quotes/disable-reminders", quoteHandler.DisableReminders).Methods("POST")
|
||||
goRouter.HandleFunc("/quotes/enable-reminders", quoteHandler.EnableReminders).Methods("POST")
|
||||
|
||||
// Attachment routes
|
||||
goRouter.HandleFunc("/attachments/upload", attachmentHandler.Create).Methods("POST")
|
||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
||||
|
||||
// The following routes are currently disabled:
|
||||
/*
|
||||
|
|
@ -27,6 +27,8 @@ Processes emails from local filesystem directories.
|
|||
--dbname=cmc
|
||||
```
|
||||
|
||||
**Note:** The `emaildir` should be set to `/var/www/emails` to match the legacy directory structure expected by the CakePHP application.
|
||||
|
||||
### 2. Gmail Index Mode
|
||||
Indexes Gmail emails without downloading content. Creates database references only.
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -342,17 +342,20 @@ type Quote struct {
|
|||
// limited at 5 digits. Really, you're not going to have more revisions of a single quote than that
|
||||
Revision int32 `json:"revision"`
|
||||
// estimated delivery time for quote
|
||||
DeliveryTime string `json:"delivery_time"`
|
||||
DeliveryTimeFrame string `json:"delivery_time_frame"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
DaysValid int32 `json:"days_valid"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
DeliveryPoint string `json:"delivery_point"`
|
||||
ExchangeRate string `json:"exchange_rate"`
|
||||
CustomsDuty string `json:"customs_duty"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
CommercialComments sql.NullString `json:"commercial_comments"`
|
||||
DeliveryTime string `json:"delivery_time"`
|
||||
DeliveryTimeFrame string `json:"delivery_time_frame"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
DaysValid int32 `json:"days_valid"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
|
||||
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
DeliveryPoint string `json:"delivery_point"`
|
||||
ExchangeRate string `json:"exchange_rate"`
|
||||
CustomsDuty string `json:"customs_duty"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
CommercialComments sql.NullString `json:"commercial_comments"`
|
||||
}
|
||||
|
||||
type QuoteReminder struct {
|
||||
|
|
@ -43,6 +43,8 @@ type Querier interface {
|
|||
DeletePurchaseOrder(ctx context.Context, id int32) error
|
||||
DeleteState(ctx context.Context, id int32) error
|
||||
DeleteStatus(ctx context.Context, id int32) error
|
||||
DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error)
|
||||
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
|
||||
GetAddress(ctx context.Context, id int32) (Address, error)
|
||||
GetAllCountries(ctx context.Context) ([]Country, error)
|
||||
GetAllPrinciples(ctx context.Context) ([]Principle, error)
|
||||
|
|
@ -61,7 +63,7 @@ type Querier interface {
|
|||
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
|
||||
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
|
||||
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
|
||||
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error)
|
||||
GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error)
|
||||
GetLineItem(ctx context.Context, id int32) (LineItem, error)
|
||||
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
|
||||
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
|
||||
|
|
@ -76,6 +78,7 @@ type Querier interface {
|
|||
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
|
||||
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
|
||||
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
|
||||
GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error)
|
||||
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
|
||||
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
|
||||
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)
|
||||
|
|
@ -11,8 +11,45 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const disableQuoteReminders = `-- name: DisableQuoteReminders :execresult
|
||||
UPDATE quotes
|
||||
SET reminders_disabled = TRUE,
|
||||
reminders_disabled_at = NOW(),
|
||||
reminders_disabled_by = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type DisableQuoteRemindersParams struct {
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, disableQuoteReminders, arg.RemindersDisabledBy, arg.ID)
|
||||
}
|
||||
|
||||
const enableQuoteReminders = `-- name: EnableQuoteReminders :execresult
|
||||
UPDATE quotes
|
||||
SET reminders_disabled = FALSE,
|
||||
reminders_disabled_at = NULL,
|
||||
reminders_disabled_by = NULL
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, enableQuoteReminders, id)
|
||||
}
|
||||
|
||||
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
|
||||
WITH 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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -35,10 +72,10 @@ latest_quote_reminder AS (
|
|||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -46,13 +83,15 @@ SELECT
|
|||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time,
|
||||
COALESCE(q.reminders_disabled, FALSE) AS reminders_disabled
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
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 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
|
||||
|
||||
|
|
@ -60,21 +99,24 @@ WHERE
|
|||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
RemindersDisabled bool `json:"reminders_disabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
|
||||
|
|
@ -89,6 +131,7 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
|
|||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.UserEmail,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
|
|
@ -97,6 +140,7 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
|
|||
&i.ValidUntil,
|
||||
&i.LatestReminderType,
|
||||
&i.LatestReminderSentTime,
|
||||
&i.RemindersDisabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -112,7 +156,15 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
|
|||
}
|
||||
|
||||
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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -139,6 +191,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -150,35 +203,44 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 users uu ON uu.id = e.contact_user_id
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until
|
||||
`
|
||||
|
||||
type GetExpiringSoonQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
type GetExpiringSoonQuotesOnDayParams struct {
|
||||
DATEADD interface{} `json:"DATE_ADD"`
|
||||
DATEADD_2 interface{} `json:"DATE_ADD_2"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD)
|
||||
type GetExpiringSoonQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, arg.DATEADD, arg.DATEADD_2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -189,6 +251,7 @@ func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interf
|
|||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.UserEmail,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
|
|
@ -252,8 +315,35 @@ func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemin
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getQuoteRemindersDisabled = `-- name: GetQuoteRemindersDisabled :one
|
||||
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
|
||||
FROM quotes
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type GetQuoteRemindersDisabledRow struct {
|
||||
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
|
||||
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
|
||||
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getQuoteRemindersDisabled, id)
|
||||
var i GetQuoteRemindersDisabledRow
|
||||
err := row.Scan(&i.RemindersDisabled, &i.RemindersDisabledAt, &i.RemindersDisabledBy)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
|
||||
WITH 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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -280,6 +370,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -291,9 +382,10 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 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
|
||||
|
||||
|
|
@ -301,21 +393,23 @@ WHERE
|
|||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) {
|
||||
|
|
@ -330,6 +424,7 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
|
|||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.UserEmail,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
|
|
@ -353,7 +448,15 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
|
|||
}
|
||||
|
||||
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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -380,6 +483,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -391,9 +495,10 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 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
|
||||
|
||||
|
|
@ -401,21 +506,23 @@ WHERE
|
|||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC
|
||||
`
|
||||
|
||||
type GetRecentlyExpiredQuotesOnDayRow struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username string `json:"username"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
DocumentID int32 `json:"document_id"`
|
||||
Username sql.NullString `json:"username"`
|
||||
UserEmail sql.NullString `json:"user_email"`
|
||||
EnquiryID int32 `json:"enquiry_id"`
|
||||
EnquiryRef string `json:"enquiry_ref"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
DateIssued time.Time `json:"date_issued"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
LatestReminderType int32 `json:"latest_reminder_type"`
|
||||
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
|
||||
|
|
@ -430,6 +537,7 @@ func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB int
|
|||
if err := rows.Scan(
|
||||
&i.DocumentID,
|
||||
&i.Username,
|
||||
&i.UserEmail,
|
||||
&i.EnquiryID,
|
||||
&i.EnquiryRef,
|
||||
&i.CustomerName,
|
||||
|
|
@ -3,14 +3,22 @@ package email
|
|||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Attachment represents an email attachment
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
var (
|
||||
emailServiceInstance *EmailService
|
||||
once sync.Once
|
||||
|
|
@ -50,7 +58,21 @@ func GetEmailService() *EmailService {
|
|||
|
||||
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
|
||||
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
|
||||
defaultBccs := []string{"carpis@cmctechnologies.com.au", "mcarpis@cmctechnologies.com.au"}
|
||||
return es.SendTemplateEmailWithAttachments(to, subject, templateName, data, ccs, bccs, nil)
|
||||
}
|
||||
|
||||
// SendTemplateEmailWithAttachments renders a template and sends an email with optional CC, BCC, and attachments.
|
||||
func (es *EmailService) SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error {
|
||||
// Convert interface{} attachments to []Attachment
|
||||
var typedAttachments []Attachment
|
||||
for _, att := range attachments {
|
||||
if a, ok := att.(Attachment); ok {
|
||||
typedAttachments = append(typedAttachments, a)
|
||||
} else if a, ok := att.(struct{ Filename, FilePath string }); ok {
|
||||
typedAttachments = append(typedAttachments, Attachment{Filename: a.Filename, FilePath: a.FilePath})
|
||||
}
|
||||
}
|
||||
defaultBccs := []string{"carpis@cmctechnologies.com.au"}
|
||||
bccs = append(defaultBccs, bccs...)
|
||||
|
||||
const templateDir = "templates/quotes"
|
||||
|
|
@ -65,22 +87,80 @@ func (es *EmailService) SendTemplateEmail(to string, subject string, templateNam
|
|||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = es.FromAddress
|
||||
headers["To"] = to
|
||||
if len(ccs) > 0 {
|
||||
headers["Cc"] = joinAddresses(ccs)
|
||||
}
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
|
||||
|
||||
var msg bytes.Buffer
|
||||
for k, v := range headers {
|
||||
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
|
||||
|
||||
// If there are attachments, use multipart message
|
||||
if len(typedAttachments) > 0 {
|
||||
boundary := "boundary123456789"
|
||||
|
||||
// Write headers
|
||||
fmt.Fprintf(&msg, "From: %s\r\n", es.FromAddress)
|
||||
fmt.Fprintf(&msg, "To: %s\r\n", to)
|
||||
if len(ccs) > 0 {
|
||||
fmt.Fprintf(&msg, "Cc: %s\r\n", joinAddresses(ccs))
|
||||
}
|
||||
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
|
||||
fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n", boundary)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Write HTML body part
|
||||
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Write attachments
|
||||
for _, att := range typedAttachments {
|
||||
file, err := os.Open(att.FilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open attachment %s: %w", att.FilePath, err)
|
||||
}
|
||||
|
||||
fileData, err := io.ReadAll(file)
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read attachment %s: %w", att.FilePath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&msg, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&msg, "Content-Type: application/pdf\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Transfer-Encoding: base64\r\n")
|
||||
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(fileData)
|
||||
// Split into lines of 76 characters for proper MIME formatting
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
msg.WriteString(encoded[i:end])
|
||||
msg.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&msg, "--%s--\r\n", boundary)
|
||||
} else {
|
||||
// Simple message without attachments
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = es.FromAddress
|
||||
headers["To"] = to
|
||||
if len(ccs) > 0 {
|
||||
headers["Cc"] = joinAddresses(ccs)
|
||||
}
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
|
||||
|
||||
for k, v := range headers {
|
||||
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
|
||||
recipients := []string{to}
|
||||
recipients = append(recipients, ccs...)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package handlers
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
|
@ -111,11 +111,15 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get file from form
|
||||
file, handler, err := r.FormFile("file")
|
||||
// Get file from form - try both field names (CakePHP format and plain)
|
||||
file, handler, err := r.FormFile("data[Attachment][file]")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
// Try plain "file" field name as fallback
|
||||
file, handler, err = r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -131,8 +135,8 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Save file to disk
|
||||
filepath := filepath.Join(attachDir, filename)
|
||||
dst, err := os.Create(filepath)
|
||||
filePath := filepath.Join(attachDir, filename)
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -144,23 +148,40 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse principle_id
|
||||
// Parse principle_id - try CakePHP format first, then fallback
|
||||
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 {
|
||||
principleID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Get other form values - try CakePHP format first, then fallback
|
||||
name := r.FormValue("data[Attachment][name]")
|
||||
if name == "" {
|
||||
name = r.FormValue("name")
|
||||
}
|
||||
|
||||
description := r.FormValue("data[Attachment][description]")
|
||||
if description == "" {
|
||||
description = r.FormValue("description")
|
||||
}
|
||||
|
||||
// Create database record
|
||||
// Store path in PHP format: /var/www/cmc-sales/app/webroot/attachments_files/filename
|
||||
phpPath := "/var/www/cmc-sales/app/webroot/attachments_files/" + filename
|
||||
params := db.CreateAttachmentParams{
|
||||
PrincipleID: int32(principleID),
|
||||
Name: r.FormValue("name"),
|
||||
Name: name,
|
||||
Filename: handler.Filename,
|
||||
File: filename,
|
||||
File: phpPath, // Store PHP container path for compatibility
|
||||
Type: handler.Header.Get("Content-Type"),
|
||||
Size: int32(handler.Size),
|
||||
Description: r.FormValue("description"),
|
||||
Description: description,
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
// Clean up file on error
|
||||
os.Remove(filepath)
|
||||
os.Remove(filePath)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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"
|
||||
"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"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
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
|
||||
func getUsername(r *http.Request) string {
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok && username != "" {
|
||||
caser := cases.Title(language.English)
|
||||
return caser.String(username) // Capitalise the username for display
|
||||
}
|
||||
return "Guest"
|
||||
return auth.GetUsername(r)
|
||||
}
|
||||
|
||||
// Home page
|
||||
|
|
@ -882,16 +876,16 @@ func (h *PageHandler) EmailsIndex(w http.ResponseWriter, r *http.Request) {
|
|||
defer rows.Close()
|
||||
|
||||
type EmailWithUser struct {
|
||||
ID int32 `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
UserID int32 `json:"user_id"`
|
||||
Created time.Time `json:"created"`
|
||||
GmailMessageID *string `json:"gmail_message_id"`
|
||||
AttachmentCount int32 `json:"attachment_count"`
|
||||
IsDownloaded *bool `json:"is_downloaded"`
|
||||
UserEmail *string `json:"user_email"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
ID int32 `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
UserID int32 `json:"user_id"`
|
||||
Created time.Time `json:"created"`
|
||||
GmailMessageID *string `json:"gmail_message_id"`
|
||||
AttachmentCount int32 `json:"attachment_count"`
|
||||
IsDownloaded *bool `json:"is_downloaded"`
|
||||
UserEmail *string `json:"user_email"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
}
|
||||
|
||||
var emails []EmailWithUser
|
||||
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,
|
||||
}
|
||||
// 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 {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
|
|||
queries: mq,
|
||||
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 {
|
||||
t.Error("Expected error for already sent reminder")
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
|
|||
emailService: me,
|
||||
}
|
||||
// First call should succeed
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected first call to succeed, got %v", err1)
|
||||
}
|
||||
|
|
@ -197,7 +197,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
|
|||
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
|
||||
}
|
||||
// Second call should fail
|
||||
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
|
||||
if err2 == nil {
|
||||
t.Error("Expected error for already sent reminder on second call")
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
|
|||
queries: mq,
|
||||
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 {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
|
@ -236,7 +236,7 @@ func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
|
|||
queries: mq,
|
||||
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 {
|
||||
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.
|
||||
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
|
||||
h := &QuotesHandler{queries: &mockQueriesError{}}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error when DB fails, got nil")
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func TestSendQuoteReminderEmail_DBError(t *testing.T) {
|
|||
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil)
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil recipient, got nil")
|
||||
}
|
||||
|
|
@ -276,7 +276,7 @@ func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
|
|||
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil)
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing template data, got nil")
|
||||
}
|
||||
|
|
@ -288,7 +288,7 @@ func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
|
|||
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
|
||||
mq := &mockQueries{reminders: []db.QuoteReminder{}}
|
||||
h := &QuotesHandler{queries: mq}
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
|
||||
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid reminder type, got nil")
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
|
|||
h := &QuotesHandler{queries: mq, emailService: me}
|
||||
|
||||
// First reminder type
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
|
||||
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected first reminder to be sent, got %v", err1)
|
||||
}
|
||||
|
|
@ -314,7 +314,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
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}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -22,10 +30,10 @@ latest_quote_reminder AS (
|
|||
FROM ranked_reminders
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -33,13 +41,15 @@ SELECT
|
|||
q.date_issued,
|
||||
q.valid_until,
|
||||
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
|
||||
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time,
|
||||
COALESCE(q.reminders_disabled, FALSE) AS reminders_disabled
|
||||
|
||||
FROM quotes q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
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 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
|
||||
|
||||
|
|
@ -47,11 +57,20 @@ WHERE
|
|||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until;
|
||||
|
||||
-- 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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -78,6 +97,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -89,21 +109,32 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 users uu ON uu.id = e.contact_user_id
|
||||
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
|
||||
|
||||
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
|
||||
|
||||
WHERE
|
||||
q.valid_until >= CURRENT_DATE
|
||||
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until;
|
||||
|
||||
-- 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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -130,6 +161,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -141,9 +173,10 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 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
|
||||
|
||||
|
|
@ -151,11 +184,20 @@ WHERE
|
|||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC;
|
||||
|
||||
-- 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
|
||||
id,
|
||||
quote_id,
|
||||
|
|
@ -182,6 +224,7 @@ latest_quote_reminder AS (
|
|||
SELECT
|
||||
d.id AS document_id,
|
||||
u.username,
|
||||
u.email as user_email,
|
||||
e.id AS enquiry_id,
|
||||
e.title as enquiry_ref,
|
||||
uu.first_name as customer_name,
|
||||
|
|
@ -193,9 +236,10 @@ SELECT
|
|||
|
||||
FROM quotes q
|
||||
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 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
|
||||
|
||||
|
|
@ -203,6 +247,7 @@ WHERE
|
|||
q.valid_until < CURRENT_DATE
|
||||
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
AND e.status_id = 5
|
||||
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
|
||||
|
||||
ORDER BY q.valid_until DESC;
|
||||
|
||||
|
|
@ -215,3 +260,22 @@ ORDER BY date_sent;
|
|||
-- name: InsertQuoteReminder :execresult
|
||||
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
|
||||
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,
|
||||
`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,
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue