diff --git a/internal/handler/stats.go b/internal/handler/stats.go
index 2026164..61a676e 100644
--- a/internal/handler/stats.go
+++ b/internal/handler/stats.go
@@ -1,11 +1,14 @@
package handler
import (
+ "errors"
"html/template"
"net/http"
"time"
+ "github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
+ "github.com/prabhupadasays/spsays-bff/internal/service"
)
// StatsHandler renders a single-page HTML snapshot of the system —
@@ -13,13 +16,18 @@ import (
// (outside /api/v1, since it returns HTML not JSON) and intentionally
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
+//
+// It also handles the "Mark resolved" form POST per feedback row, which
+// stamps resolved_at (one-way) and fires the author thank-you email via
+// the feedback service.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
+ svc *service.FeedbackService
}
-func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
- return &StatsHandler{stats: stats, feedback: feedback}
+func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
+ return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
@@ -47,6 +55,20 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
}
}
+// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
+// the row resolved (one-way) and fires the author thank-you email, then
+// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
+// resolved or unknown ids redirect silently — the desired end state is
+// already true.
+func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
+ http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/stats", http.StatusSeeOther)
+}
+
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
@@ -72,10 +94,15 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
.row:last-child { border-bottom: 0 }
.row .cat { font-weight: 600; text-transform: capitalize }
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
-.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap }
+.fb.resolved { background: #f3f8f4; border-color: #d6e9da }
+.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap; align-items: center }
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
+.fb .foot { display: flex; justify-content: flex-end; margin-top: 0.6rem }
+.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
+.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
+.resolve-btn:hover { background: #196b40 }
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic }
@@ -106,7 +133,7 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
+
{{.Category}}
{{fmtTime .CreatedAt}}
@@ -121,6 +148,15 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
anonymous
{{end}}
+
{{end}}
{{else}}
diff --git a/internal/model/feedback.go b/internal/model/feedback.go
index 4779892..1c7b916 100644
--- a/internal/model/feedback.go
+++ b/internal/model/feedback.go
@@ -15,12 +15,17 @@ import "time"
// the repo's lookup helpers (used by the admin email template and the
// /stats page). They're nil for anonymous rows.
type Feedback struct {
- ID string `json:"id"`
- UserID *string `json:"user_id,omitempty"`
- Category string `json:"category"`
- Message string `json:"message"`
- ContactEmail *string `json:"contact_email,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ UserID *string `json:"user_id,omitempty"`
+ Category string `json:"category"`
+ Message string `json:"message"`
+ ContactEmail *string `json:"contact_email,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ // ResolvedAt is set the moment an admin presses "Mark resolved" on
+ // the /stats page. NULL while open. The action is one-way — the
+ // repo's MarkResolved guards on `resolved_at IS NULL` so a second
+ // resolve is a no-op.
+ ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go
index a16d0e6..ac44c64 100644
--- a/internal/repository/feedback_repo.go
+++ b/internal/repository/feedback_repo.go
@@ -32,16 +32,17 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
// FindByID returns one feedback row joined with the submitter's
// handle + name + email if a user_id is present. Used after Create to
-// assemble the admin notification email with the proper author block.
+// assemble the admin notification email with the proper author block,
+// and after MarkResolved to assemble the author thank-you email.
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
- SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
+ SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at, f.resolved_at,
u.handle, u.name, u.email
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
WHERE f.id = $1`, id,
- ).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
+ ).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
@@ -49,6 +50,23 @@ func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback
return f, nil
}
+// MarkResolved stamps resolved_at = now() on a still-open row. The
+// `resolved_at IS NULL` guard makes this idempotent and one-way: the
+// first call returns true (newly resolved), any subsequent call — or a
+// call on an unknown id — returns false so the caller knows NOT to fire
+// the thank-you email a second time.
+func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
+ tag, err := r.pg.Pool.Exec(ctx, `
+ UPDATE feedback
+ SET resolved_at = now()
+ WHERE id = $1 AND resolved_at IS NULL`, id,
+ )
+ if err != nil {
+ return false, fmt.Errorf("mark feedback resolved: %w", err)
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
// Recent returns the most-recent N feedback rows joined with users
// for display on /stats. NULL author columns mean anonymous (or the
// original submitter's account was since deleted).
@@ -57,7 +75,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
- SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
+ SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at, f.resolved_at,
u.handle, u.name
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
@@ -71,7 +89,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
- if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
+ if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
}
diff --git a/internal/server/server.go b/internal/server/server.go
index d6655ec..336358c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -195,7 +195,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
statsRepo := repository.NewStatsRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
- s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
+ s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -314,6 +314,10 @@ func (s *Server) setupRouter() {
// ever needs to change.
if s.statsH != nil {
r.Get("/stats", s.statsH.Render)
+ // One-way "mark resolved" per feedback row — POST so a refresh of
+ // /stats can't replay it, and it lives at the same public access
+ // level as /stats itself (gate both together if that changes).
+ r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
}
// API v1
diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go
index 2b56cbe..bcb2cbb 100644
--- a/internal/service/feedback_service.go
+++ b/internal/service/feedback_service.go
@@ -92,6 +92,70 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
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")
+
+// 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.
@@ -165,3 +229,50 @@ func derefOr(p *string, fallback string) string {
}
return *p
}
+
+// feedbackResolvedEmailTpl is the author-facing thank-you sent when an
+// admin marks their feedback resolved. Vaishnava-styled — warm,
+// devotional register, addressed to the submitter by name when known.
+var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`
+
Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏
+
+
Please accept our humble obeisances. All glories to Śrīla Prabhupāda.
+
+
We are writing to let you know that the feedback you kindly shared with
+ us has been acknowledged and addressed. The necessary
+ features and fixes you pointed us toward have now been implemented.
+
+
Your observations help us serve Śrīla Prabhupāda's vāṇī more
+ faithfully, and we are sincerely grateful that you took the time to write.
+ Devotee feedback like yours is one of the ways this humble service keeps
+ improving.
+
+
We warmly invite you to continue using
+ prabhupadasays.org
+ with even greater enthusiasm — there is always more of Śrīla Prabhupāda's
+ nectar waiting to be relished.
+
+
Your servants,
+ The Prabhupāda Says team
+
+
+
+ “Books are the basis.” — His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda
+
+
`))
+
+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,
+ }); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/migrations/0017_add_feedback_resolved_at.down.sql b/migrations/0017_add_feedback_resolved_at.down.sql
new file mode 100644
index 0000000..8fd889a
--- /dev/null
+++ b/migrations/0017_add_feedback_resolved_at.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_feedback_unresolved;
+ALTER TABLE feedback DROP COLUMN IF EXISTS resolved_at;
diff --git a/migrations/0017_add_feedback_resolved_at.up.sql b/migrations/0017_add_feedback_resolved_at.up.sql
new file mode 100644
index 0000000..7044486
--- /dev/null
+++ b/migrations/0017_add_feedback_resolved_at.up.sql
@@ -0,0 +1,19 @@
+-- Track when a feedback row was resolved.
+--
+-- NULL = still open. Non-NULL = closed at that moment. The action is
+-- one-way: the resolve endpoint enforces `WHERE resolved_at IS NULL` so
+-- a row already marked stays marked, and a subsequent re-resolve is a
+-- no-op (no new email goes out).
+--
+-- We don't store "who" resolved it because the /stats page surfaces
+-- this to the team and any audit-quality "who closed what" trail would
+-- want its own table — out of scope for now.
+ALTER TABLE feedback
+ ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
+
+-- Partial index makes "give me the still-open feedback" cheap as the
+-- table grows. The /stats page lists newest-first regardless of state,
+-- so the existing created_at index still serves its read path.
+CREATE INDEX IF NOT EXISTS idx_feedback_unresolved
+ ON feedback (created_at DESC)
+ WHERE resolved_at IS NULL;