Adding reminder cron job for email sending, adding ui to view expiring quotes, adding db refresh script
This commit is contained in:
parent
9fee1677e2
commit
6276167663
|
|
@ -6,10 +6,13 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||||
|
"github.com/go-co-op/gocron"
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
@ -52,8 +55,11 @@ func main() {
|
||||||
log.Fatal("Failed to initialize templates:", err)
|
log.Fatal("Failed to initialize templates:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize email service
|
||||||
|
emailService := email.GetEmailService()
|
||||||
|
|
||||||
// Load handlers
|
// Load handlers
|
||||||
quoteHandler := handlers.ExpiringQuotesHandler(queries, tmpl)
|
quoteHandler := handlers.NewQuotesHandler(queries, tmpl, emailService)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
@ -65,7 +71,8 @@ func main() {
|
||||||
// PDF files
|
// PDF files
|
||||||
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
|
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:
|
// The following routes are currently disabled:
|
||||||
/*
|
/*
|
||||||
|
|
@ -247,6 +254,16 @@ func main() {
|
||||||
w.Write([]byte("404 page not found"))
|
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
|
// Start server
|
||||||
port := getEnv("PORT", "8080")
|
port := getEnv("PORT", "8080")
|
||||||
log.Printf("Starting server on port %s", port)
|
log.Printf("Starting server on port %s", port)
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,10 @@ require (
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
golang.org/x/text v0.27.0
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
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.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 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 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.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 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
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/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.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/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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
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=
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,16 @@ type Quote struct {
|
||||||
CommercialComments sql.NullString `json:"commercial_comments"`
|
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 {
|
type State struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ type Querier interface {
|
||||||
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
|
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
|
||||||
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
|
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
|
||||||
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
|
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
|
||||||
|
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesOnDayRow, error)
|
||||||
GetLineItem(ctx context.Context, id int32) (LineItem, error)
|
GetLineItem(ctx context.Context, id int32) (LineItem, error)
|
||||||
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
|
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
|
||||||
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
|
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
|
||||||
|
|
@ -74,12 +75,15 @@ type Querier interface {
|
||||||
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
|
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
|
||||||
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
|
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
|
||||||
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
|
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
|
||||||
|
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
|
||||||
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
|
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
|
||||||
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
|
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
|
||||||
|
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)
|
||||||
GetState(ctx context.Context, id int32) (State, error)
|
GetState(ctx context.Context, id int32) (State, error)
|
||||||
GetStatus(ctx context.Context, id int32) (Status, error)
|
GetStatus(ctx context.Context, id int32) (Status, error)
|
||||||
GetUser(ctx context.Context, id int32) (GetUserRow, error)
|
GetUser(ctx context.Context, id int32) (GetUserRow, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, 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)
|
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
|
||||||
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
|
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
|
||||||
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
|
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,53 @@ package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
|
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
|
FROM quotes q
|
||||||
JOIN documents d on d.id = q.document_id
|
JOIN documents d ON d.id = q.document_id
|
||||||
JOIN users u on u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
JOIN enquiries e on e.id = q.enquiry_id
|
JOIN enquiries e ON e.id = q.enquiry_id
|
||||||
WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
|
||||||
ORDER BY valid_until DESC
|
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 {
|
type GetExpiringSoonQuotesRow struct {
|
||||||
ID int32 `json:"id"`
|
DocumentID int32 `json:"document_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ID_2 int32 `json:"id_2"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
Title string `json:"title"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
DateIssued time.Time `json:"date_issued"`
|
DateIssued time.Time `json:"date_issued"`
|
||||||
ValidUntil time.Time `json:"valid_until"`
|
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) {
|
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() {
|
for rows.Next() {
|
||||||
var i GetExpiringSoonQuotesRow
|
var i GetExpiringSoonQuotesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.DocumentID,
|
||||||
&i.Username,
|
&i.Username,
|
||||||
&i.ID_2,
|
&i.EnquiryID,
|
||||||
&i.Title,
|
&i.EnquiryRef,
|
||||||
&i.DateIssued,
|
&i.DateIssued,
|
||||||
&i.ValidUntil,
|
&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 {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -60,22 +207,48 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
|
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
|
FROM quotes q
|
||||||
JOIN documents d on d.id = q.document_id
|
JOIN documents d ON d.id = q.document_id
|
||||||
JOIN users u on u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
JOIN enquiries e on e.id = q.enquiry_id
|
JOIN enquiries e ON e.id = q.enquiry_id
|
||||||
WHERE valid_until < CURRENT_DATE AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
|
||||||
ORDER BY valid_until DESC
|
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 {
|
type GetRecentlyExpiredQuotesRow struct {
|
||||||
ID int32 `json:"id"`
|
DocumentID int32 `json:"document_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ID_2 int32 `json:"id_2"`
|
EnquiryID int32 `json:"enquiry_id"`
|
||||||
Title string `json:"title"`
|
EnquiryRef string `json:"enquiry_ref"`
|
||||||
DateIssued time.Time `json:"date_issued"`
|
DateIssued time.Time `json:"date_issued"`
|
||||||
ValidUntil time.Time `json:"valid_until"`
|
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) {
|
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() {
|
for rows.Next() {
|
||||||
var i GetRecentlyExpiredQuotesRow
|
var i GetRecentlyExpiredQuotesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.DocumentID,
|
||||||
&i.Username,
|
&i.Username,
|
||||||
&i.ID_2,
|
&i.EnquiryID,
|
||||||
&i.Title,
|
&i.EnquiryRef,
|
||||||
&i.DateIssued,
|
&i.DateIssued,
|
||||||
&i.ValidUntil,
|
&i.ValidUntil,
|
||||||
|
&i.LatestReminderType,
|
||||||
|
&i.LatestReminderSentTime,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -107,3 +282,101 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
|
||||||
}
|
}
|
||||||
return items, nil
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
174
go-app/internal/cmc/email/email.go
Normal file
174
go-app/internal/cmc/email/email.go
Normal 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(", ")))
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 we’ll 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—we’re 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>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<p>Dear {{.CustomerName}},</p>
|
||||||
|
|
||||||
|
<p>We hope this message finds you well.</p>
|
||||||
|
|
||||||
|
<p>We’re following up regarding the quotation we submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we haven’t received a response, we wanted to check in to see if you’re still considering this proposal.</p>
|
||||||
|
|
||||||
|
<p>If you’d like a revised quotation or require any updates to better suit your current needs, please don’t hesitate to let us know—we’re 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>
|
||||||
|
|
@ -1,110 +1,369 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuotesHandler struct {
|
// Helper: returns date string or empty if zero
|
||||||
queries *db.Queries
|
func formatDate(t time.Time, layout string) string {
|
||||||
tmpl *templates.TemplateManager
|
if t.IsZero() || t.Year() == 1970 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(layout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExpiringQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager) *QuotesHandler {
|
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
|
||||||
return &QuotesHandler{
|
func isValidDBTime(t time.Time) bool {
|
||||||
queries: queries,
|
return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
tmpl: tmpl,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *QuotesHandler) ExpiringQuotesView(w http.ResponseWriter, r *http.Request) {
|
// calcExpiryInfo is a helper to calculate expiry info for a quote
|
||||||
days := int32(14)
|
func calcExpiryInfo(validUntil time.Time) (string, int, int) {
|
||||||
|
|
||||||
recentlyExpiredQuotes, err := h.queries.GetRecentlyExpiredQuotes(r.Context(), days)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expiringSoonQuotes, err := h.queries.GetExpiringSoonQuotes(r.Context(), days)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
calcExpiryInfo := func(validUntil time.Time) (string, int, int) {
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
daysUntil := int(validUntil.Sub(now).Hours() / 24)
|
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
daysSince := int(now.Sub(validUntil).Hours() / 24)
|
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
|
var relative string
|
||||||
if validUntil.After(now) {
|
if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) {
|
||||||
if daysUntil == 0 {
|
switch daysUntil {
|
||||||
|
case 0:
|
||||||
relative = "expires today"
|
relative = "expires today"
|
||||||
} else if daysUntil == 1 {
|
case 1:
|
||||||
relative = "in 1 day"
|
relative = "expires tomorrow"
|
||||||
} else {
|
default:
|
||||||
relative = "in " + strconv.Itoa(daysUntil) + " days"
|
relative = "expires in " + strconv.Itoa(daysUntil) + " days"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if daysSince == 0 {
|
switch daysSince {
|
||||||
|
case 0:
|
||||||
relative = "expired today"
|
relative = "expired today"
|
||||||
} else if daysSince == 1 {
|
case 1:
|
||||||
relative = "expired 1 day ago"
|
relative = "expired yesterday"
|
||||||
} else {
|
default:
|
||||||
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
|
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return relative, daysUntil, daysSince
|
return relative, daysUntil, daysSince
|
||||||
}
|
}
|
||||||
|
|
||||||
formatQuote := func(q db.GetRecentlyExpiredQuotesRow) map[string]interface{} {
|
// QuoteRow interface for all quote row types
|
||||||
relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil)
|
// (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{}{
|
return map[string]interface{}{
|
||||||
"ID": q.ID,
|
"ID": q.GetID(),
|
||||||
"Username": strings.Title(q.Username),
|
"Username": strings.Title(q.GetUsername()),
|
||||||
"EnquiryID": q.ID_2,
|
"EnquiryID": q.GetEnquiryID(),
|
||||||
"EnquiryTitle": q.Title,
|
"EnquiryRef": q.GetEnquiryRef(),
|
||||||
"DateIssued": q.DateIssued.Format("2006-01-02"),
|
"DateIssued": formatDate(q.GetDateIssued(), "2006-01-02"),
|
||||||
"ValidUntil": q.ValidUntil.Format("2006-01-02"),
|
"ValidUntil": formatDate(q.GetValidUntil(), "2006-01-02"),
|
||||||
"ValidUntilRelative": relative,
|
"ValidUntilRelative": relative,
|
||||||
"DaysUntilExpiry": daysUntil,
|
"DaysUntilExpiry": daysUntil,
|
||||||
"DaysSinceExpiry": daysSince,
|
"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
|
||||||
|
emailService *email.EmailService // Add email service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager, emailService *email.EmailService) *QuotesHandler {
|
||||||
|
return &QuotesHandler{
|
||||||
|
queries: queries,
|
||||||
|
tmpl: tmpl,
|
||||||
|
emailService: emailService,
|
||||||
}
|
}
|
||||||
formatSoonQuote := func(q db.GetExpiringSoonQuotesRow) map[string]interface{} {
|
}
|
||||||
relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil)
|
|
||||||
return map[string]interface{}{
|
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
|
||||||
"ID": q.ID,
|
// Days to look ahead and behind for expiring quotes
|
||||||
"Username": strings.ToUpper(q.Username),
|
days := 7
|
||||||
"EnquiryID": q.ID_2,
|
|
||||||
"EnquiryTitle": q.Title,
|
// Show all quotes that are expiring in the next 7 days
|
||||||
"DateIssued": q.DateIssued.Format("2006-01-02"),
|
expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days)
|
||||||
"ValidUntil": q.ValidUntil.Format("2006-01-02"),
|
if err != nil {
|
||||||
"ValidUntilRelative": relative,
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
"DaysUntilExpiry": daysUntil,
|
return
|
||||||
"DaysSinceExpiry": daysSince,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var expiredRows []map[string]interface{}
|
// Show all quotes that have expired in the last 60 days
|
||||||
for _, q := range recentlyExpiredQuotes {
|
recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60)
|
||||||
expiredRows = append(expiredRows, formatQuote(q))
|
if err != nil {
|
||||||
}
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
var soonRows []map[string]interface{}
|
return
|
||||||
for _, q := range expiringSoonQuotes {
|
|
||||||
soonRows = append(soonRows, formatSoonQuote(q))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"RecentlyExpiredQuotes": expiredRows,
|
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
|
||||||
"ExpiringSoonQuotes": soonRows,
|
"ExpiringSoonQuotes": expiringSoonQuotes,
|
||||||
}
|
}
|
||||||
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
|
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
go-app/server
BIN
go-app/server
Binary file not shown.
|
|
@ -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
|
-- 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
|
FROM quotes q
|
||||||
JOIN documents d on d.id = q.document_id
|
JOIN documents d ON d.id = q.document_id
|
||||||
JOIN users u on u.id = d.user_id
|
JOIN users u ON u.id = d.user_id
|
||||||
JOIN enquiries e on e.id = q.enquiry_id
|
JOIN enquiries e ON e.id = q.enquiry_id
|
||||||
WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
|
|
||||||
ORDER BY valid_until DESC;
|
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 (?, ?, ?, ?);
|
||||||
|
|
@ -20,3 +20,15 @@ CREATE TABLE `quotes` (
|
||||||
`commercial_comments` text DEFAULT NULL,
|
`commercial_comments` text DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`)
|
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;
|
||||||
|
|
@ -7,25 +7,43 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">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">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">Issued By</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 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</th>
|
||||||
|
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .ExpiringSoonQuotes}}
|
{{range .ExpiringSoonQuotes}}
|
||||||
<tr class="hover:bg-slate-50 transition">
|
<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="/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">{{.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">{{.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">{{.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>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -35,25 +53,43 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">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">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">Issued By</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 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</th>
|
||||||
|
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder Sent</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .RecentlyExpiredQuotes}}
|
{{range .RecentlyExpiredQuotes}}
|
||||||
<tr class="hover:bg-slate-50 transition">
|
<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="/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">{{.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">{{.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">{{.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>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
40
refresh_data.sh
Executable file
40
refresh_data.sh
Executable 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."
|
||||||
Loading…
Reference in a new issue