363 lines
13 KiB
Go
363 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
|
|
}
|
|
|
|
// 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(`<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
|
|
}
|