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 "encoding/json" 9 "errors" 10 "fmt" 11 "log" 12 "net/url" 13 "strings" 14 "time" 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.community.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 and handle as required, so DIDs alone are insufficient 461 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data. 462 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID) 463 if err != nil { 464 // This indicates a data integrity issue: post references non-existent community 465 // Log as ERROR (not warning) since this should never happen in normal operation 466 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v", 467 post.URI, post.CommunityDID, err) 468 // Use DID as fallback for both handle and name to prevent breaking the API 469 // This allows the response to be returned while surfacing the integrity issue in logs 470 community = &communities.Community{ 471 DID: post.CommunityDID, 472 Handle: post.CommunityDID, // Fallback: use DID as handle 473 Name: post.CommunityDID, // Fallback: use DID as name 474 } 475 } 476 477 // Capture handle for communityRef (required by lexicon) 478 communityHandle := community.Handle 479 480 // Determine display name: prefer DisplayName, fall back to Name, then Handle 481 var communityName string 482 if community.DisplayName != "" { 483 communityName = community.DisplayName 484 } else if community.Name != "" { 485 communityName = community.Name 486 } else { 487 communityName = community.Handle 488 } 489 490 // Build avatar URL from CID if available 491 // Avatar is stored as blob in community's repository 492 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid} 493 var avatarURL *string 494 if community.AvatarCID != "" && community.PDSURL != "" { 495 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks) 496 if !strings.HasPrefix(community.PDSURL, "https://") { 497 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID) 498 } else if !strings.HasPrefix(community.AvatarCID, "baf") { 499 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32) 500 log.Printf("Warning: Invalid CID format for community %s", community.DID) 501 } else { 502 // Use proper URL escaping to prevent injection attacks 503 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 504 strings.TrimSuffix(community.PDSURL, "/"), 505 url.QueryEscape(community.DID), 506 url.QueryEscape(community.AvatarCID)) 507 avatarURL = &avatarURLString 508 } 509 } 510 511 communityRef := &posts.CommunityRef{ 512 DID: post.CommunityDID, 513 Handle: communityHandle, 514 Name: communityName, 515 Avatar: avatarURL, 516 } 517 518 // Build aggregated statistics 519 stats := &posts.PostStats{ 520 Upvotes: post.UpvoteCount, 521 Downvotes: post.DownvoteCount, 522 Score: post.Score, 523 CommentCount: post.CommentCount, 524 } 525 526 // Build viewer state if authenticated 527 var viewer *posts.ViewerState 528 if viewerDID != nil { 529 // TODO (Phase 2B): Query viewer's vote state 530 viewer = &posts.ViewerState{ 531 Vote: nil, 532 VoteURI: nil, 533 Saved: false, 534 } 535 } 536 537 // Build minimal post record to satisfy lexicon contract 538 // The record field is required by social.coves.community.post.get#postView 539 postRecord := s.buildPostRecord(post) 540 541 return &posts.PostView{ 542 URI: post.URI, 543 CID: post.CID, 544 RKey: post.RKey, 545 Author: authorView, 546 Record: postRecord, 547 Community: communityRef, 548 Title: post.Title, 549 Text: post.Content, 550 CreatedAt: post.CreatedAt, 551 IndexedAt: post.IndexedAt, 552 EditedAt: post.EditedAt, 553 Stats: stats, 554 Viewer: viewer, 555 } 556} 557 558// buildPostRecord constructs a minimal PostRecord from a Post entity 559// Satisfies the lexicon requirement that postView.record is a required field 560// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 561func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 562 record := &posts.PostRecord{ 563 Type: "social.coves.community.post", 564 Community: post.CommunityDID, 565 Author: post.AuthorDID, 566 CreatedAt: post.CreatedAt.Format(time.RFC3339), 567 Title: post.Title, 568 Content: post.Content, 569 } 570 571 // TODO (Phase 2C): Parse JSON fields from database for complete record: 572 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 573 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 574 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 575 // These fields are stored as JSONB in the database and need proper deserialization 576 577 return record 578} 579 580// validateGetCommentsRequest validates and normalizes request parameters 581// Applies default values and enforces bounds per API specification 582func validateGetCommentsRequest(req *GetCommentsRequest) error { 583 if req == nil { 584 return errors.New("request cannot be nil") 585 } 586 587 // Validate PostURI is present and well-formed 588 if req.PostURI == "" { 589 return errors.New("post URI is required") 590 } 591 592 if !strings.HasPrefix(req.PostURI, "at://") { 593 return errors.New("invalid AT-URI format: must start with 'at://'") 594 } 595 596 // Apply depth defaults and bounds (0-100, default 10) 597 if req.Depth < 0 { 598 req.Depth = 10 599 } 600 if req.Depth > 100 { 601 req.Depth = 100 602 } 603 604 // Apply limit defaults and bounds (1-100, default 50) 605 if req.Limit <= 0 { 606 req.Limit = 50 607 } 608 if req.Limit > 100 { 609 req.Limit = 100 610 } 611 612 // Apply sort default and validate 613 if req.Sort == "" { 614 req.Sort = "hot" 615 } 616 617 validSorts := map[string]bool{ 618 "hot": true, 619 "top": true, 620 "new": true, 621 } 622 if !validSorts[req.Sort] { 623 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 624 } 625 626 // Validate timeframe (only applies to "top" sort) 627 if req.Timeframe != "" { 628 validTimeframes := map[string]bool{ 629 "hour": true, 630 "day": true, 631 "week": true, 632 "month": true, 633 "year": true, 634 "all": true, 635 } 636 if !validTimeframes[req.Timeframe] { 637 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 638 } 639 } 640 641 return nil 642}