prod #123
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>We’re 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 we’ll consider the proposal closed unless we hear otherwise. If you’re still interested in moving forward, we’d 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 — we’re 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>
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
||||
<p>We’d 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, we’re 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 don’t hesitate to get in touch. We’re 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>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
<p>Dear {{.CustomerName}},</p>
|
||||
|
||||
<p>We hope this message finds you well.</p>
|
||||
<p>We’re following up on the quotation submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, which expired on {{.ExpiryDate}}. As we haven’t heard back, we wanted to check whether you’re still considering this proposal or if your requirements have changed.</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 an updated or revised quote tailored to your current needs, simply reply to this email — we’re more than happy to assist.</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>If you have any questions or feedback regarding the original quotation, please don’t 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. We’d 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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
359
go-app/internal/cmc/handlers/quotes/quotes_test.go
Normal file
359
go-app/internal/cmc/handlers/quotes/quotes_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
BIN
go-app/server
BIN
go-app/server
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue