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/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 { if t.IsZero() || t.Year() == 1970 { return "" } return t.Format(layout) } // 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 } // Wrapper types for each DB row struct type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow } func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID } func (q ExpiringSoonQuoteRowWrapper) GetUsername() string { return q.Username } func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow } func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID } func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string { return q.Username } func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } type ExpiringSoonQuoteOnDayRowWrapper struct { db.GetExpiringSoonQuotesOnDayRow } func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID } func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string { return q.Username } func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } type RecentlyExpiredQuoteOnDayRowWrapper struct { db.GetRecentlyExpiredQuotesOnDayRow } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string { return q.Username } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType } func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime } // Helper: formats a quote row for output (generic) func formatQuoteRow(q QuoteRow) map[string]interface{} { relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil()) return map[string]interface{}{ "ID": q.GetID(), "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"), "ValidUntilRelative": relative, "DaysUntilExpiry": daysUntil, "DaysSinceExpiry": daysSince, "LatestReminderSent": formatDate(q.GetReminderSent(), "2006-01-02 15:04:05"), "LatestReminderType": reminderTypeString(int(q.GetReminderType())), } } type QuotesHandler struct { queries *db.Queries tmpl *templates.TemplateManager emailService *email.EmailService // Add email service } func NewQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager, emailService *email.EmailService) *QuotesHandler { return &QuotesHandler{ queries: queries, tmpl: tmpl, emailService: emailService, } } 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, } 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, "Quote Validity Notice – Quote Ref #: ", "quotes/first_reminder.html"}, {-7, SecondReminder, "Quote Expired – 2nd Notice for Quote Ref #: ", "quotes/second_reminder.html"}, {-60, ThirdReminder, "Final Reminder – Quote Ref #: ", "quotes/final_reminder.html"}, } for _, job := range reminderJobs { quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset) if err != nil { fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err) continue } for _, q := range quotes { templateData := map[string]interface{}{ "CustomerName": "Test Customer", "SubmissionDate": q["DateIssued"], "QuoteRef": q["EnquiryRef"], "SenderName": "Test Sender", "SenderPosition": "Sales Manager", "CompanyName": "Test Company", } err := h.SendQuoteReminderEmail( context.Background(), q["ID"].(int32), job.ReminderType, "test@example.com", job.Subject+fmt.Sprint(q["ID"]), job.Template, templateData, nil, ) if err != nil { fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err) } else { fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"]) } } } } // SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it. func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, username *string) error { // Check if reminder already sent reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{ QuoteID: quoteID, ReminderType: int32(reminderType), }) if err != nil { return fmt.Errorf("failed to check existing reminders: %w", err) } if len(reminders) > 0 { return fmt.Errorf("reminder of type %s already sent for quote %d", reminderType.String(), quoteID) } // Send the email err = h.emailService.SendTemplateEmail( recipient, subject, templateName, templateData, nil, nil, ) if err != nil { return fmt.Errorf("failed to send email: %w", err) } // Record the reminder var user sql.NullString if username != nil { user = sql.NullString{String: *username, Valid: true} } else { user = sql.NullString{Valid: false} } _, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{ QuoteID: quoteID, ReminderType: int32(reminderType), DateSent: time.Now(), Username: user, }) if err != nil { return fmt.Errorf("failed to record reminder: %w", err) } return nil } // Helper: get reminder type as string func reminderTypeString(reminderType int) string { switch reminderType { case 0: return "No Reminder" case 1: return "First Reminder" case 2: return "Second Reminder" case 3: return "Final Reminder" default: return "" } }