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