// Package service — feedback flow. Sits between the feedback handler // and FeedbackRepo: validates the submission, writes the row, then // fans an HTML notification email out to every address listed in // FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient // send failures are logged and don't fail the request, because the // row is already in the DB and visible on /stats. package service import ( "bytes" "context" "errors" "fmt" "html/template" "strings" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/mailer" "go.uber.org/zap" ) type FeedbackService struct { repo *repository.FeedbackRepo mail mailer.Mailer admins []string logger *zap.Logger } func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService { return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger} } // Allowed categories — kept lowercase in storage so dashboards group // cleanly. The frontend dropdown sends one of these exact strings. var feedbackCategories = map[string]bool{ "bug": true, "feature": true, "general": true, "other": true, } // Submit persists a feedback row and fires the admin email. // // userID is nil for anonymous submissions; contactEmail is the // optional reply-to in that case. For authed users we ignore whatever // the form sent and read the canonical email off the joined user row // when assembling the notification — that keeps the reply path // trustworthy (the form can't lie about who's writing). func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) { category = strings.ToLower(strings.TrimSpace(category)) if !feedbackCategories[category] { return nil, errors.New("invalid category") } message = strings.TrimSpace(message) if message == "" { return nil, errors.New("message is required") } if len(message) > 5000 { return nil, errors.New("message too long (max 5000)") } if userID == nil { if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" { return nil, errors.New("contact_email is required when not logged in") } trimmed := strings.TrimSpace(*contactEmail) contactEmail = &trimmed } else { // Authed: ignore client-supplied contact_email; user row owns it. contactEmail = nil } f, err := s.repo.Create(ctx, userID, category, message, contactEmail) if err != nil { return nil, err } // Hydrate the joined author fields for the email template. Failing // the lookup isn't worth failing the request — we fall back to // "anonymous" rather than 500ing on the user. if userID != nil { if full, err := s.repo.FindByID(ctx, f.ID); err == nil { f = full } else { s.logger.Warn("feedback hydrate failed; sending email without author info", zap.String("feedback_id", f.ID), zap.Error(err)) } } s.notifyAdmins(f) return f, nil } // ErrAlreadyResolved is returned by Resolve when the row was already // closed (or doesn't exist). The handler maps it to a benign redirect // rather than an error — the end state the admin wanted is already true. var ErrAlreadyResolved = errors.New("feedback already resolved") // Resolve marks a feedback row resolved (one-way) and, if a recipient // address is known, sends the author a Vaishnava-styled thank-you note. // // The DB update is the source of truth: MarkResolved's `resolved_at IS // NULL` guard guarantees the email fires at most once even under // concurrent button presses. The email is fire-and-forget — a send // failure logs but doesn't un-resolve the row. func (s *FeedbackService) Resolve(ctx context.Context, id string) error { resolved, err := s.repo.MarkResolved(ctx, id) if err != nil { return err } if !resolved { return ErrAlreadyResolved } // Re-read with author joins so we know where to send the thank-you. f, err := s.repo.FindByID(ctx, id) if err != nil { // Row IS resolved; we just can't assemble the email. Log and // succeed — the resolve itself stuck. s.logger.Warn("feedback resolved but hydrate failed; skipping thank-you email", zap.String("feedback_id", id), zap.Error(err)) return nil } to := feedbackReplyTo(f) if to == "" { // No email on file (anonymous submitter who left no contact, or // a since-deleted account). Nothing to send — that's fine. s.logger.Info("feedback resolved; no recipient address, skipping thank-you email", zap.String("feedback_id", id)) return nil } body, err := renderFeedbackResolvedEmail(f) if err != nil { s.logger.Warn("render feedback-resolved email failed", zap.Error(err)) return nil } if err := s.mail.Send(to, "Your feedback has been addressed — Prabhupāda Says", body); err != nil { s.logger.Warn("feedback-resolved email send failed", zap.String("to", to), zap.Error(err)) } return nil } // feedbackReplyTo picks the address to thank: the authed submitter's // account email if present, else the anonymous contact_email, else "". func feedbackReplyTo(f *model.Feedback) string { if f.AuthorEmail != nil && strings.TrimSpace(*f.AuthorEmail) != "" { return strings.TrimSpace(*f.AuthorEmail) } if f.ContactEmail != nil && strings.TrimSpace(*f.ContactEmail) != "" { return strings.TrimSpace(*f.ContactEmail) } return "" } // notifyAdmins fires one HTML email per configured admin. Each // failure logs and falls through — the row is persisted so a missed // email is annoying but not data-losing. func (s *FeedbackService) notifyAdmins(f *model.Feedback) { if len(s.admins) == 0 { s.logger.Info("feedback received but no admin recipients configured", zap.String("feedback_id", f.ID)) return } body, err := renderFeedbackEmail(f) if err != nil { s.logger.Warn("render feedback email failed", zap.Error(err)) return } subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60)) for _, to := range s.admins { if err := s.mail.Send(to, subject, body); err != nil { s.logger.Warn("feedback email send failed", zap.String("to", to), zap.Error(err)) } } } func summarize(s string, max int) string { s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max-1]) + "…" } var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`
New feedback received
Category: {{.Category}}
Submitted: {{.CreatedAt}}
From: {{.From}}
Message:
{{.Message}}
Feedback ID: {{.ID}}
`)) func renderFeedbackEmail(f *model.Feedback) (string, error) { var from string switch { case f.AuthorHandle != nil && *f.AuthorHandle != "": from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle) if f.AuthorEmail != nil && *f.AuthorEmail != "" { from += " — " + *f.AuthorEmail } case f.ContactEmail != nil && *f.ContactEmail != "": from = "anonymous — " + *f.ContactEmail default: from = "anonymous" } var buf bytes.Buffer err := feedbackEmailTpl.Execute(&buf, map[string]any{ "Category": f.Category, "Message": f.Message, "From": from, "CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"), "ID": f.ID, }) if err != nil { return "", err } return buf.String(), nil } func derefOr(p *string, fallback string) string { if p == nil || *p == "" { return fallback } return *p } // feedbackResolvedEmailTpl is the author-facing thank-you sent when an // admin marks their feedback resolved. Vaishnava-styled — warm, // devotional register, addressed to the submitter by name when known. var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏
Please accept our humble obeisances. All glories to Śrīla Prabhupāda.
We are writing to let you know that the feedback you kindly shared with us has been acknowledged and addressed. The necessary features and fixes you pointed us toward have now been implemented.
Your observations help us serve Śrīla Prabhupāda's vāṇī more faithfully, and we are sincerely grateful that you took the time to write. Devotee feedback like yours is one of the ways this humble service keeps improving.
We warmly invite you to continue using prabhupadasays.org with even greater enthusiasm — there is always more of Śrīla Prabhupāda's nectar waiting to be relished.
Your servants,
The Prabhupāda Says team
“Books are the basis.” — His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda