prod #123

Merged
finley merged 122 commits from prod into master 2025-11-22 17:52:40 -08:00
16 changed files with 1168 additions and 134 deletions
Showing only changes of commit 6276167663 - Show all commits

View file

@ -6,10 +6,13 @@ import (
"log"
"net/http"
"os"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/go-co-op/gocron"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
@ -52,8 +55,11 @@ func main() {
log.Fatal("Failed to initialize templates:", err)
}
// Initialize email service
emailService := email.GetEmailService()
// Load handlers
quoteHandler := handlers.ExpiringQuotesHandler(queries, tmpl)
quoteHandler := handlers.NewQuotesHandler(queries, tmpl, emailService)
// Setup routes
r := mux.NewRouter()
@ -65,7 +71,8 @@ func main() {
// PDF files
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
goRouter.HandleFunc("/quotes", quoteHandler.ExpiringQuotesView).Methods("GET")
// Quote routes
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
// The following routes are currently disabled:
/*
@ -247,6 +254,16 @@ func main() {
w.Write([]byte("404 page not found"))
})
/* Cron Jobs */
go func() {
s := gocron.NewScheduler(time.UTC)
s.Every(10).Second().Do(func() {
// Checks quotes for reminders and expiry notices
quoteHandler.DailyQuoteExpirationCheck()
})
s.StartAsync()
}()
// Start server
port := getEnv("PORT", "8080")
log.Printf("Starting server on port %s", port)

View file

@ -11,3 +11,10 @@ require (
github.com/jung-kurt/gofpdf v1.16.2
golang.org/x/text v0.27.0
)
require (
github.com/go-co-op/gocron v1.37.0 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
)

View file

@ -1,7 +1,13 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@ -9,12 +15,38 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -355,6 +355,16 @@ type Quote struct {
CommercialComments sql.NullString `json:"commercial_comments"`
}
type QuoteReminder struct {
ID int32 `json:"id"`
QuoteID int32 `json:"quote_id"`
// 1=1st, 2=2nd, 3=3rd reminder
ReminderType int32 `json:"reminder_type"`
DateSent time.Time `json:"date_sent"`
// User who manually (re)sent the reminder
Username sql.NullString `json:"username"`
}
type State struct {
ID int32 `json:"id"`
Name string `json:"name"`

View file

@ -61,6 +61,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)
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)
@ -74,12 +75,15 @@ type Querier interface {
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)
GetState(ctx context.Context, id int32) (State, error)
GetStatus(ctx context.Context, id int32) (Status, error)
GetUser(ctx context.Context, id int32) (GetUserRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error)
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)

View file

@ -7,26 +7,53 @@ package db
import (
"context"
"database/sql"
"time"
)
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d on d.id = q.document_id
JOIN users u on u.id = d.user_id
JOIN enquiries e on e.id = q.enquiry_id
WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
ORDER BY valid_until DESC
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until
`
type GetExpiringSoonQuotesRow struct {
ID int32 `json:"id"`
Username string `json:"username"`
ID_2 int32 `json:"id_2"`
Title string `json:"title"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
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) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
@ -39,12 +66,132 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
for rows.Next() {
var i GetExpiringSoonQuotesRow
if err := rows.Scan(
&i.ID,
&i.DocumentID,
&i.Username,
&i.ID_2,
&i.Title,
&i.EnquiryID,
&i.EnquiryRef,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
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"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, dateADD)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetExpiringSoonQuotesOnDayRow{}
for rows.Next() {
var i GetExpiringSoonQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getQuoteRemindersByType = `-- name: GetQuoteRemindersByType :many
SELECT id, quote_id, reminder_type, date_sent, username
FROM quote_reminders
WHERE quote_id = ? AND reminder_type = ?
ORDER BY date_sent
`
type GetQuoteRemindersByTypeParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
}
func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error) {
rows, err := q.db.QueryContext(ctx, getQuoteRemindersByType, arg.QuoteID, arg.ReminderType)
if err != nil {
return nil, err
}
defer rows.Close()
items := []QuoteReminder{}
for rows.Next() {
var i QuoteReminder
if err := rows.Scan(
&i.ID,
&i.QuoteID,
&i.ReminderType,
&i.DateSent,
&i.Username,
); err != nil {
return nil, err
}
@ -60,22 +207,48 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
}
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d on d.id = q.document_id
JOIN users u on u.id = d.user_id
JOIN enquiries e on e.id = q.enquiry_id
WHERE valid_until < CURRENT_DATE AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
ORDER BY valid_until DESC
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC
`
type GetRecentlyExpiredQuotesRow struct {
ID int32 `json:"id"`
Username string `json:"username"`
ID_2 int32 `json:"id_2"`
Title string `json:"title"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
DocumentID int32 `json:"document_id"`
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
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) {
@ -88,12 +261,14 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
for rows.Next() {
var i GetRecentlyExpiredQuotesRow
if err := rows.Scan(
&i.ID,
&i.DocumentID,
&i.Username,
&i.ID_2,
&i.Title,
&i.EnquiryID,
&i.EnquiryRef,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
@ -107,3 +282,101 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
}
return items, nil
}
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
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"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotesOnDay, dateSUB)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRecentlyExpiredQuotesOnDayRow{}
for rows.Next() {
var i GetRecentlyExpiredQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertQuoteReminder = `-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?)
`
type InsertQuoteReminderParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
DateSent time.Time `json:"date_sent"`
Username sql.NullString `json:"username"`
}
func (q *Queries) InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error) {
return q.db.ExecContext(ctx, insertQuoteReminder,
arg.QuoteID,
arg.ReminderType,
arg.DateSent,
arg.Username,
)
}

View file

@ -0,0 +1,174 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"html/template"
"net/smtp"
"os"
"strconv"
"sync"
)
var (
emailServiceInstance *EmailService
once sync.Once
)
// EmailService provides methods to send templated emails via SMTP.
type EmailService struct {
SMTPHost string
SMTPPort int
Username string
Password string
FromAddress string
}
// GetEmailService returns a singleton EmailService loaded from environment variables
func GetEmailService() *EmailService {
once.Do(func() {
host := os.Getenv("SMTP_HOST")
portStr := os.Getenv("SMTP_PORT")
port, err := strconv.Atoi(portStr)
if err != nil {
port = 25 // default SMTP port
}
username := os.Getenv("SMTP_USER")
password := os.Getenv("SMTP_PASS")
from := os.Getenv("SMTP_FROM")
emailServiceInstance = &EmailService{
SMTPHost: host,
SMTPPort: port,
Username: username,
Password: password,
FromAddress: from,
}
})
return emailServiceInstance
}
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
const templateDir = "internal/cmc/email/templates"
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
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)
}
msg.WriteString("\r\n")
msg.Write(body.Bytes())
recipients := []string{to}
recipients = append(recipients, ccs...)
recipients = append(recipients, bccs...)
smtpAddr := fmt.Sprintf("%s:%d", es.SMTPHost, es.SMTPPort)
// If no username/password, assume no auth or TLS (e.g., MailHog)
if es.Username == "" && es.Password == "" {
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
auth := smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost)
// Establish connection to SMTP server
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Upgrade to TLS if supported (STARTTLS)
tlsconfig := &tls.Config{
ServerName: es.SMTPHost,
}
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(tlsconfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
// joinAddresses joins email addresses with a comma and space.
func joinAddresses(addrs []string) string {
return fmt.Sprintf("%s", bytes.Join([][]byte(func() [][]byte {
b := make([][]byte, len(addrs))
for i, a := range addrs {
b[i] = []byte(a)
}
return b
}()), []byte(", ")))
}

View file

@ -0,0 +1,14 @@
<p>Dear {{.CustomerName}},</p>
<p>We are reaching out regarding the quotation issued on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As of today, more than 60 days have passed without a response.</p>
<p>Please note that this quotation is no longer valid, and we will consider the proposal closed unless we hear otherwise from you. If your interest in proceeding still stands, we would be happy to provide a new quote tailored to your current requirements.</p>
<p>Should you have any outstanding questions or concerns, or wish to initiate a new quote, kindly respond to this email as soon as possible.</p>
<p>Thank you for your time and consideration.</p>
<p>Kind regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>

View file

@ -0,0 +1,16 @@
# Subject: Quote Validity Notice Quote Ref #: {{.QuoteRef}}
<html>
<body>
<p>Dear {{.CustomerName}},</p>
<p>We wish to advise that the quote we submitted on {{.SubmissionDate}}, reference <b>{{.QuoteRef}}</b>, will remain valid for a further 7 days. After this period, the quote will expire.</p>
<p>If you would like to request an extension to the validity period or prefer a reissued quote, please reply to this email and well be happy to assist.</p>
<p>Should you have any questions or concerns regarding the quotation, please don't hesitate to let us know in your reply—were here to ensure everything is clear and satisfactory.</p>
<p>Thank you for your attention. We look forward to supporting your requirements.</p>
<br>
<p>Warm regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>
</body>
</html>

View file

@ -0,0 +1,16 @@
<p>Dear {{.CustomerName}},</p>
<p>We hope this message finds you well.</p>
<p>Were following up regarding the quotation we submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we havent received a response, we wanted to check in to see if youre still considering this proposal.</p>
<p>If youd like a revised quotation or require any updates to better suit your current needs, please dont hesitate to let us know—were here to assist.</p>
<p>Should you have any questions or concerns regarding the previous quote, feel free to include them in your reply.</p>
<p>Thank you once again for your time and consideration.</p>
<p>Warm regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>

View file

@ -1,110 +1,369 @@
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/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
)
// Helper: returns date string or empty if zero
func formatDate(t time.Time, layout string) string {
if t.IsZero() || t.Year() == 1970 {
return ""
}
return t.Format(layout)
}
// 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
}
// 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 }
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 }
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
}
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
}
// 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(),
"DateIssued": formatDate(q.GetDateIssued(), "2006-01-02"),
"ValidUntil": formatDate(q.GetValidUntil(), "2006-01-02"),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
"LatestReminderSent": formatDate(q.GetReminderSent(), "2006-01-02 15:04:05"),
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
}
}
type QuotesHandler struct {
queries *db.Queries
tmpl *templates.TemplateManager
queries *db.Queries
tmpl *templates.TemplateManager
emailService *email.EmailService // Add email service
}
func ExpiringQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager) *QuotesHandler {
func NewQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager, emailService *email.EmailService) *QuotesHandler {
return &QuotesHandler{
queries: queries,
tmpl: tmpl,
queries: queries,
tmpl: tmpl,
emailService: emailService,
}
}
func (h *QuotesHandler) ExpiringQuotesView(w http.ResponseWriter, r *http.Request) {
days := int32(14)
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
// Days to look ahead and behind for expiring quotes
days := 7
recentlyExpiredQuotes, err := h.queries.GetRecentlyExpiredQuotes(r.Context(), days)
// 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
}
expiringSoonQuotes, err := h.queries.GetExpiringSoonQuotes(r.Context(), days)
// 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
}
calcExpiryInfo := func(validUntil time.Time) (string, int, int) {
now := time.Now()
daysUntil := int(validUntil.Sub(now).Hours() / 24)
daysSince := int(now.Sub(validUntil).Hours() / 24)
var relative string
if validUntil.After(now) {
if daysUntil == 0 {
relative = "expires today"
} else if daysUntil == 1 {
relative = "in 1 day"
} else {
relative = "in " + strconv.Itoa(daysUntil) + " days"
}
} else {
if daysSince == 0 {
relative = "expired today"
} else if daysSince == 1 {
relative = "expired 1 day ago"
} else {
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
}
}
return relative, daysUntil, daysSince
}
formatQuote := func(q db.GetRecentlyExpiredQuotesRow) map[string]interface{} {
relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil)
return map[string]interface{}{
"ID": q.ID,
"Username": strings.Title(q.Username),
"EnquiryID": q.ID_2,
"EnquiryTitle": q.Title,
"DateIssued": q.DateIssued.Format("2006-01-02"),
"ValidUntil": q.ValidUntil.Format("2006-01-02"),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
}
}
formatSoonQuote := func(q db.GetExpiringSoonQuotesRow) map[string]interface{} {
relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil)
return map[string]interface{}{
"ID": q.ID,
"Username": strings.ToUpper(q.Username),
"EnquiryID": q.ID_2,
"EnquiryTitle": q.Title,
"DateIssued": q.DateIssued.Format("2006-01-02"),
"ValidUntil": q.ValidUntil.Format("2006-01-02"),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
}
}
var expiredRows []map[string]interface{}
for _, q := range recentlyExpiredQuotes {
expiredRows = append(expiredRows, formatQuote(q))
}
var soonRows []map[string]interface{}
for _, q := range expiringSoonQuotes {
soonRows = append(soonRows, formatSoonQuote(q))
}
data := map[string]interface{}{
"RecentlyExpiredQuotes": expiredRows,
"ExpiringSoonQuotes": soonRows,
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
"ExpiringSoonQuotes": expiringSoonQuotes,
}
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, "Quote Validity Notice Quote Ref #: ", "quotes/first_reminder.html"},
{-7, SecondReminder, "Quote Expired 2nd Notice for Quote Ref #: ", "quotes/second_reminder.html"},
{-60, ThirdReminder, "Final Reminder Quote Ref #: ", "quotes/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
}
for _, q := range quotes {
templateData := map[string]interface{}{
"CustomerName": "Test Customer",
"SubmissionDate": q["DateIssued"],
"QuoteRef": q["EnquiryRef"],
"SenderName": "Test Sender",
"SenderPosition": "Sales Manager",
"CompanyName": "Test Company",
}
err := h.SendQuoteReminderEmail(
context.Background(),
q["ID"].(int32),
job.ReminderType,
"test@example.com",
job.Subject+fmt.Sprint(q["ID"]),
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 {
// 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)
}
if len(reminders) > 0 {
return fmt.Errorf("reminder of type %s already sent for quote %d", reminderType.String(), quoteID)
}
// 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(),
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 ""
}
}

Binary file not shown.

View file

@ -1,17 +1,141 @@
-- name: GetRecentlyExpiredQuotes :many
SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until
FROM quotes q
JOIN documents d on d.id = q.document_id
JOIN users u on u.id = d.user_id
JOIN enquiries e on e.id = q.enquiry_id
WHERE valid_until < CURRENT_DATE AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
ORDER BY valid_until DESC;
-- name: GetExpiringSoonQuotes :many
SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d on d.id = q.document_id
JOIN users u on u.id = d.user_id
JOIN enquiries e on e.id = q.enquiry_id
WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
ORDER BY valid_until DESC;
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until;
-- name: GetExpiringSoonQuotesOnDay :many
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until;
-- name: GetRecentlyExpiredQuotes :many
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC;
-- name: GetRecentlyExpiredQuotesOnDay :many
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
q.date_issued,
q.valid_until,
COALESCE(qr_latest.reminder_type, 0) AS latest_reminder_type,
COALESCE(qr_latest.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
LEFT JOIN (
SELECT qr1.quote_id, qr1.reminder_type, qr1.date_sent
FROM quote_reminders qr1
JOIN (
SELECT quote_id, MAX(reminder_type) AS max_type
FROM quote_reminders
GROUP BY quote_id
) qr2 ON qr1.quote_id = qr2.quote_id AND qr1.reminder_type = qr2.max_type
) qr_latest ON qr_latest.quote_id = d.id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
ORDER BY q.valid_until DESC;
-- name: GetQuoteRemindersByType :many
SELECT id, quote_id, reminder_type, date_sent, username
FROM quote_reminders
WHERE quote_id = ? AND reminder_type = ?
ORDER BY date_sent;
-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?);

View file

@ -19,4 +19,16 @@ CREATE TABLE `quotes` (
`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;
) ENGINE=MyISAM AUTO_INCREMENT=18245 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-- cmc.quote_reminders definition
CREATE TABLE `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;

View file

@ -7,25 +7,43 @@
<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">Issued By</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">Date Issued</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Valid Until</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"><a href="/enquiries/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryTitle}}</a></td>
<td class="px-4 py-2 border align-middle">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700">Unsent</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}}{{.LatestReminderSent}}{{else}}-{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="px-4 py-2 border text-center align-middle">No quotes expiring soon.</td></tr>
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No quotes expiring soon.</td></tr>
{{end}}
</tbody>
</table>
@ -35,25 +53,43 @@
<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">Issued By</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">Date Issued</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Valid Until</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"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryTitle}}</a></td>
<td class="px-4 py-2 border align-middle">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700">Unsent</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}}{{.LatestReminderSent}}{{else}}-{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="px-4 py-2 border text-center align-middle">No recently expired quotes.</td></tr>
<tr><td colspan="7" class="px-4 py-2 border text-center align-middle">No recently expired quotes.</td></tr>
{{end}}
</tbody>
</table>

40
refresh_data.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
set -euo pipefail
# Config
SSH_CONFIG="$HOME/.ssh/config"
SSH_IDENTITY="$HOME/.ssh/cmc" #Path to your SSH identity file
REMOTE_USER="cmc"
REMOTE_HOST="sales.cmctechnologies.com.au"
REMOTE_PATH="~/backups/"
LOCAL_BACKUP_DIR="backups"
DB_HOST="127.0.0.1"
DB_USER="cmc"
DB_NAME="cmc"
# Ensure backups dir exists
if [ ! -d "$LOCAL_BACKUP_DIR" ]; then
echo "Creating $LOCAL_BACKUP_DIR directory..."
mkdir -p "$LOCAL_BACKUP_DIR"
fi
# Step 1: Rsync backups (flatten output)
echo "Starting rsync..."
rsync -avz --progress -e "ssh -F $SSH_CONFIG -i $SSH_IDENTITY" --no-relative --include='backup_*.sql.gz' --exclude='*' "$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH" "$LOCAL_BACKUP_DIR/" || { echo "Rsync failed"; exit 1; }
echo "Rsync complete."
# Step 2: Find latest backup
LATEST_BACKUP=$(ls -t $LOCAL_BACKUP_DIR/backup_*.sql.gz | head -n1)
if [[ -z "$LATEST_BACKUP" ]]; then
echo "No backup file found!"
exit 1
fi
echo "Latest backup: $LATEST_BACKUP"
# Step 3: Import to MariaDB
read -s -p "Enter DB password for $DB_USER: " DB_PASS
echo
echo "Importing backup to MariaDB..."
gunzip -c "$LATEST_BACKUP" | mariadb -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" || { echo "Database import failed"; exit 1; }
echo "Database import complete."