feat: add stats page
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user