// 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 } // ListForUser returns the signed-in user's own submissions (newest // first) with status and any admin reply, for the "My feedback" page. func (s *FeedbackService) ListForUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) { return s.repo.ListByUser(ctx, userID, limit) } // 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") // ErrAlreadyAcknowledged is the acknowledge-path counterpart. Same // benign-redirect treatment in the handler. var ErrAlreadyAcknowledged = errors.New("feedback already acknowledged") // Acknowledge marks a feedback row acknowledged (one-way) and, if a // recipient address is known, emails the author that the team has seen // their feedback and — if it needs work — the devs have started, with a // second email promised on completion. // // Mirrors Resolve: MarkAcknowledged's `acknowledged_at IS NULL` guard // guarantees the email fires at most once; the email is fire-and-forget. func (s *FeedbackService) Acknowledge(ctx context.Context, id string) error { acked, err := s.repo.MarkAcknowledged(ctx, id) if err != nil { return err } if !acked { return ErrAlreadyAcknowledged } f, err := s.repo.FindByID(ctx, id) if err != nil { s.logger.Warn("feedback acknowledged but hydrate failed; skipping email", zap.String("feedback_id", id), zap.Error(err)) return nil } to := feedbackReplyTo(f) if to == "" { s.logger.Info("feedback acknowledged; no recipient address, skipping email", zap.String("feedback_id", id)) return nil } body, err := renderFeedbackAcknowledgedEmail(f) if err != nil { s.logger.Warn("render feedback-acknowledged email failed", zap.Error(err)) return nil } if err := s.mail.Send(to, "We've received your feedback — Prabhupāda Says", body); err != nil { s.logger.Warn("feedback-acknowledged email send failed", zap.String("to", to), zap.Error(err)) } return nil } // 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, short, and // quotes back the exact feedback the devotee submitted so they know // precisely what was addressed. Base font is 17px for comfortable // reading on phones. var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`

Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏

The feedback you shared with us has been addressed and the necessary changes are now live:

{{.Message}}

Thank you for helping us serve Śrīla Prabhupāda's vāṇī more faithfully. Please continue using prabhupadasays.org with even greater enthusiasm.

Your servants,
The Prabhupāda Says team

`)) // feedbackAcknowledgedEmailTpl is the author-facing "we've seen it" // note sent when an admin presses "Acknowledge". Same Vaishnava styling // and 17px base as the resolved email, quotes the submission back, and // promises a follow-up email on completion. var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").Parse(`

Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏

Thank you for the feedback you shared with us:

{{.Message}}

We acknowledge your feedback and have noted it carefully. If it calls for a new feature or a fix, our dev team has already begun working on it. You'll receive another email from us once it has been implemented or resolved.

Thank you for helping us serve Śrīla Prabhupāda's vāṇī more faithfully. Please continue using prabhupadasays.org with even greater enthusiasm.

Your servants,
The Prabhupāda Says team

`)) func renderFeedbackAcknowledgedEmail(f *model.Feedback) (string, error) { name := "" if f.AuthorName != nil { name = strings.TrimSpace(*f.AuthorName) } var buf bytes.Buffer if err := feedbackAcknowledgedEmailTpl.Execute(&buf, map[string]any{ "Name": name, "Message": strings.TrimSpace(f.Message), }); err != nil { return "", err } return buf.String(), nil } func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) { // Prefer the account display name; fall back to no name (the // template drops the comma cleanly when Name is empty). name := "" if f.AuthorName != nil { name = strings.TrimSpace(*f.AuthorName) } var buf bytes.Buffer if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{ "Name": name, "Message": strings.TrimSpace(f.Message), }); err != nil { return "", err } return buf.String(), nil }