prod #123

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

View file

@ -10,7 +10,7 @@ import (
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/go-co-op/gocron"
_ "github.com/go-sql-driver/mysql"
@ -59,7 +59,7 @@ func main() {
emailService := email.GetEmailService()
// Load handlers
quoteHandler := handlers.NewQuotesHandler(queries, tmpl, emailService)
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
// Setup routes
r := mux.NewRouter()

View file

@ -12,30 +12,49 @@ import (
)
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
@ -50,6 +69,8 @@ type GetExpiringSoonQuotesRow struct {
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
@ -70,6 +91,8 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
@ -89,30 +112,49 @@ func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}
}
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
@ -127,6 +169,8 @@ type GetExpiringSoonQuotesOnDayRow struct {
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
@ -147,6 +191,8 @@ func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interf
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
@ -207,30 +253,49 @@ func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemin
}
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
@ -245,6 +310,8 @@ type GetRecentlyExpiredQuotesRow struct {
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
@ -265,6 +332,8 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
@ -284,30 +353,49 @@ func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interfac
}
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
@ -322,6 +410,8 @@ type GetRecentlyExpiredQuotesOnDayRow struct {
Username string `json:"username"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
@ -342,6 +432,8 @@ func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB int
&i.Username,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,

View file

@ -50,6 +50,9 @@ func GetEmailService() *EmailService {
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
defaultBccs := []string{"carpis@cmctechnologies.com.au", "mcarpis@cmctechnologies.com.au"}
bccs = append(defaultBccs, bccs...)
const templateDir = "internal/cmc/email/templates"
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
tmpl, err := template.ParseFiles(tmplPath)

View file

@ -1,14 +1,12 @@
<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>Were getting in touch regarding the quotation issued on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As of today, more than <strong>60 days</strong> 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>This quotation is no longer valid, and well consider the proposal closed unless we hear otherwise. If youre still interested in moving forward, wed 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>If you have any outstanding questions or would like to initiate a new quote, simply reply to this email — were here to help.</p>
<p>Thank you for your time and consideration.</p>
<p>Thank you again for your time and consideration.</p>
<p>Kind regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>

View file

@ -1,16 +1,15 @@
# Subject: Quote Validity Notice Quote Ref #: {{.QuoteRef}}
<html>
<body>
<p>Dear {{.CustomerName}},</p>
<p>We wish to advise that the quote we submitted on {{.SubmissionDate}}, reference <b>{{.QuoteRef}}</b>, will remain valid for a further 7 days. After this period, the quote will expire.</p>
<p>If you would like to request an extension to the validity period or prefer a reissued quote, please reply to this email and well be happy to assist.</p>
<p>Should you have any questions or concerns regarding the quotation, please don't hesitate to let us know in your reply—were here to ensure everything is clear and satisfactory.</p>
<p>Thank you for your attention. We look forward to supporting your requirements.</p>
<br>
<p>Warm regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>
<p>Wed like to remind you that the quote we provided on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, remains valid for another <strong>7 days</strong>. After this period, the quote will expire.</p>
<p>If you require more time, were happy to help — simply reply to this email to request an extension or a refreshed version of the quote.</p>
<p>Should you have any questions or need further clarification, please dont hesitate to get in touch. Were here to support you and ensure everything is in order.</p>
<p>Thank you for time and consideration. We look forward to the opportunity to work with you.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>
</body>
</html>

View file

@ -1,16 +1,12 @@
<p>Dear {{.CustomerName}},</p>
<p>We hope this message finds you well.</p>
<p>Were following up on the quotation submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we havent heard back, we wanted to check whether youre still considering this proposal or if your requirements have changed.</p>
<p>Were following up regarding the quotation we submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we havent received a response, we wanted to check in to see if youre still considering this proposal.</p>
<p>If youd like an updated or revised quote tailored to your current needs, simply reply to this email — were more than happy to assist.</p>
<p>If youd like a revised quotation or require any updates to better suit your current needs, please dont hesitate to let us know—were here to assist.</p>
<p>If you have any questions or feedback regarding the original quotation, please dont hesitate to let us know.</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 again for your time and consideration. Wed be glad to support you whenever you're ready.</p>
<p>Thank you once again for your time and consideration.</p>
<p>Warm regards,<br>
{{.SenderName}}<br>
{{.SenderPosition}}<br>
{{.CompanyName}}</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>

View file

@ -10,16 +10,15 @@ import (
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
)
// Helper: returns date string or empty if zero
func formatDate(t time.Time, layout string) string {
func formatDate(t time.Time) string {
if t.IsZero() || t.Year() == 1970 {
return ""
}
return t.Format(layout)
return t.UTC().Format(time.RFC3339)
}
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
@ -68,6 +67,8 @@ type QuoteRow interface {
GetValidUntil() time.Time
GetReminderType() int32
GetReminderSent() time.Time
GetCustomerName() string
GetCustomerEmail() string
}
// Wrapper types for each DB row struct
@ -82,6 +83,8 @@ func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.Date
func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow }
@ -93,6 +96,8 @@ func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.D
func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type ExpiringSoonQuoteOnDayRowWrapper struct {
db.GetExpiringSoonQuotesOnDayRow
@ -108,6 +113,8 @@ func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.L
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteOnDayRowWrapper struct {
db.GetRecentlyExpiredQuotesOnDayRow
@ -123,6 +130,8 @@ func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
// Helper: formats a quote row for output (generic)
func formatQuoteRow(q QuoteRow) map[string]interface{} {
@ -132,23 +141,38 @@ func formatQuoteRow(q QuoteRow) map[string]interface{} {
"Username": strings.Title(q.GetUsername()),
"EnquiryID": q.GetEnquiryID(),
"EnquiryRef": q.GetEnquiryRef(),
"DateIssued": formatDate(q.GetDateIssued(), "2006-01-02"),
"ValidUntil": formatDate(q.GetValidUntil(), "2006-01-02"),
"CustomerName": strings.TrimSpace(q.GetCustomerName()),
"CustomerEmail": q.GetCustomerEmail(),
"DateIssued": formatDate(q.GetDateIssued()),
"ValidUntil": formatDate(q.GetValidUntil()),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
"LatestReminderSent": formatDate(q.GetReminderSent(), "2006-01-02 15:04:05"),
"LatestReminderSent": formatDate(q.GetReminderSent()),
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
}
}
type QuotesHandler struct {
queries *db.Queries
tmpl *templates.TemplateManager
emailService *email.EmailService // Add email service
type QuoteQueries interface {
GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error)
InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error)
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error)
GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
}
func NewQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager, emailService *email.EmailService) *QuotesHandler {
type EmailSender interface {
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
}
type QuotesHandler struct {
queries QuoteQueries
tmpl *templates.TemplateManager
emailService EmailSender
}
func NewQuotesHandler(queries QuoteQueries, tmpl *templates.TemplateManager, emailService EmailSender) *QuotesHandler {
return &QuotesHandler{
queries: queries,
tmpl: tmpl,
@ -268,9 +292,10 @@ 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"},
{7, FirstReminder, "Reminder: Quote %s Expires Soon", "quotes/first_reminder.html"},
{-7, SecondReminder, "Follow-Up: Quote %s Expired", "quotes/second_reminder.html"},
{-44, ThirdReminder, "Final Reminder: Quote %s Closed", "quotes/final_reminder.html"}, // TODO: remove this its a tester
{-60, ThirdReminder, "Final Reminder: Quote %s Closed", "quotes/final_reminder.html"},
}
for _, job := range reminderJobs {
@ -280,20 +305,36 @@ func (h *QuotesHandler) DailyQuoteExpirationCheck() {
continue
}
for _, q := range quotes {
// Format dates as DD/MM/YYYY
var submissionDate, expiryDate string
if dateIssued, ok := q["DateIssued"].(string); ok && dateIssued != "" {
t, err := time.Parse(time.RFC3339, dateIssued)
if err == nil {
submissionDate = t.Format("02/01/2006")
} else {
submissionDate = dateIssued
}
}
if validUntil, ok := q["ValidUntil"].(string); ok && validUntil != "" {
t, err := time.Parse(time.RFC3339, validUntil)
if err == nil {
expiryDate = t.Format("02/01/2006")
} else {
expiryDate = validUntil
}
}
templateData := map[string]interface{}{
"CustomerName": "Test Customer",
"SubmissionDate": q["DateIssued"],
"CustomerName": q["CustomerName"],
"SubmissionDate": submissionDate,
"ExpiryDate": expiryDate,
"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"]),
q["CustomerEmail"].(string),
fmt.Sprintf(job.Subject, q["EnquiryRef"]),
job.Template,
templateData,
nil,
@ -309,6 +350,19 @@ func (h *QuotesHandler) DailyQuoteExpirationCheck() {
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, username *string) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
}
// Safeguard: check for valid template data
if templateData == nil {
return fmt.Errorf("template data is required")
}
// Safeguard: check for valid reminder type
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if reminder already sent
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
@ -343,7 +397,7 @@ func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int3
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
DateSent: time.Now(),
DateSent: time.Now().UTC(),
Username: user,
})
if err != nil {

View file

@ -0,0 +1,359 @@
package handlers
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
)
// Mocks
type mockQuoteRow struct {
reminderType int32
reminderSent time.Time
designatedDay time.Weekday
emailSent bool
}
func (m *mockQuoteRow) GetReminderType() int32 { return m.reminderType }
func (m *mockQuoteRow) GetReminderSent() time.Time { return m.reminderSent }
func (m *mockQuoteRow) GetCustomerEmail() string { return "test@example.com" }
// Realistic mock for db.Queries
type mockQueries struct {
reminders []db.QuoteReminder
insertCalled bool
}
func (m *mockQueries) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
var filtered []db.QuoteReminder
for _, r := range m.reminders {
if r.QuoteID == params.QuoteID && r.ReminderType == params.ReminderType {
filtered = append(filtered, r)
}
}
return filtered, nil
}
func (m *mockQueries) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
m.insertCalled = true
m.reminders = append(m.reminders, db.QuoteReminder{
QuoteID: params.QuoteID,
ReminderType: params.ReminderType,
DateSent: params.DateSent,
Username: params.Username,
})
return &mockResult{}, nil
}
func (m *mockQueries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Mock sql.Result for InsertQuoteReminder
type mockResult struct{}
func (m *mockResult) LastInsertId() (int64, error) { return 1, nil }
func (m *mockResult) RowsAffected() (int64, error) { return 1, nil }
// Realistic mock for email.EmailService
type mockEmailService struct {
sent bool
sentReminders map[int32]map[int32]bool // quoteID -> reminderType -> sent
}
func (m *mockEmailService) SendTemplateEmail(to, subject, templateName string, data interface{}, ccs, bccs []string) error {
m.sent = true
if m.sentReminders == nil {
m.sentReminders = make(map[int32]map[int32]bool)
}
var quoteID int32
var reminderType int32
if dataMap, ok := data.(map[string]interface{}); ok {
if id, ok := dataMap["QuoteID"].(int32); ok {
quoteID = id
} else if id, ok := dataMap["QuoteID"].(int); ok {
quoteID = int32(id)
}
if rt, ok := dataMap["ReminderType"].(int32); ok {
reminderType = rt
} else if rt, ok := dataMap["ReminderType"].(int); ok {
reminderType = int32(rt)
}
}
if quoteID == 0 {
quoteID = 123
}
if reminderType == 0 {
reminderType = 1
}
if m.sentReminders[quoteID] == nil {
m.sentReminders[quoteID] = make(map[int32]bool)
}
m.sentReminders[quoteID][reminderType] = true
return nil
}
// Mock for db.Queries with error simulation
type mockQueriesError struct{}
func (m *mockQueriesError) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
return nil, errors.New("db error")
}
func (m *mockQueriesError) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
return &mockResult{}, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Test: Should send email on designated day if not already sent
// Description: Verifies that a reminder email is sent and recorded when no previous reminder exists for the quote. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// Simulate designated day logic by calling SendQuoteReminderEmail
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent on designated day")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded on designated day")
}
}
// Test: Should NOT send email if reminder already sent
// Description: Verifies that if a reminder for the quote and type already exists, the handler does not send another email or record a duplicate. Should fail if duplicate reminders are allowed.
func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent if already reminded")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded if already reminded")
}
}
// Test: Should only send reminder once, second call should fail
// Description: Sends a reminder, then tries to send the same reminder again. The first should succeed, the second should fail and not send or record a duplicate. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// First call should succeed
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected first call to succeed, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
}
// Second call should fail
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err2 == nil {
t.Error("Expected error for already sent reminder on second call")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected no additional reminder recorded in DB, got %d", len(mq.reminders))
}
}
// Test: Should send and record reminder if not already sent
// Description: Verifies that a reminder is sent and recorded when no previous reminder exists. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded")
}
}
// Test: Should ignore already sent reminder
// Description: Verifies that the handler does not send or record a reminder if one already exists for the quote/type. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded")
}
}
// Test: Should fail if DB returns error
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled.
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
h := &QuotesHandler{queries: &mockQueriesError{}}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error when DB fails, got nil")
}
}
// Edge case: nil recipient
// Test: Should fail if recipient is empty
// Description: Verifies that the handler returns an error if the recipient email is missing. Should fail if emails are sent to empty recipients.
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error for nil recipient, got nil")
}
}
// Edge case: missing template data
// Test: Should fail if template data is missing
// Description: Verifies that the handler returns an error if template data is nil. Should fail if emails are sent without template data.
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil)
if err == nil {
t.Error("Expected error for missing template data, got nil")
}
}
// Boundary: invalid reminder type
// Test: Should fail if reminder type is invalid
// Description: Verifies that the handler returns an error for an invalid reminder type. Should fail if invalid types are allowed.
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil)
if err == nil {
t.Error("Expected error for invalid reminder type, got nil")
}
}
// Test: Multiple reminders of different types allowed for same quote
// Description: Verifies that reminders of different types for the same quote can be sent and recorded independently. Should fail if only one reminder per quote is allowed.
func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// First reminder type
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected first reminder to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Second reminder type
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil)
if err2 != nil {
t.Fatalf("Expected second reminder to be sent, got %v", err2)
}
if !me.sentReminders[123][2] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 2")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}
// Test: Reminders for different quotes are independent
// Description: Verifies that reminders for different quotes do not block each other and are recorded independently. Should fail if reminders for one quote affect another.
func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// Send reminder for quote 123
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil)
if err1 != nil {
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Send reminder for quote 456
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil)
if err2 != nil {
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
}
if !me.sentReminders[456][1] {
t.Error("Expected email to be sent and recorded for quote 456, reminder 1")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}

Binary file not shown.

View file

@ -1,28 +1,47 @@
-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
@ -32,30 +51,49 @@ WHERE
ORDER BY q.valid_until;
-- name: GetExpiringSoonQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
@ -65,30 +103,49 @@ WHERE
ORDER BY q.valid_until;
-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
@ -98,30 +155,49 @@ WHERE
ORDER BY q.valid_until DESC;
-- name: GetRecentlyExpiredQuotesOnDay :many
WITH ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
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
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
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
JOIN users uu ON uu.id = e.contact_user_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
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE

View file

@ -21,8 +21,8 @@
<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">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderType}}
{{if or (eq .LatestReminderType "First Reminder") (eq .LatestReminderType "First Reminder Sent")}}
@ -39,7 +39,7 @@
{{end}}
</td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderSent}}{{.LatestReminderSent}}{{else}}-{{end}}
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
</td>
</tr>
{{else}}
@ -67,8 +67,8 @@
<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">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.DateIssued}}</span></td>
<td class="px-4 py-2 border align-middle"><span class="localdate">{{.ValidUntil}}</span> <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderType}}
{{if or (eq .LatestReminderType "First Reminder Sent") (eq .LatestReminderType "First Reminder")}}
@ -85,7 +85,7 @@
{{end}}
</td>
<td class="px-4 py-2 border align-middle">
{{if .LatestReminderSent}}{{.LatestReminderSent}}{{else}}-{{end}}
{{if .LatestReminderSent}}<span class="localdatetime">{{.LatestReminderSent}}</span>{{else}}-{{end}}
</td>
</tr>
{{else}}
@ -94,4 +94,34 @@
</tbody>
</table>
</div>
<script>
// Convert .localdate to browser local date, .localdatetime to browser local date+time (no offset)
function formatLocalDate(isoString) {
if (!isoString) return '';
var d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
return d.toLocaleDateString();
}
function formatLocalDateTime(isoString) {
if (!isoString) return '';
var d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
// Show date and time in local time, no offset
return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit' });
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.localdate').forEach(function(el) {
var iso = el.textContent.trim();
if (iso) {
el.textContent = formatLocalDate(iso);
}
});
document.querySelectorAll('.localdatetime').forEach(function(el) {
var iso = el.textContent.trim();
if (iso) {
el.textContent = formatLocalDateTime(iso);
}
});
});
</script>
{{end}}