diff --git a/go-app/cmd/server/main.go b/go-app/cmd/server/main.go index 69b65740..0f0f6ac3 100644 --- a/go-app/cmd/server/main.go +++ b/go-app/cmd/server/main.go @@ -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) diff --git a/go-app/go.mod b/go-app/go.mod index 50ab1a3d..c7145680 100644 --- a/go-app/go.mod +++ b/go-app/go.mod @@ -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 +) diff --git a/go-app/go.sum b/go-app/go.sum index e16d802c..3a0c001c 100644 --- a/go-app/go.sum +++ b/go-app/go.sum @@ -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= diff --git a/go-app/internal/cmc/db/models.go b/go-app/internal/cmc/db/models.go index 99ad8410..8a1c73a9 100644 --- a/go-app/internal/cmc/db/models.go +++ b/go-app/internal/cmc/db/models.go @@ -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"` diff --git a/go-app/internal/cmc/db/querier.go b/go-app/internal/cmc/db/querier.go index 5f13c7ac..eae28a35 100644 --- a/go-app/internal/cmc/db/querier.go +++ b/go-app/internal/cmc/db/querier.go @@ -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) diff --git a/go-app/internal/cmc/db/quotes.sql.go b/go-app/internal/cmc/db/quotes.sql.go index d0685cae..ee8d2311 100644 --- a/go-app/internal/cmc/db/quotes.sql.go +++ b/go-app/internal/cmc/db/quotes.sql.go @@ -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, + ) +} diff --git a/go-app/internal/cmc/email/email.go b/go-app/internal/cmc/email/email.go new file mode 100644 index 00000000..66908c95 --- /dev/null +++ b/go-app/internal/cmc/email/email.go @@ -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(", "))) +} diff --git a/go-app/internal/cmc/email/templates/quotes/final_reminder.html b/go-app/internal/cmc/email/templates/quotes/final_reminder.html new file mode 100644 index 00000000..7a359dc2 --- /dev/null +++ b/go-app/internal/cmc/email/templates/quotes/final_reminder.html @@ -0,0 +1,14 @@ +
Dear {{.CustomerName}},
+ +We are reaching out regarding the quotation issued on {{.SubmissionDate}}, reference {{.QuoteRef}}, which expired on {{.ExpiryDate}}. As of today, more than 60 days have passed without a response.
+ +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.
+ +Should you have any outstanding questions or concerns, or wish to initiate a new quote, kindly respond to this email as soon as possible.
+ +Thank you for your time and consideration.
+ +Kind regards,
+{{.SenderName}}
+{{.SenderPosition}}
+{{.CompanyName}}
Dear {{.CustomerName}},
+We wish to advise that the quote we submitted on {{.SubmissionDate}}, reference {{.QuoteRef}}, will remain valid for a further 7 days. After this period, the quote will expire.
+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.
+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.
+Thank you for your attention. We look forward to supporting your requirements.
+Warm regards,
+ {{.SenderName}}
+ {{.SenderPosition}}
+ {{.CompanyName}}
Dear {{.CustomerName}},
+ +We hope this message finds you well.
+ +We’re following up regarding the quotation we submitted on {{.SubmissionDate}}, reference {{.QuoteRef}}, 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.
+ +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.
+ +Should you have any questions or concerns regarding the previous quote, feel free to include them in your reply.
+ +Thank you once again for your time and consideration.
+ +Warm regards,
+{{.SenderName}}
+{{.SenderPosition}}
+{{.CompanyName}}