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 // ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
// the row resolved (one-way) and fires the author thank-you email, then // 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- // 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 .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
.fb .msg { white-space: pre-wrap; font-size: 0.95rem } .fb .msg { white-space: pre-wrap; font-size: 0.95rem }
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem } .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 } .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 { 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 } .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 } code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic } .empty { color: #888; font-style: italic }
</style> </style>
@@ -150,8 +166,16 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
</div> </div>
<div class="foot"> <div class="foot">
{{if .ResolvedAt}} {{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span> <span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else}} {{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.');"> <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> <button type="submit" class="resolve-btn">Mark resolved</button>
</form> </form>
+4
View File
@@ -21,6 +21,10 @@ type Feedback struct {
Message string `json:"message"` Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"` ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"` 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 // ResolvedAt is set the moment an admin presses "Mark resolved" on
// the /stats page. NULL while open. The action is one-way — the // the /stats page. NULL while open. The action is one-way — the
// repo's MarkResolved guards on `resolved_at IS NULL` so a second // 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) { func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{} f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, ` 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 u.handle, u.name, u.email
FROM feedback f FROM feedback f
LEFT JOIN users u ON u.id = f.user_id LEFT JOIN users u ON u.id = f.user_id
WHERE f.id = $1`, 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) &f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil { if err != nil {
return nil, fmt.Errorf("find feedback: %w", err) 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 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 // MarkResolved stamps resolved_at = now() on a still-open row. The
// `resolved_at IS NULL` guard makes this idempotent and one-way: the // `resolved_at IS NULL` guard makes this idempotent and one-way: the
// first call returns true (newly resolved), any subsequent call — or a // 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 limit = 25
} }
rows, err := r.pg.Pool.Query(ctx, ` 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 u.handle, u.name
FROM feedback f FROM feedback f
LEFT JOIN users u ON u.id = f.user_id 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{} out := []model.Feedback{}
for rows.Next() { for rows.Next() {
var f model.Feedback 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 { &f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err) return nil, fmt.Errorf("scan feedback: %w", err)
} }
+4 -3
View File
@@ -314,9 +314,10 @@ func (s *Server) setupRouter() {
// ever needs to change. // ever needs to change.
if s.statsH != nil { if s.statsH != nil {
r.Get("/stats", s.statsH.Render) r.Get("/stats", s.statsH.Render)
// One-way "mark resolved" per feedback row — POST so a refresh of // One-way per-row feedback actions — POST so a refresh of /stats
// /stats can't replay it, and it lives at the same public access // can't replay them. Both live at the same public access level as
// level as /stats itself (gate both together if that changes). // /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) 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. // rather than an error — the end state the admin wanted is already true.
var ErrAlreadyResolved = errors.New("feedback already resolved") 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 // Resolve marks a feedback row resolved (one-way) and, if a recipient
// address is known, sends the author a Vaishnava-styled thank-you note. // 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 // feedbackResolvedEmailTpl is the author-facing thank-you sent when an
// admin marks their feedback resolved. Vaishnava-styled — warm, // admin marks their feedback resolved. Vaishnava-styled, short, and
// devotional register, addressed to the submitter by name when known. // quotes back the exact feedback the devotee submitted so they know
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"> // precisely what was addressed. Base font is 17px for comfortable
<p style="font-size:15px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1.25em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p> // 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 <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>
us has been <strong>acknowledged and addressed</strong>. The necessary
features and fixes you pointed us toward have now been implemented.</p>
<p>Your observations help us serve Śrīla Prabhupāda's <em>vāṇī</em> more <p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
faithfully, and we are sincerely grateful that you took the time to write. faithfully. Please continue using
Devotee feedback like yours is one of the ways this humble service keeps
improving.</p>
<p>We warmly invite you to continue using
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a> <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 with even greater enthusiasm.</p>
nectar waiting to be relished.</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> <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>`)) </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) { func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
// Prefer the account display name; fall back to no name (the // Prefer the account display name; fall back to no name (the
// template drops the comma cleanly when Name is empty). // template drops the comma cleanly when Name is empty).
@@ -270,7 +347,8 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{ if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{
"Name": name, "Name": name,
"Message": strings.TrimSpace(f.Message),
}); err != nil { }); err != nil {
return "", err return "", err
} }
@@ -0,0 +1 @@
ALTER TABLE feedback DROP COLUMN IF EXISTS acknowledged_at;
@@ -0,0 +1,17 @@
-- Track when a feedback row was acknowledged.
--
-- Acknowledgement is the intermediate state between "open" and
-- "resolved": the team has seen the feedback and (if it needs work) the
-- devs have started on it. The author gets an email saying so, with a
-- promise of a second email once it's actually fixed/implemented.
--
-- NULL = not yet acknowledged. Non-NULL = acknowledged at that moment.
-- Like resolved_at, the action is one-way and idempotent: the endpoint
-- enforces `WHERE acknowledged_at IS NULL` so the acknowledgement email
-- fires at most once.
--
-- The two columns are independent stamps. An admin may acknowledge then
-- later resolve (two emails), or resolve directly without acknowledging
-- (one email). Resolving does not require a prior acknowledgement.
ALTER TABLE feedback
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;