168 lines
5.3 KiB
Go
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
|
|
}
|