diff --git a/mutations_add_comment.go b/mutations_add_comment.go new file mode 100644 index 0000000..66a02f6 --- /dev/null +++ b/mutations_add_comment.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + "strings" +) + +// AddComment — appends a comment to a post. Bumps the post. Computes the +// comment path from the parent comment's path. Pre-validated; the lock is +// held during apply so the path computation + index update is atomic. +type AddComment struct { + PostID string + ParentID string // empty for top-level + AuthorID string + Body string + + // Set by Apply. + resolvedCommentID string + resolvedPath string + resolvedDepth int +} + +func (m *AddComment) Kind() string { return "add_comment" } + +func (m *AddComment) AffectedAuthors() []string { + return []string{m.AuthorID} +} + +func (m *AddComment) Validate(s *Store) error { + if strings.TrimSpace(m.AuthorID) == "" { + return &ValidationError{Field: "author_id", Message: "required"} + } + if _, ok := s.GetAuthor(m.AuthorID); !ok { + return &ValidationError{Field: "author_id", Message: fmt.Sprintf("unknown author %q", m.AuthorID)} + } + if strings.TrimSpace(m.PostID) == "" { + return &ValidationError{Field: "post_id", Message: "required"} + } + post, ok := s.GetPost(m.PostID) + if !ok { + return &ValidationError{Field: "post_id", Message: fmt.Sprintf("unknown post %q", m.PostID)} + } + if post.IsDeleted { + return &ValidationError{Field: "post_id", Message: "post is deleted"} + } + if m.ParentID != "" { + comments := s.ListComments(m.PostID) + found := false + for _, c := range comments { + if c.ID == m.ParentID && !c.IsDeleted { + found = true + break + } + } + if !found { + return &ValidationError{Field: "parent_id", Message: fmt.Sprintf("unknown parent %q on post %q", m.ParentID, m.PostID)} + } + } + if strings.TrimSpace(m.Body) == "" { + return &ValidationError{Field: "body", Message: "required"} + } + return nil +} + +func (m *AddComment) Apply(s *Store, lock LockRegion) error { + now := s.Now() + id := s.NewID() + + // Compute path under the lock. Look up parent's path if any. + s.mu.Lock() + defer s.mu.Unlock() + var path string + var depth int + if m.ParentID != "" { + parent, ok := s.comments[m.ParentID] + if !ok { + return fmt.Errorf("parent comment disappeared mid-apply: %s", m.ParentID) + } + path = parent.Path + "." + id + depth = parent.Depth + 1 + } else { + path = id + depth = 0 + } + + c := &Comment{ + ID: id, + PostID: m.PostID, + ParentID: m.ParentID, + Path: path, + Depth: depth, + AuthorID: m.AuthorID, + Body: m.Body, + CreatedAt: now, + UpdatedAt: now, + } + m.resolvedCommentID = id + m.resolvedPath = path + m.resolvedDepth = depth + s.comments[id] = c + s.indexInsertComment(c) + + // Bump the post. + post, ok := s.posts[m.PostID] + if !ok { + return fmt.Errorf("post disappeared mid-apply: %s", m.PostID) + } + post.BumpedAt = now + post.UpdatedAt = now + s.indexUpdatePost(post) + return nil +} + +func (m *AddComment) ResolvedCommentID() string { return m.resolvedCommentID } +func (m *AddComment) ResolvedPath() string { return m.resolvedPath } +func (m *AddComment) ResolvedDepth() int { return m.resolvedDepth }