Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting. Reviewed-on: #7 Co-authored-by: codegirl-007 <s.raide@gmail.com>
277 lines
6.6 KiB
Go
277 lines
6.6 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"plumber/internal/mail"
|
|
"plumber/internal/store"
|
|
)
|
|
|
|
// handleCreatePost creates either a root question or a reply. Replies are
|
|
// limited to the root author and admins, and cannot be added to hidden threads.
|
|
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireCSRF(w, r) {
|
|
return
|
|
}
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
parentID := strings.TrimSpace(r.PostFormValue("parent_id"))
|
|
body := strings.TrimSpace(r.PostFormValue("body"))
|
|
if body == "" {
|
|
http.Error(w, "post body required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
post := &store.Post{
|
|
AuthorID: user.ID,
|
|
Body: truncateRunes(body, 12000),
|
|
}
|
|
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)
|
|
if post.Title == "" {
|
|
http.Error(w, "post title required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else {
|
|
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "could not load thread", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if threadRoot.PostState == store.PostStateHidden {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !canReplyToThread(user, threadRoot) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
parent = loadedParent
|
|
post.ParentID = &parent.ID
|
|
root = threadRoot
|
|
}
|
|
|
|
if err := s.store.CreatePost(r.Context(), post); err != nil {
|
|
if errors.Is(err, store.ErrInvalidPost) {
|
|
http.Error(w, "invalid post", http.StatusBadRequest)
|
|
return
|
|
}
|
|
http.Error(w, "could not save post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if root == nil {
|
|
root = post
|
|
}
|
|
if parent != nil {
|
|
s.notifyPostReply(parent, root, post, user)
|
|
}
|
|
http.Redirect(
|
|
w,
|
|
r,
|
|
"/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID),
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// notifyPostReply emails the root homeowner for admin replies and the direct
|
|
// parent author for homeowner replies.
|
|
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 {
|
|
return
|
|
}
|
|
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
|
|
return
|
|
}
|
|
recipientID := parent.AuthorID
|
|
if replyAuthor.Admin() {
|
|
recipientID = root.AuthorID
|
|
}
|
|
if recipientID == replyAuthor.ID {
|
|
return
|
|
}
|
|
msg := mail.PostReply{
|
|
RootID: root.ID,
|
|
RootTitle: root.Title,
|
|
ReplyID: reply.ID,
|
|
ReplyBody: reply.Body,
|
|
ReplyAuthorName: replyAuthor.Name,
|
|
}
|
|
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) {
|
|
if !s.requireCSRF(w, r) {
|
|
return
|
|
}
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
post, root, err := s.postAndRoot(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "could not load post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !canEditPost(user, post) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
body := strings.TrimSpace(r.PostFormValue("body"))
|
|
if body == "" {
|
|
http.Error(w, "post body required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
post.Body = truncateRunes(body, 12000)
|
|
if err := s.store.UpdatePost(r.Context(), post); err != nil {
|
|
if errors.Is(err, store.ErrInvalidPost) {
|
|
http.Error(w, "invalid post", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "could not save post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(
|
|
w,
|
|
r,
|
|
"/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID),
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// postAndRoot loads a post and follows its immutable parent chain to the root.
|
|
// It returns both so callers can authorize against the thread and redirect to it.
|
|
func (s *Server) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) {
|
|
postID = strings.TrimSpace(postID)
|
|
if postID == "" {
|
|
return nil, nil, sql.ErrNoRows
|
|
}
|
|
post, err := s.store.GetPost(ctx, postID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
current := post
|
|
seen := map[string]bool{}
|
|
for current.ParentID != nil {
|
|
if seen[current.ID] {
|
|
return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID)
|
|
}
|
|
seen[current.ID] = true
|
|
current, err = s.store.GetPost(ctx, *current.ParentID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
return post, current, nil
|
|
}
|
|
|
|
// canEditPost keeps homeowner posts owner-only while allowing admins to edit
|
|
// posts authored by an admin.
|
|
func canEditPost(user *store.User, post *store.Post) bool {
|
|
if user == nil || post == nil {
|
|
return false
|
|
}
|
|
if post.AuthorRole == store.RoleAdmin {
|
|
return user.Admin()
|
|
}
|
|
return user.ID == post.AuthorID
|
|
}
|
|
|
|
func canReplyToThread(user *store.User, root *store.Post) bool {
|
|
return user != nil &&
|
|
root != nil &&
|
|
root.PostState != store.PostStateHidden &&
|
|
(user.Admin() || user.ID == root.AuthorID)
|
|
}
|
|
|
|
func postLabel(post *store.Post) string {
|
|
if post == nil {
|
|
return ""
|
|
}
|
|
if post.ParentID == nil {
|
|
return "Question"
|
|
}
|
|
if post.AuthorRole == store.RoleAdmin {
|
|
return "Shop response"
|
|
}
|
|
return "Homeowner"
|
|
}
|
|
|
|
func postDepthClass(depth int) string {
|
|
switch depth {
|
|
case 0:
|
|
return "root"
|
|
case 1:
|
|
return "branch"
|
|
default:
|
|
return "deep"
|
|
}
|
|
}
|
|
|
|
func postPointers(posts []store.Post) []*store.Post {
|
|
out := make([]*store.Post, len(posts))
|
|
for i := range posts {
|
|
out[i] = &posts[i]
|
|
}
|
|
return out
|
|
}
|