// 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 } // 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 }