feat: add feedback resolver
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatsHandler renders a single-page HTML snapshot of the system —
|
// 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
|
// (outside /api/v1, since it returns HTML not JSON) and intentionally
|
||||||
// public per current product call. If that ever needs to change, wrap
|
// public per current product call. If that ever needs to change, wrap
|
||||||
// the route in middleware in server.go.
|
// 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 {
|
type StatsHandler struct {
|
||||||
stats *repository.StatsRepo
|
stats *repository.StatsRepo
|
||||||
feedback *repository.FeedbackRepo
|
feedback *repository.FeedbackRepo
|
||||||
|
svc *service.FeedbackService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
|
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
|
||||||
return &StatsHandler{stats: stats, feedback: feedback}
|
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
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{
|
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
|
||||||
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||||
}).Parse(statsHTML))
|
}).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:last-child { border-bottom: 0 }
|
||||||
.row .cat { font-weight: 600; text-transform: capitalize }
|
.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 { 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 .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 }
|
||||||
|
.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 }
|
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>
|
||||||
@@ -106,7 +133,7 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
|
|||||||
<h2>Recent feedback</h2>
|
<h2>Recent feedback</h2>
|
||||||
{{if .Feedback}}
|
{{if .Feedback}}
|
||||||
{{range .Feedback}}
|
{{range .Feedback}}
|
||||||
<div class="fb">
|
<div class="fb{{if .ResolvedAt}} resolved{{end}}">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="cat-pill">{{.Category}}</span>
|
<span class="cat-pill">{{.Category}}</span>
|
||||||
<span>{{fmtTime .CreatedAt}}</span>
|
<span>{{fmtTime .CreatedAt}}</span>
|
||||||
@@ -121,6 +148,15 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
|
|||||||
anonymous
|
anonymous
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
{{if .ResolvedAt}}
|
||||||
|
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
|
||||||
|
{{else}}
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ import "time"
|
|||||||
// the repo's lookup helpers (used by the admin email template and the
|
// the repo's lookup helpers (used by the admin email template and the
|
||||||
// /stats page). They're nil for anonymous rows.
|
// /stats page). They're nil for anonymous rows.
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID *string `json:"user_id,omitempty"`
|
UserID *string `json:"user_id,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
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"`
|
||||||
|
// 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"`
|
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||||
AuthorName *string `json:"author_name,omitempty"`
|
AuthorName *string `json:"author_name,omitempty"`
|
||||||
|
|||||||
@@ -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
|
// FindByID returns one feedback row joined with the submitter's
|
||||||
// handle + name + email if a user_id is present. Used after Create to
|
// 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) {
|
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,
|
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
|
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,
|
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, &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)
|
||||||
@@ -49,6 +50,23 @@ func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback
|
|||||||
return f, nil
|
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
|
// Recent returns the most-recent N feedback rows joined with users
|
||||||
// for display on /stats. NULL author columns mean anonymous (or the
|
// for display on /stats. NULL author columns mean anonymous (or the
|
||||||
// original submitter's account was since deleted).
|
// original submitter's account was since deleted).
|
||||||
@@ -57,7 +75,7 @@ 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,
|
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_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
|
||||||
@@ -71,7 +89,7 @@ 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,
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, &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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
statsRepo := repository.NewStatsRepo(s.pg)
|
statsRepo := repository.NewStatsRepo(s.pg)
|
||||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
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.
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||||
@@ -314,6 +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
|
||||||
|
// /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
|
// API v1
|
||||||
|
|||||||
@@ -92,6 +92,70 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
|
|||||||
return f, nil
|
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
|
// notifyAdmins fires one HTML email per configured admin. Each
|
||||||
// failure logs and falls through — the row is persisted so a missed
|
// failure logs and falls through — the row is persisted so a missed
|
||||||
// email is annoying but not data-losing.
|
// email is annoying but not data-losing.
|
||||||
@@ -165,3 +229,50 @@ func derefOr(p *string, fallback string) string {
|
|||||||
}
|
}
|
||||||
return *p
|
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(`<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>
|
||||||
|
|
||||||
|
<p>Please accept our humble obeisances. All glories to Śrīla Prabhupāda.</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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<p style="margin-top:1.75em">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>`))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_feedback_unresolved;
|
||||||
|
ALTER TABLE feedback DROP COLUMN IF EXISTS resolved_at;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user