A community based topic aggregation platform built on atproto
1package comments 2 3import ( 4 "Coves/internal/core/posts" 5 "context" 6 "errors" 7 "fmt" 8 "strings" 9 "time" 10) 11 12// Service defines the business logic interface for comment operations 13// Orchestrates repository calls and builds view models for API responses 14type Service interface { 15 // GetComments retrieves and builds a threaded comment tree for a post 16 // Supports hot, top, and new sorting with configurable depth and pagination 17 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 18} 19 20// GetCommentsRequest defines the parameters for fetching comments 21type GetCommentsRequest struct { 22 PostURI string // AT-URI of the post to fetch comments for 23 Sort string // "hot", "top", "new" - sorting algorithm 24 Timeframe string // "hour", "day", "week", "month", "year", "all" - for "top" sort only 25 Depth int // 0-100 - how many levels of nested replies to load (default 10) 26 Limit int // 1-100 - max top-level comments per page (default 50) 27 Cursor *string // Pagination cursor from previous response 28 ViewerDID *string // Optional DID of authenticated viewer (for vote state) 29} 30 31// commentService implements the Service interface 32// Coordinates between repository layer and view model construction 33type commentService struct { 34 commentRepo Repository // Comment data access 35 userRepo interface{} // User lookup (stubbed for now - Phase 2B) 36 postRepo interface{} // Post lookup (stubbed for now - Phase 2B) 37} 38 39// NewCommentService creates a new comment service instance 40// userRepo and postRepo are interface{} for now to allow incremental implementation 41func NewCommentService(commentRepo Repository, userRepo, postRepo interface{}) Service { 42 return &commentService{ 43 commentRepo: commentRepo, 44 userRepo: userRepo, 45 postRepo: postRepo, 46 } 47} 48 49// GetComments retrieves comments for a post with threading and pagination 50// Algorithm: 51// 1. Validate input parameters and apply defaults 52// 2. Fetch top-level comments with specified sorting 53// 3. Recursively load nested replies up to depth limit 54// 4. Build view models with author info and stats 55// 5. Return response with pagination cursor 56func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) { 57 // 1. Validate inputs and apply defaults/bounds 58 if err := validateGetCommentsRequest(req); err != nil { 59 return nil, fmt.Errorf("invalid request: %w", err) 60 } 61 62 // 2. Fetch post for context (stubbed for now - just create minimal response) 63 // Future: s.fetchPost(ctx, req.PostURI) 64 // For now, we'll return nil for Post field per the instructions 65 66 // 3. Fetch top-level comments with pagination 67 // Uses repository's hot rank sorting and cursor-based pagination 68 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank( 69 ctx, 70 req.PostURI, 71 req.Sort, 72 req.Timeframe, 73 req.Limit, 74 req.Cursor, 75 ) 76 if err != nil { 77 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err) 78 } 79 80 // 4. Build threaded view with nested replies up to depth limit 81 // This iteratively loads child comments and builds the tree structure 82 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID) 83 84 // 5. Return response with comments, post reference, and cursor 85 return &GetCommentsResponse{ 86 Comments: threadViews, 87 Post: nil, // TODO: Fetch and include PostView (Phase 2B) 88 Cursor: nextCursor, 89 }, nil 90} 91 92// buildThreadViews recursively constructs threaded comment views with nested replies 93// Loads replies iteratively up to the specified depth limit 94// Each level fetches a limited number of replies to prevent N+1 query explosions 95func (s *commentService) buildThreadViews( 96 ctx context.Context, 97 comments []*Comment, 98 remainingDepth int, 99 sort string, 100 viewerDID *string, 101) []*ThreadViewComment { 102 // Always return an empty slice, never nil (important for JSON serialization) 103 result := make([]*ThreadViewComment, 0, len(comments)) 104 105 if len(comments) == 0 { 106 return result 107 } 108 109 // Convert each comment to a thread view 110 for _, comment := range comments { 111 // Skip deleted comments (soft-deleted records) 112 if comment.DeletedAt != nil { 113 continue 114 } 115 116 // Build the comment view with author info and stats 117 commentView := s.buildCommentView(comment, viewerDID) 118 119 threadView := &ThreadViewComment{ 120 Comment: commentView, 121 Replies: nil, 122 HasMore: comment.ReplyCount > 0 && remainingDepth == 0, 123 } 124 125 // Recursively load replies if depth remains and comment has replies 126 if remainingDepth > 0 && comment.ReplyCount > 0 { 127 // Load first 5 replies per comment (configurable constant) 128 // This prevents excessive nesting while showing conversation flow 129 const repliesPerLevel = 5 130 131 replies, _, err := s.commentRepo.ListByParentWithHotRank( 132 ctx, 133 comment.URI, 134 sort, 135 "", // No timeframe filter for nested replies 136 repliesPerLevel, 137 nil, // No cursor for nested replies (top 5 only) 138 ) 139 140 // Only recurse if we successfully fetched replies 141 if err == nil && len(replies) > 0 { 142 threadView.Replies = s.buildThreadViews( 143 ctx, 144 replies, 145 remainingDepth-1, 146 sort, 147 viewerDID, 148 ) 149 150 // HasMore indicates if there are additional replies beyond what we loaded 151 threadView.HasMore = comment.ReplyCount > len(replies) 152 } 153 } 154 155 result = append(result, threadView) 156 } 157 158 return result 159} 160 161// buildCommentView converts a Comment entity to a CommentView with full metadata 162// Constructs author view, stats, and references to parent post/comment 163func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView { 164 // Build author view from comment data 165 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN 166 authorView := &posts.AuthorView{ 167 DID: comment.CommenterDID, 168 Handle: comment.CommenterHandle, 169 // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B) 170 } 171 172 // Build aggregated statistics 173 stats := &CommentStats{ 174 Upvotes: comment.UpvoteCount, 175 Downvotes: comment.DownvoteCount, 176 Score: comment.Score, 177 ReplyCount: comment.ReplyCount, 178 } 179 180 // Build reference to parent post (always present) 181 postRef := &CommentRef{ 182 URI: comment.RootURI, 183 CID: comment.RootCID, 184 } 185 186 // Build reference to parent comment (only if nested) 187 // Top-level comments have ParentURI == RootURI (both point to the post) 188 var parentRef *CommentRef 189 if comment.ParentURI != comment.RootURI { 190 parentRef = &CommentRef{ 191 URI: comment.ParentURI, 192 CID: comment.ParentCID, 193 } 194 } 195 196 // Build viewer state (stubbed for now - Phase 2B) 197 // Future: Fetch viewer's vote state from GetVoteStateForComments 198 var viewer *CommentViewerState 199 if viewerDID != nil { 200 // TODO: Query voter state 201 // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI}) 202 // For now, return empty viewer state to indicate authenticated request 203 viewer = &CommentViewerState{ 204 Vote: nil, 205 VoteURI: nil, 206 } 207 } 208 209 return &CommentView{ 210 URI: comment.URI, 211 CID: comment.CID, 212 Author: authorView, 213 Record: nil, // TODO: Parse and include original record if needed (Phase 2B) 214 Post: postRef, 215 Parent: parentRef, 216 Content: comment.Content, 217 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 218 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 219 Stats: stats, 220 Viewer: viewer, 221 } 222} 223 224// validateGetCommentsRequest validates and normalizes request parameters 225// Applies default values and enforces bounds per API specification 226func validateGetCommentsRequest(req *GetCommentsRequest) error { 227 if req == nil { 228 return errors.New("request cannot be nil") 229 } 230 231 // Validate PostURI is present and well-formed 232 if req.PostURI == "" { 233 return errors.New("post URI is required") 234 } 235 236 if !strings.HasPrefix(req.PostURI, "at://") { 237 return errors.New("invalid AT-URI format: must start with 'at://'") 238 } 239 240 // Apply depth defaults and bounds (0-100, default 10) 241 if req.Depth < 0 { 242 req.Depth = 10 243 } 244 if req.Depth > 100 { 245 req.Depth = 100 246 } 247 248 // Apply limit defaults and bounds (1-100, default 50) 249 if req.Limit <= 0 { 250 req.Limit = 50 251 } 252 if req.Limit > 100 { 253 req.Limit = 100 254 } 255 256 // Apply sort default and validate 257 if req.Sort == "" { 258 req.Sort = "hot" 259 } 260 261 validSorts := map[string]bool{ 262 "hot": true, 263 "top": true, 264 "new": true, 265 } 266 if !validSorts[req.Sort] { 267 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 268 } 269 270 // Validate timeframe (only applies to "top" sort) 271 if req.Timeframe != "" { 272 validTimeframes := map[string]bool{ 273 "hour": true, 274 "day": true, 275 "week": true, 276 "month": true, 277 "year": true, 278 "all": true, 279 } 280 if !validTimeframes[req.Timeframe] { 281 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 282 } 283 } 284 285 return nil 286}