diff --git a/internal/handler/stats.go b/internal/handler/stats.go index 61a676e..9d3e2b7 100644 --- a/internal/handler/stats.go +++ b/internal/handler/stats.go @@ -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 } @@ -150,8 +166,16 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
{{if .ResolvedAt}} + {{if .AcknowledgedAt}}✓ Acknowledged {{fmtTime .AcknowledgedAt}}{{end}} ✓ Resolved {{fmtTime .ResolvedAt}} {{else}} + {{if .AcknowledgedAt}} + ✓ Acknowledged {{fmtTime .AcknowledgedAt}} + {{else}} +
+ +
+ {{end}}
diff --git a/internal/model/feedback.go b/internal/model/feedback.go index 1c7b916..b07d1b2 100644 --- a/internal/model/feedback.go +++ b/internal/model/feedback.go @@ -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 diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go index ac44c64..6150ce2 100644 --- a/internal/repository/feedback_repo.go +++ b/internal/repository/feedback_repo.go @@ -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) } diff --git a/internal/server/server.go b/internal/server/server.go index 336358c..263e5fd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) } diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go index bcb2cbb..56cd473 100644 --- a/internal/service/feedback_service.go +++ b/internal/service/feedback_service.go @@ -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(`
-

Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏

+// 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(`
+

Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏

-

Please accept our humble obeisances. All glories to Śrīla Prabhupāda.

+

The feedback you shared with us has been addressed and the necessary + changes are now live:

-

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.

+
{{.Message}}
-

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 +

Thank you for helping us serve Śrīla Prabhupāda's vāṇī more + faithfully. Please continue using prabhupadasays.org - with even greater enthusiasm — there is always more of Śrīla Prabhupāda's - nectar waiting to be relished.

+ with even greater enthusiasm.

-

Your servants,
+

Your servants,
The Prabhupāda Says team

- -
-

- “Books are the basis.” — His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda -

`)) +// 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(`
+

Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏

+ +

Thank you for the feedback you shared with us:

+ +
{{.Message}}
+ +

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.

+ +

Thank you for helping us serve Śrīla Prabhupāda's vāṇī more + faithfully. Please continue using + prabhupadasays.org + with even greater enthusiasm.

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) + +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 } diff --git a/migrations/0018_add_feedback_acknowledged_at.down.sql b/migrations/0018_add_feedback_acknowledged_at.down.sql new file mode 100644 index 0000000..1cfac17 --- /dev/null +++ b/migrations/0018_add_feedback_acknowledged_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE feedback DROP COLUMN IF EXISTS acknowledged_at; diff --git a/migrations/0018_add_feedback_acknowledged_at.up.sql b/migrations/0018_add_feedback_acknowledged_at.up.sql new file mode 100644 index 0000000..a96d207 --- /dev/null +++ b/migrations/0018_add_feedback_acknowledged_at.up.sql @@ -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;