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