A community based topic aggregation platform built on atproto
1package comments 2 3import ( 4 "Coves/internal/core/communities" 5 "Coves/internal/core/posts" 6 "Coves/internal/core/users" 7 "context" 8 "errors" 9 "fmt" 10 "log" 11 "strings" 12 "time" 13) 14 15// Service defines the business logic interface for comment operations 16// Orchestrates repository calls and builds view models for API responses 17type Service interface { 18 // GetComments retrieves and builds a threaded comment tree for a post 19 // Supports hot, top, and new sorting with configurable depth and pagination 20 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 21} 22 23// GetCommentsRequest defines the parameters for fetching comments 24type GetCommentsRequest struct { 25 Cursor *string 26 ViewerDID *string 27 PostURI string 28 Sort string 29 Timeframe string 30 Depth int 31 Limit int 32} 33 34// commentService implements the Service interface 35// Coordinates between repository layer and view model construction 36type commentService struct { 37 commentRepo Repository // Comment data access 38 userRepo users.UserRepository // User lookup for author hydration 39 postRepo posts.Repository // Post lookup for building post views 40 communityRepo communities.Repository // Community lookup for community hydration 41} 42 43// NewCommentService creates a new comment service instance 44// All repositories are required for proper view construction per lexicon requirements 45func NewCommentService( 46 commentRepo Repository, 47 userRepo users.UserRepository, 48 postRepo posts.Repository, 49 communityRepo communities.Repository, 50) Service { 51 return &commentService{ 52 commentRepo: commentRepo, 53 userRepo: userRepo, 54 postRepo: postRepo, 55 communityRepo: communityRepo, 56 } 57} 58 59// GetComments retrieves comments for a post with threading and pagination 60// Algorithm: 61// 1. Validate input parameters and apply defaults 62// 2. Fetch top-level comments with specified sorting 63// 3. Recursively load nested replies up to depth limit 64// 4. Build view models with author info and stats 65// 5. Return response with pagination cursor 66func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) { 67 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations) 68 if err := validateGetCommentsRequest(req); err != nil { 69 return nil, fmt.Errorf("invalid request: %w", err) 70 } 71 72 // Add timeout to prevent runaway queries with deep nesting 73 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 74 defer cancel() 75 76 // 2. Fetch post for context 77 post, err := s.postRepo.GetByURI(ctx, req.PostURI) 78 if err != nil { 79 // Translate post not-found errors to comment-layer errors for proper HTTP status 80 if posts.IsNotFound(err) { 81 return nil, ErrRootNotFound 82 } 83 return nil, fmt.Errorf("failed to fetch post: %w", err) 84 } 85 86 // Build post view for response (hydrates author handle and community name) 87 postView := s.buildPostView(ctx, post, req.ViewerDID) 88 89 // 3. Fetch top-level comments with pagination 90 // Uses repository's hot rank sorting and cursor-based pagination 91 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank( 92 ctx, 93 req.PostURI, 94 req.Sort, 95 req.Timeframe, 96 req.Limit, 97 req.Cursor, 98 ) 99 if err != nil { 100 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err) 101 } 102 103 // 4. Build threaded view with nested replies up to depth limit 104 // This iteratively loads child comments and builds the tree structure 105 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID) 106 107 // 5. Return response with comments, post reference, and cursor 108 return &GetCommentsResponse{ 109 Comments: threadViews, 110 Post: postView, 111 Cursor: nextCursor, 112 }, nil 113} 114 115// buildThreadViews constructs threaded comment views with nested replies using batch loading 116// Uses batch queries to prevent N+1 query problem when loading nested replies 117// Loads replies level-by-level up to the specified depth limit 118func (s *commentService) buildThreadViews( 119 ctx context.Context, 120 comments []*Comment, 121 remainingDepth int, 122 sort string, 123 viewerDID *string, 124) []*ThreadViewComment { 125 // Always return an empty slice, never nil (important for JSON serialization) 126 result := make([]*ThreadViewComment, 0, len(comments)) 127 128 if len(comments) == 0 { 129 return result 130 } 131 132 // Build thread views for current level 133 threadViews := make([]*ThreadViewComment, 0, len(comments)) 134 commentsByURI := make(map[string]*ThreadViewComment) 135 parentsWithReplies := make([]string, 0) 136 137 for _, comment := range comments { 138 // Skip deleted comments (soft-deleted records) 139 if comment.DeletedAt != nil { 140 continue 141 } 142 143 // Build the comment view with author info and stats 144 commentView := s.buildCommentView(comment, viewerDID) 145 146 threadView := &ThreadViewComment{ 147 Comment: commentView, 148 Replies: nil, 149 HasMore: comment.ReplyCount > 0 && remainingDepth == 0, 150 } 151 152 threadViews = append(threadViews, threadView) 153 commentsByURI[comment.URI] = threadView 154 155 // Collect parent URIs that have replies and depth remaining 156 if remainingDepth > 0 && comment.ReplyCount > 0 { 157 parentsWithReplies = append(parentsWithReplies, comment.URI) 158 } 159 } 160 161 // Batch load all replies for this level in a single query 162 if len(parentsWithReplies) > 0 { 163 const repliesPerParent = 5 // Load top 5 replies per comment 164 165 repliesByParent, err := s.commentRepo.ListByParentsBatch( 166 ctx, 167 parentsWithReplies, 168 sort, 169 repliesPerParent, 170 ) 171 172 // Process replies if batch query succeeded 173 if err == nil { 174 // Group child comments by parent for recursive processing 175 for parentURI, replies := range repliesByParent { 176 threadView := commentsByURI[parentURI] 177 if threadView != nil && len(replies) > 0 { 178 // Recursively build views for child comments 179 threadView.Replies = s.buildThreadViews( 180 ctx, 181 replies, 182 remainingDepth-1, 183 sort, 184 viewerDID, 185 ) 186 187 // Update HasMore based on actual reply count vs loaded count 188 // Get the original comment to check reply count 189 for _, comment := range comments { 190 if comment.URI == parentURI { 191 threadView.HasMore = comment.ReplyCount > len(replies) 192 break 193 } 194 } 195 } 196 } 197 } 198 } 199 200 return threadViews 201} 202 203// buildCommentView converts a Comment entity to a CommentView with full metadata 204// Constructs author view, stats, and references to parent post/comment 205func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView { 206 // Build author view from comment data 207 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN 208 authorView := &posts.AuthorView{ 209 DID: comment.CommenterDID, 210 Handle: comment.CommenterHandle, 211 // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B) 212 } 213 214 // Build aggregated statistics 215 stats := &CommentStats{ 216 Upvotes: comment.UpvoteCount, 217 Downvotes: comment.DownvoteCount, 218 Score: comment.Score, 219 ReplyCount: comment.ReplyCount, 220 } 221 222 // Build reference to parent post (always present) 223 postRef := &CommentRef{ 224 URI: comment.RootURI, 225 CID: comment.RootCID, 226 } 227 228 // Build reference to parent comment (only if nested) 229 // Top-level comments have ParentURI == RootURI (both point to the post) 230 var parentRef *CommentRef 231 if comment.ParentURI != comment.RootURI { 232 parentRef = &CommentRef{ 233 URI: comment.ParentURI, 234 CID: comment.ParentCID, 235 } 236 } 237 238 // Build viewer state (stubbed for now - Phase 2B) 239 // Future: Fetch viewer's vote state from GetVoteStateForComments 240 var viewer *CommentViewerState 241 if viewerDID != nil { 242 // TODO: Query voter state 243 // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI}) 244 // For now, return empty viewer state to indicate authenticated request 245 viewer = &CommentViewerState{ 246 Vote: nil, 247 VoteURI: nil, 248 } 249 } 250 251 // Build minimal comment record to satisfy lexicon contract 252 // The record field is required by social.coves.community.comment.defs#commentView 253 commentRecord := s.buildCommentRecord(comment) 254 255 return &CommentView{ 256 URI: comment.URI, 257 CID: comment.CID, 258 Author: authorView, 259 Record: commentRecord, 260 Post: postRef, 261 Parent: parentRef, 262 Content: comment.Content, 263 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 264 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 265 Stats: stats, 266 Viewer: viewer, 267 } 268} 269 270// buildCommentRecord constructs a minimal CommentRecord from a Comment entity 271// Satisfies the lexicon requirement that commentView.record is a required field 272// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 273func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord { 274 record := &CommentRecord{ 275 Type: "social.coves.feed.comment", 276 Reply: ReplyRef{ 277 Root: StrongRef{ 278 URI: comment.RootURI, 279 CID: comment.RootCID, 280 }, 281 Parent: StrongRef{ 282 URI: comment.ParentURI, 283 CID: comment.ParentCID, 284 }, 285 }, 286 Content: comment.Content, 287 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 288 Langs: comment.Langs, 289 } 290 291 // TODO (Phase 2C): Parse JSON fields from database for complete record: 292 // - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{}) 293 // - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{}) 294 // - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels) 295 // These fields are stored as JSONB in the database and need proper deserialization 296 297 return record 298} 299 300// buildPostView converts a Post entity to a PostView for the comment response 301// Hydrates author handle and community name per lexicon requirements 302func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView { 303 // Build author view - fetch user to get handle (required by lexicon) 304 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid 305 authorHandle := post.AuthorDID // Fallback if user not found 306 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil { 307 authorHandle = user.Handle 308 } else { 309 // Log warning but don't fail the entire request 310 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 311 } 312 313 authorView := &posts.AuthorView{ 314 DID: post.AuthorDID, 315 Handle: authorHandle, 316 // TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile 317 } 318 319 // Build community reference - fetch community to get name (required by lexicon) 320 // The lexicon marks communityRef.name as required, so DIDs are insufficient 321 communityName := post.CommunityDID // Fallback if community not found 322 if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil { 323 communityName = community.Handle // Use handle as display name 324 // TODO (Phase 2C): Use community.DisplayName or community.Name if available 325 } else { 326 // Log warning but don't fail the entire request 327 log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err) 328 } 329 330 communityRef := &posts.CommunityRef{ 331 DID: post.CommunityDID, 332 Name: communityName, 333 // TODO (Phase 2C): Add Avatar from community profile 334 } 335 336 // Build aggregated statistics 337 stats := &posts.PostStats{ 338 Upvotes: post.UpvoteCount, 339 Downvotes: post.DownvoteCount, 340 Score: post.Score, 341 CommentCount: post.CommentCount, 342 } 343 344 // Build viewer state if authenticated 345 var viewer *posts.ViewerState 346 if viewerDID != nil { 347 // TODO (Phase 2B): Query viewer's vote state 348 viewer = &posts.ViewerState{ 349 Vote: nil, 350 VoteURI: nil, 351 Saved: false, 352 } 353 } 354 355 // Build minimal post record to satisfy lexicon contract 356 // The record field is required by social.coves.community.post.get#postView 357 postRecord := s.buildPostRecord(post) 358 359 return &posts.PostView{ 360 URI: post.URI, 361 CID: post.CID, 362 RKey: post.RKey, 363 Author: authorView, 364 Record: postRecord, 365 Community: communityRef, 366 Title: post.Title, 367 Text: post.Content, 368 CreatedAt: post.CreatedAt, 369 IndexedAt: post.IndexedAt, 370 EditedAt: post.EditedAt, 371 Stats: stats, 372 Viewer: viewer, 373 } 374} 375 376// buildPostRecord constructs a minimal PostRecord from a Post entity 377// Satisfies the lexicon requirement that postView.record is a required field 378// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 379func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 380 record := &posts.PostRecord{ 381 Type: "social.coves.community.post", 382 Community: post.CommunityDID, 383 Author: post.AuthorDID, 384 CreatedAt: post.CreatedAt.Format(time.RFC3339), 385 Title: post.Title, 386 Content: post.Content, 387 } 388 389 // TODO (Phase 2C): Parse JSON fields from database for complete record: 390 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 391 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 392 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 393 // These fields are stored as JSONB in the database and need proper deserialization 394 395 return record 396} 397 398// validateGetCommentsRequest validates and normalizes request parameters 399// Applies default values and enforces bounds per API specification 400func validateGetCommentsRequest(req *GetCommentsRequest) error { 401 if req == nil { 402 return errors.New("request cannot be nil") 403 } 404 405 // Validate PostURI is present and well-formed 406 if req.PostURI == "" { 407 return errors.New("post URI is required") 408 } 409 410 if !strings.HasPrefix(req.PostURI, "at://") { 411 return errors.New("invalid AT-URI format: must start with 'at://'") 412 } 413 414 // Apply depth defaults and bounds (0-100, default 10) 415 if req.Depth < 0 { 416 req.Depth = 10 417 } 418 if req.Depth > 100 { 419 req.Depth = 100 420 } 421 422 // Apply limit defaults and bounds (1-100, default 50) 423 if req.Limit <= 0 { 424 req.Limit = 50 425 } 426 if req.Limit > 100 { 427 req.Limit = 100 428 } 429 430 // Apply sort default and validate 431 if req.Sort == "" { 432 req.Sort = "hot" 433 } 434 435 validSorts := map[string]bool{ 436 "hot": true, 437 "top": true, 438 "new": true, 439 } 440 if !validSorts[req.Sort] { 441 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 442 } 443 444 // Validate timeframe (only applies to "top" sort) 445 if req.Timeframe != "" { 446 validTimeframes := map[string]bool{ 447 "hour": true, 448 "day": true, 449 "week": true, 450 "month": true, 451 "year": true, 452 "all": true, 453 } 454 if !validTimeframes[req.Timeframe] { 455 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 456 } 457 } 458 459 return nil 460}