From e9a96f1ff96efb9e8e68220e54c0a512a2fe6236 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Wed, 3 Jun 2026 23:43:17 +0530 Subject: [PATCH] feat: add feedback resolver --- internal/handler/stats.go | 44 ++++++- internal/model/feedback.go | 17 ++- internal/repository/feedback_repo.go | 28 ++++- internal/server/server.go | 6 +- internal/service/feedback_service.go | 111 ++++++++++++++++++ .../0017_add_feedback_resolved_at.down.sql | 2 + .../0017_add_feedback_resolved_at.up.sql | 19 +++ 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 migrations/0017_add_feedback_resolved_at.down.sql create mode 100644 migrations/0017_add_feedback_resolved_at.up.sql 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

Recent feedback

{{if .Feedback}} {{range .Feedback}} -
+
{{.Category}} {{fmtTime .CreatedAt}} @@ -121,6 +148,15 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size anonymous {{end}}
+
+ {{if .ResolvedAt}} + ✓ Resolved {{fmtTime .ResolvedAt}} + {{else}} +
+ +
+ {{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;