Files
spsays-bff/internal/service/feedback_service.go
T

357 lines
13 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
}
// 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(`<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
}
// 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(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
<p>The feedback you shared with us has been addressed and the necessary
changes are now live:</p>
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
faithfully. Please continue using
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
with even greater enthusiasm.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// 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(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
<p>Thank you for the feedback you shared with us:</p>
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
<p>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.</p>
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
faithfully. Please continue using
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
with even greater enthusiasm.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
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
}