Add polished answer notifications (#1)
Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior. Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -34,6 +35,7 @@ type Config struct {
|
||||
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
|
||||
TrustedProxies []*net.IPNet
|
||||
Blob blob.Uploader
|
||||
Mail mail.Notifier
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -82,6 +84,7 @@ type submitPage struct {
|
||||
type authPage struct {
|
||||
page
|
||||
Username string
|
||||
Email string
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
@@ -98,6 +101,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
if cfg.Mail == nil {
|
||||
cfg.Mail = mail.Nop{}
|
||||
}
|
||||
funcMap := template.FuncMap{
|
||||
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
|
||||
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
|
||||
@@ -482,6 +488,17 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if len(body) > 12000 {
|
||||
body = truncateRunes(body, 12000)
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, priorErr := s.store.GetAnswer(r.Context(), id)
|
||||
wasNew := errors.Is(priorErr, sql.ErrNoRows)
|
||||
if priorErr != nil && !wasNew {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ans := &store.Answer{
|
||||
QuestionID: id,
|
||||
AuthorID: u.ID,
|
||||
@@ -491,6 +508,9 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if wasNew {
|
||||
s.notifyQuestionAnswered(q, body, u.ID)
|
||||
}
|
||||
saved, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
@@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
|
||||
if q == nil || s.cfg.Mail == nil {
|
||||
return
|
||||
}
|
||||
author, err := s.store.UserByID(context.Background(), q.AuthorID)
|
||||
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
|
||||
return
|
||||
}
|
||||
msg := mail.QuestionAnswered{
|
||||
ToEmail: author.Email,
|
||||
ToName: author.Name,
|
||||
QuestionID: q.ID,
|
||||
QuestionTitle: q.Title,
|
||||
AnswerBody: answerBody,
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
|
||||
log.Printf("notify answer %s: %v", q.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("notify answer %s: accepted", q.ID)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user