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