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