Files
spsays-bff/internal/service/feedback_service.go
T
2026-05-31 14:54:15 +05:30

168 lines
5.3 KiB
Go

// 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(`<p><strong>New feedback received</strong></p>
<p><strong>Category:</strong> {{.Category}}<br>
<strong>Submitted:</strong> {{.CreatedAt}}<br>
<strong>From:</strong> {{.From}}</p>
<p><strong>Message:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Message}}</pre>
<p style="color:#999;font-size:12px">Feedback ID: {{.ID}}</p>`))
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
}