Generalize post reply notifications (#6)
## Summary - Notify the direct parent post author for replies throughout nested conversations - Skip root creation, self-replies, edits, disabled mail, and recipients without email - Link directly to each reply and use per-reply Resend idempotency Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
+56
-2
@@ -5,12 +5,15 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
@@ -37,7 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
AuthorID: user.ID,
|
||||
Body: truncateRunes(body, 12000),
|
||||
}
|
||||
var root *store.Post
|
||||
var parent, root *store.Post
|
||||
if parentID == "" {
|
||||
post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120)
|
||||
post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80)
|
||||
@@ -46,7 +49,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
parent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
||||
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
@@ -63,6 +66,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
parent = loadedParent
|
||||
post.ParentID = &parent.ID
|
||||
root = threadRoot
|
||||
}
|
||||
@@ -78,6 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
if root == nil {
|
||||
root = post
|
||||
}
|
||||
if parent != nil {
|
||||
s.notifyPostReply(parent, root, post, user)
|
||||
}
|
||||
http.Redirect(
|
||||
w,
|
||||
r,
|
||||
@@ -86,6 +93,53 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// notifyPostReply asynchronously emails the direct parent post's author.
|
||||
func (s *Server) notifyPostReply(
|
||||
parent *store.Post,
|
||||
root *store.Post,
|
||||
reply *store.Post,
|
||||
replyAuthor *store.User,
|
||||
) {
|
||||
if parent == nil ||
|
||||
root == nil ||
|
||||
reply == nil ||
|
||||
replyAuthor == nil ||
|
||||
s.cfg.Mail == nil ||
|
||||
parent.AuthorID == replyAuthor.ID {
|
||||
return
|
||||
}
|
||||
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
|
||||
return
|
||||
}
|
||||
msg := mail.PostReply{
|
||||
RootID: root.ID,
|
||||
RootTitle: root.Title,
|
||||
ReplyID: reply.ID,
|
||||
ReplyBody: reply.Body,
|
||||
ReplyAuthorName: replyAuthor.Name,
|
||||
}
|
||||
recipientID := parent.AuthorID
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
recipient, err := s.store.UserByID(ctx, recipientID)
|
||||
if err != nil {
|
||||
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
|
||||
return
|
||||
}
|
||||
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
|
||||
return
|
||||
}
|
||||
msg.ToEmail = recipient.Email
|
||||
msg.ToName = recipient.Name
|
||||
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
|
||||
log.Printf("notify reply %s: %v", msg.ReplyID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("notify reply %s: accepted", msg.ReplyID)
|
||||
}()
|
||||
}
|
||||
|
||||
// handleEditPost updates only a post's body after verifying that the current
|
||||
// homeowner owns it or that an admin is editing an admin-authored post.
|
||||
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -258,6 +260,127 @@ func TestEditPostRoutePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostReplyNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recording := &mail.Recording{}
|
||||
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||
handler := srv.Handler()
|
||||
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
|
||||
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
|
||||
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
|
||||
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
|
||||
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
|
||||
adminCSRF := csrfForCookies(t, handler, adminCookies)
|
||||
|
||||
rec := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"title": {"Leaky sink"},
|
||||
"body": {"Water under the cabinet."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if recording.Len() != 0 {
|
||||
t.Fatalf("root create sent %d notifications", recording.Len())
|
||||
}
|
||||
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
|
||||
if err != nil || len(roots) != 1 {
|
||||
t.Fatalf("created roots = %+v, %v", roots, err)
|
||||
}
|
||||
root := roots[0]
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"parent_id": {root.ID},
|
||||
"body": {"Replace the cartridge."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
thread, err := mem.GetPostThread(context.Background(), root.ID)
|
||||
if err != nil || len(thread.Replies) != 1 {
|
||||
t.Fatalf("admin reply thread = %+v, %v", thread, err)
|
||||
}
|
||||
adminReply := thread.Replies[0]
|
||||
msgs := waitForMail(t, recording, 1)
|
||||
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
|
||||
msg.RootID != root.ID ||
|
||||
msg.RootTitle != root.Title ||
|
||||
msg.ReplyID != adminReply.ID ||
|
||||
msg.ReplyBody != adminReply.Body ||
|
||||
msg.ReplyAuthorName != admin.Name {
|
||||
t.Fatalf("admin reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"parent_id": {adminReply.ID},
|
||||
"body": {"That fixed the drip."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
thread, err = mem.GetPostThread(context.Background(), root.ID)
|
||||
if err != nil || len(thread.Replies[0].Replies) != 1 {
|
||||
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
|
||||
}
|
||||
homeownerReply := thread.Replies[0].Replies[0]
|
||||
msgs = waitForMail(t, recording, 2)
|
||||
if msg := msgs[1]; msg.ToEmail != admin.Email ||
|
||||
msg.RootID != root.ID ||
|
||||
msg.ReplyID != homeownerReply.ID ||
|
||||
msg.ReplyAuthorName != homeowner.Name {
|
||||
t.Fatalf("homeowner reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"parent_id": {root.ID},
|
||||
"body": {"A note to myself."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"body": {"Replace the ceramic cartridge."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
noEmail := &store.User{
|
||||
Username: uniq("no-email"),
|
||||
PasswordHash: homeowner.PasswordHash,
|
||||
Role: store.RoleUser,
|
||||
}
|
||||
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noEmailRoot := &store.Post{
|
||||
AuthorID: noEmail.ID,
|
||||
Title: "Quiet thread",
|
||||
Body: "No email configured.",
|
||||
PostDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"parent_id": {noEmailRoot.ID},
|
||||
"body": {"This should not send."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if recording.Len() != 2 {
|
||||
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -345,6 +468,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if recording.Len() >= want {
|
||||
return recording.Snapshot()
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||
|
||||
@@ -510,6 +510,7 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.notifyPostReply(root, root, reply, u)
|
||||
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", location)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -483,7 +484,8 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
recording := &mail.Recording{}
|
||||
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||
h := srv.Handler()
|
||||
adminName := uniq("admin")
|
||||
userName := uniq("user")
|
||||
@@ -611,6 +613,13 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
|
||||
t.Fatalf("admin answer redirect = %q", got)
|
||||
}
|
||||
msgs := waitForMail(t, recording, 1)
|
||||
if msg := msgs[0]; msg.ToEmail != user.Email ||
|
||||
msg.RootID != q.ID ||
|
||||
msg.ReplyID != adminReply.ID ||
|
||||
msg.ReplyBody != adminReply.Body {
|
||||
t.Fatalf("compatibility reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
|
||||
Reference in New Issue
Block a user