package handlers import ( "context" "database/sql" "fmt" "net/http" "strconv" "strings" "time" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" ) // Import getUsername from pages.go // If you move getUsername to a shared utils package, update the import accordingly. func getUsername(r *http.Request) string { username, _, ok := r.BasicAuth() if ok && username != "" { // Capitalise the username for display return strings.Title(username) } return "Guest" } // Helper: returns date string or empty if zero func formatDate(t time.Time) string { if t.IsZero() || t.Year() == 1970 { return "" } return t.UTC().Format(time.RFC3339) } // Helper: checks if a time is a valid DB value (not zero or 1970-01-01) func isValidDBTime(t time.Time) bool { return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC)) } // calcExpiryInfo is a helper to calculate expiry info for a quote func calcExpiryInfo(validUntil time.Time) (string, int, int) { now := time.Now() nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) validUntilDate := time.Date(validUntil.Year(), validUntil.Month(), validUntil.Day(), 0, 0, 0, 0, validUntil.Location()) daysUntil := int(validUntilDate.Sub(nowDate).Hours() / 24) daysSince := int(nowDate.Sub(validUntilDate).Hours() / 24) var relative string if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) { switch daysUntil { case 0: relative = "expires today" case 1: relative = "expires tomorrow" default: relative = "expires in " + strconv.Itoa(daysUntil) + " days" } } else { switch daysSince { case 0: relative = "expired today" case 1: relative = "expired yesterday" default: relative = "expired " + strconv.Itoa(daysSince) + " days ago" } } return relative, daysUntil, daysSince } // QuoteRow interface for all quote row types // (We use wrapper types since sqlc structs can't be modified directly) type QuoteRow interface { GetID() int32 GetUsername() string GetEnquiryID() int32 GetEnquiryRef() string GetDateIssued() time.Time GetValidUntil() time.Time GetReminderType() int32 GetReminderSent() time.Time GetCustomerName() string GetCustomerEmail() string } // Wrapper types for each DB row struct type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow } func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID } func (q ExpiringSoonQuoteRowWrapper) GetUsername() string { return q.Username } func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName } func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail } type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow } func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID } func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string { return q.Username } func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName } func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail } type ExpiringSoonQuoteOnDayRowWrapper struct { db.GetExpiringSoonQuotesOnDayRow } func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID } func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string { return q.Username } func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName } func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail } type RecentlyExpiredQuoteOnDayRowWrapper struct { db.GetRecentlyExpiredQuotesOnDayRow } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string { return q.Username } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } 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{} { relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil()) return map[string]interface{}{ "ID": q.GetID(), "Username": strings.Title(q.GetUsername()), "EnquiryID": q.GetEnquiryID(), "EnquiryRef": q.GetEnquiryRef(), "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()), "LatestReminderType": reminderTypeString(int(q.GetReminderType())), } } 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) } 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, emailService: emailService, } } func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) { // Days to look ahead and behind for expiring quotes days := 7 // Show all quotes that are expiring in the next 7 days expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Show all quotes that have expired in the last 60 days recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data := map[string]interface{}{ "RecentlyExpiredQuotes": recentlyExpiredQuotes, "ExpiringSoonQuotes": expiringSoonQuotes, "User": getUsername(r), } if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // GetOutstandingQuotes returns outstanding quotes based on daysUntilExpiry. func (h *QuotesHandler) GetOutstandingQuotes(r *http.Request, daysUntilExpiry int) ([]map[string]interface{}, error) { var rows []map[string]interface{} ctx := r.Context() // If daysUntilExpiry is positive, get quotes expiring soon; if negative, get recently expired quotes if daysUntilExpiry >= 0 { quotes, err := h.queries.GetExpiringSoonQuotes(ctx, daysUntilExpiry) if err != nil { return nil, err } for _, q := range quotes { rows = append(rows, formatQuoteRow(ExpiringSoonQuoteRowWrapper{q})) } } else { days := -daysUntilExpiry quotes, err := h.queries.GetRecentlyExpiredQuotes(ctx, days) if err != nil { return nil, err } for _, q := range quotes { rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteRowWrapper{q})) } } return rows, nil } // GetOutstandingQuotesOnDay returns quotes expiring exactly N days from today (if day >= 0), or exactly N days ago (if day < 0). func (h *QuotesHandler) GetOutstandingQuotesOnDay(r *http.Request, day int) ([]map[string]interface{}, error) { var rows []map[string]interface{} ctx := r.Context() // If day is positive, get quotes expiring on that day; if negative, get recently expired quotes on that day in the past if day >= 0 { quotes, err := h.queries.GetExpiringSoonQuotesOnDay(ctx, day) if err != nil { return nil, err } for _, q := range quotes { rows = append(rows, formatQuoteRow(ExpiringSoonQuoteOnDayRowWrapper{q})) } } else { days := -day quotes, err := h.queries.GetRecentlyExpiredQuotesOnDay(ctx, days) if err != nil { return nil, err } for _, q := range quotes { rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteOnDayRowWrapper{q})) } } return rows, nil } type QuoteReminderType int const ( FirstReminder QuoteReminderType = 1 SecondReminder QuoteReminderType = 2 ThirdReminder QuoteReminderType = 3 ) func (t QuoteReminderType) String() string { switch t { case FirstReminder: return "FirstReminder" case SecondReminder: return "SecondReminder" case ThirdReminder: return "ThirdReminder" default: return "UnknownReminder" } } type quoteReminderJob struct { DayOffset int ReminderType QuoteReminderType Subject string Template string } // DailyQuoteExpirationCheck checks quotes for reminders and expiry notices (callable as a cron job from main) func (h *QuotesHandler) DailyQuoteExpirationCheck() { fmt.Println("Running DailyQuoteExpirationCheck...") reminderJobs := []quoteReminderJob{ {7, FirstReminder, "Reminder: Quote %s Expires Soon", "first_reminder.html"}, {-7, SecondReminder, "Follow-Up: Quote %s Expired", "second_reminder.html"}, {-60, ThirdReminder, "Final Reminder: Quote %s Closed", "final_reminder.html"}, } for _, job := range reminderJobs { quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset) if err != nil { fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err) continue } if len(quotes) == 0 { 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": q["CustomerName"], "SubmissionDate": submissionDate, "ExpiryDate": expiryDate, "QuoteRef": q["EnquiryRef"], } err := h.SendQuoteReminderEmail( context.Background(), q["ID"].(int32), job.ReminderType, q["CustomerEmail"].(string), fmt.Sprintf(job.Subject, q["EnquiryRef"]), job.Template, templateData, nil, ) if err != nil { fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err) } else { fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"]) } } } } // SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it. func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, username *string) error { // 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, ReminderType: int32(reminderType), }) if err != nil { return fmt.Errorf("failed to check existing reminders: %w", err) } // Exit if the email has already been sent if len(reminders) > 0 { return nil } // Send the email err = h.emailService.SendTemplateEmail( recipient, subject, templateName, templateData, nil, nil, ) if err != nil { return fmt.Errorf("failed to send email: %w", err) } // Record the reminder var user sql.NullString if username != nil { user = sql.NullString{String: *username, Valid: true} } else { user = sql.NullString{Valid: false} } _, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{ QuoteID: quoteID, ReminderType: int32(reminderType), DateSent: time.Now().UTC(), Username: user, }) if err != nil { return fmt.Errorf("failed to record reminder: %w", err) } return nil } // Helper: get reminder type as string func reminderTypeString(reminderType int) string { switch reminderType { case 0: return "No Reminder" case 1: return "First Reminder" case 2: return "Second Reminder" case 3: return "Final Reminder" default: return "" } }