feat: add feedback acknowledgement

This commit is contained in:
Krispire Admin
2026-06-04 01:55:39 +05:30
parent e9a96f1ff9
commit d9678ebb3b
7 changed files with 176 additions and 31 deletions
+25 -1
View File
@@ -55,6 +55,19 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
}
}
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
// the row acknowledged (one-way) and emails the author that we've seen
// it and started work, then 303-redirects back to /stats. Already-
// acknowledged or unknown ids redirect silently.
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
// 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-
@@ -99,10 +112,13 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
.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 }
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
.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 }
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.ack-btn:hover { background: #faf5ea }
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic }
</style>
@@ -150,8 +166,16 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
</div>
<div class="foot">
{{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else}}
{{if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
<button type="submit" class="ack-btn">Acknowledge</button>
</form>
{{end}}
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
<button type="submit" class="resolve-btn">Mark resolved</button>
</form>
+4
View File
@@ -21,6 +21,10 @@ type Feedback struct {
Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"`
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
// /stats page — the team has seen it and (if it needs work) the devs
// have started. NULL until then. One-way like ResolvedAt.
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// 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
+24 -4
View File
@@ -37,12 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
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, f.resolved_at,
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
f.acknowledged_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, &f.ResolvedAt,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
@@ -50,6 +52,22 @@ func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback
return f, nil
}
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
// idempotent and one-way: first call returns true (newly acknowledged →
// fire the email), any later call — or an unknown id — returns false.
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET acknowledged_at = now()
WHERE id = $1 AND acknowledged_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
}
return tag.RowsAffected() == 1, 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
@@ -75,7 +93,8 @@ 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, f.resolved_at,
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
@@ -89,7 +108,8 @@ 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, &f.ResolvedAt,
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
}
+4 -3
View File
@@ -314,9 +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).
// One-way per-row feedback actions — POST so a refresh of /stats
// can't replay them. Both live at the same public access level as
// /stats itself (gate them together if that ever changes).
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
}
+101 -23
View File
@@ -97,6 +97,52 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
// 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.
//
@@ -231,36 +277,67 @@ func derefOr(p *string, fallback string) string {
}
// 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(`<div style="font-family:Georgia,'Times New Roman',serif;color:#2a2118;max-width:560px;margin:0 auto;line-height:1.7">
<p style="font-size:15px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1.25em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
// 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:Georgia,'Times New Roman',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>Please accept our humble obeisances. All glories to Śrīla Prabhupāda.</p>
<p>The feedback you shared with us has been addressed and the necessary
changes are now live:</p>
<p>We are writing to let you know that the feedback you kindly shared with
us has been <strong>acknowledged and addressed</strong>. The necessary
features and fixes you pointed us toward have now been implemented.</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>Your observations help us serve Śrīla Prabhupāda's <em>vāṇī</em> 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.</p>
<p>We warmly invite you to continue using
<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 — there is always more of Śrīla Prabhupāda's
nectar waiting to be relished.</p>
with even greater enthusiasm.</p>
<p style="margin-top:1.75em">Your servants,<br>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
<hr style="border:none;border-top:1px solid #e8ddc9;margin:2em 0">
<p style="font-size:12px;color:#a89878;font-style:italic">
“Books are the basis.” — His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda
</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:Georgia,'Times New Roman',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).
@@ -270,7 +347,8 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
}
var buf bytes.Buffer
if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{
"Name": name,
"Name": name,
"Message": strings.TrimSpace(f.Message),
}); err != nil {
return "", err
}