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 "log/slog" 13 "net/url" 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 "github.com/rivo/uniseg" 20 21 oauthclient "Coves/internal/atproto/oauth" 22 "Coves/internal/atproto/pds" 23) 24 25const ( 26 // DefaultRepliesPerParent defines how many nested replies to load per parent comment 27 // This balances UX (showing enough context) with performance (limiting query size) 28 // Can be made configurable via constructor if needed in the future 29 DefaultRepliesPerParent = 5 30 31 // commentCollection is the AT Protocol collection for comment records 32 commentCollection = "social.coves.community.comment" 33 34 // maxCommentGraphemes is the maximum length for comment content in graphemes 35 maxCommentGraphemes = 10000 36) 37 38// PDSClientFactory creates PDS clients from session data. 39// Used to allow injection of different auth mechanisms (OAuth for production, password for tests). 40type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) 41 42// Service defines the business logic interface for comment operations 43// Orchestrates repository calls and builds view models for API responses 44type Service interface { 45 // GetComments retrieves and builds a threaded comment tree for a post 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 49 // CreateComment creates a new comment or reply 50 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 52 // UpdateComment updates an existing comment's content 53 UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) 54 55 // DeleteComment soft-deletes a comment 56 DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error 57} 58 59// GetCommentsRequest defines the parameters for fetching comments 60type GetCommentsRequest struct { 61 Cursor *string 62 ViewerDID *string 63 PostURI string 64 Sort string 65 Timeframe string 66 Depth int 67 Limit int 68} 69 70// commentService implements the Service interface 71// Coordinates between repository layer and view model construction 72type commentService struct { 73 commentRepo Repository // Comment data access 74 userRepo users.UserRepository // User lookup for author hydration 75 postRepo posts.Repository // Post lookup for building post views 76 communityRepo communities.Repository // Community lookup for community hydration 77 oauthClient *oauthclient.OAuthClient // OAuth client for PDS authentication 78 oauthStore oauth.ClientAuthStore // OAuth session store 79 logger *slog.Logger // Structured logger 80 pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth. 81} 82 83// NewCommentService creates a new comment service instance 84// All repositories are required for proper view construction per lexicon requirements 85func NewCommentService( 86 commentRepo Repository, 87 userRepo users.UserRepository, 88 postRepo posts.Repository, 89 communityRepo communities.Repository, 90 oauthClient *oauthclient.OAuthClient, 91 oauthStore oauth.ClientAuthStore, 92 logger *slog.Logger, 93) Service { 94 if logger == nil { 95 logger = slog.Default() 96 } 97 return &commentService{ 98 commentRepo: commentRepo, 99 userRepo: userRepo, 100 postRepo: postRepo, 101 communityRepo: communityRepo, 102 oauthClient: oauthClient, 103 oauthStore: oauthStore, 104 logger: logger, 105 } 106} 107 108// NewCommentServiceWithPDSFactory creates a comment service with a custom PDS client factory. 109// This is primarily for testing with password-based authentication. 110func NewCommentServiceWithPDSFactory( 111 commentRepo Repository, 112 userRepo users.UserRepository, 113 postRepo posts.Repository, 114 communityRepo communities.Repository, 115 logger *slog.Logger, 116 factory PDSClientFactory, 117) Service { 118 if logger == nil { 119 logger = slog.Default() 120 } 121 return &commentService{ 122 commentRepo: commentRepo, 123 userRepo: userRepo, 124 postRepo: postRepo, 125 communityRepo: communityRepo, 126 logger: logger, 127 pdsClientFactory: factory, 128 } 129} 130 131// GetComments retrieves comments for a post with threading and pagination 132// Algorithm: 133// 1. Validate input parameters and apply defaults 134// 2. Fetch top-level comments with specified sorting 135// 3. Recursively load nested replies up to depth limit 136// 4. Build view models with author info and stats 137// 5. Return response with pagination cursor 138func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) { 139 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations) 140 if err := validateGetCommentsRequest(req); err != nil { 141 return nil, fmt.Errorf("invalid request: %w", err) 142 } 143 144 // Add timeout to prevent runaway queries with deep nesting 145 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 146 defer cancel() 147 148 // 2. Fetch post for context 149 post, err := s.postRepo.GetByURI(ctx, req.PostURI) 150 if err != nil { 151 // Translate post not-found errors to comment-layer errors for proper HTTP status 152 if posts.IsNotFound(err) { 153 return nil, ErrRootNotFound 154 } 155 return nil, fmt.Errorf("failed to fetch post: %w", err) 156 } 157 158 // Build post view for response (hydrates author handle and community name) 159 postView := s.buildPostView(ctx, post, req.ViewerDID) 160 161 // 3. Fetch top-level comments with pagination 162 // Uses repository's hot rank sorting and cursor-based pagination 163 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank( 164 ctx, 165 req.PostURI, 166 req.Sort, 167 req.Timeframe, 168 req.Limit, 169 req.Cursor, 170 ) 171 if err != nil { 172 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err) 173 } 174 175 // 4. Build threaded view with nested replies up to depth limit 176 // This iteratively loads child comments and builds the tree structure 177 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID) 178 179 // 5. Return response with comments, post reference, and cursor 180 return &GetCommentsResponse{ 181 Comments: threadViews, 182 Post: postView, 183 Cursor: nextCursor, 184 }, nil 185} 186 187// buildThreadViews constructs threaded comment views with nested replies using batch loading 188// Uses batch queries to prevent N+1 query problem when loading nested replies 189// Loads replies level-by-level up to the specified depth limit 190func (s *commentService) buildThreadViews( 191 ctx context.Context, 192 comments []*Comment, 193 remainingDepth int, 194 sort string, 195 viewerDID *string, 196) []*ThreadViewComment { 197 // Always return an empty slice, never nil (important for JSON serialization) 198 result := make([]*ThreadViewComment, 0, len(comments)) 199 200 if len(comments) == 0 { 201 return result 202 } 203 204 // Batch fetch vote states for all comments at this level (Phase 2B) 205 var voteStates map[string]interface{} 206 if viewerDID != nil { 207 commentURIs := make([]string, 0, len(comments)) 208 for _, comment := range comments { 209 if comment.DeletedAt == nil { 210 commentURIs = append(commentURIs, comment.URI) 211 } 212 } 213 214 if len(commentURIs) > 0 { 215 var err error 216 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, commentURIs) 217 if err != nil { 218 // Log error but don't fail the request - vote state is optional 219 log.Printf("Warning: Failed to fetch vote states for comments: %v", err) 220 } 221 } 222 } 223 224 // Batch fetch user data for all comment authors (Phase 2C) 225 // Collect unique author DIDs to prevent duplicate queries 226 authorDIDs := make([]string, 0, len(comments)) 227 seenDIDs := make(map[string]bool) 228 for _, comment := range comments { 229 if comment.DeletedAt == nil && !seenDIDs[comment.CommenterDID] { 230 authorDIDs = append(authorDIDs, comment.CommenterDID) 231 seenDIDs[comment.CommenterDID] = true 232 } 233 } 234 235 // Fetch all users in one query to avoid N+1 problem 236 var usersByDID map[string]*users.User 237 if len(authorDIDs) > 0 { 238 var err error 239 usersByDID, err = s.userRepo.GetByDIDs(ctx, authorDIDs) 240 if err != nil { 241 // Log error but don't fail the request - user data is optional 242 log.Printf("Warning: Failed to batch fetch users for comment authors: %v", err) 243 usersByDID = make(map[string]*users.User) 244 } 245 } else { 246 usersByDID = make(map[string]*users.User) 247 } 248 249 // Build thread views for current level 250 threadViews := make([]*ThreadViewComment, 0, len(comments)) 251 commentsByURI := make(map[string]*ThreadViewComment) 252 parentsWithReplies := make([]string, 0) 253 254 for _, comment := range comments { 255 var commentView *CommentView 256 257 // Build appropriate view based on deletion status 258 if comment.DeletedAt != nil { 259 // Deleted comment - build placeholder view to preserve thread structure 260 commentView = s.buildDeletedCommentView(comment) 261 } else { 262 // Active comment - build full view with author info and stats 263 commentView = s.buildCommentView(comment, viewerDID, voteStates, usersByDID) 264 } 265 266 threadView := &ThreadViewComment{ 267 Comment: commentView, 268 Replies: nil, 269 HasMore: comment.ReplyCount > 0 && remainingDepth == 0, 270 } 271 272 threadViews = append(threadViews, threadView) 273 commentsByURI[comment.URI] = threadView 274 275 // Collect parent URIs that have replies and depth remaining 276 // Include deleted comments so their children are still loaded 277 if remainingDepth > 0 && comment.ReplyCount > 0 { 278 parentsWithReplies = append(parentsWithReplies, comment.URI) 279 } 280 } 281 282 // Batch load all replies for this level in a single query 283 if len(parentsWithReplies) > 0 { 284 repliesByParent, err := s.commentRepo.ListByParentsBatch( 285 ctx, 286 parentsWithReplies, 287 sort, 288 DefaultRepliesPerParent, 289 ) 290 291 // Process replies if batch query succeeded 292 if err == nil { 293 // Group child comments by parent for recursive processing 294 for parentURI, replies := range repliesByParent { 295 threadView := commentsByURI[parentURI] 296 if threadView != nil && len(replies) > 0 { 297 // Recursively build views for child comments 298 threadView.Replies = s.buildThreadViews( 299 ctx, 300 replies, 301 remainingDepth-1, 302 sort, 303 viewerDID, 304 ) 305 306 // Update HasMore based on actual reply count vs loaded count 307 // Get the original comment to check reply count 308 for _, comment := range comments { 309 if comment.URI == parentURI { 310 threadView.HasMore = comment.ReplyCount > len(replies) 311 break 312 } 313 } 314 } 315 } 316 } 317 } 318 319 return threadViews 320} 321 322// buildCommentView converts a Comment entity to a CommentView with full metadata 323// Constructs author view, stats, and references to parent post/comment 324// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments) 325// usersByDID map contains pre-loaded user data for batch author hydration (Phase 2C) 326func (s *commentService) buildCommentView( 327 comment *Comment, 328 viewerDID *string, 329 voteStates map[string]interface{}, 330 usersByDID map[string]*users.User, 331) *CommentView { 332 // Build author view from comment data with full user hydration (Phase 2C) 333 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN (fallback) 334 // Prefer handle from usersByDID map for consistency 335 authorHandle := comment.CommenterHandle 336 if user, found := usersByDID[comment.CommenterDID]; found { 337 authorHandle = user.Handle 338 } 339 340 authorView := &posts.AuthorView{ 341 DID: comment.CommenterDID, 342 Handle: authorHandle, 343 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended 344 // Currently User model only has DID, Handle, PDSURL fields 345 DisplayName: nil, 346 Avatar: nil, 347 Reputation: nil, 348 } 349 350 // Build aggregated statistics 351 stats := &CommentStats{ 352 Upvotes: comment.UpvoteCount, 353 Downvotes: comment.DownvoteCount, 354 Score: comment.Score, 355 ReplyCount: comment.ReplyCount, 356 } 357 358 // Build reference to parent post (always present) 359 postRef := &CommentRef{ 360 URI: comment.RootURI, 361 CID: comment.RootCID, 362 } 363 364 // Build reference to parent comment (only if nested) 365 // Top-level comments have ParentURI == RootURI (both point to the post) 366 var parentRef *CommentRef 367 if comment.ParentURI != comment.RootURI { 368 parentRef = &CommentRef{ 369 URI: comment.ParentURI, 370 CID: comment.ParentCID, 371 } 372 } 373 374 // Build viewer state - populate from vote states map (Phase 2B) 375 var viewer *CommentViewerState 376 if viewerDID != nil { 377 viewer = &CommentViewerState{ 378 Vote: nil, 379 VoteURI: nil, 380 } 381 382 // Check if viewer has voted on this comment 383 if voteStates != nil { 384 if voteData, ok := voteStates[comment.URI]; ok { 385 voteMap, isMap := voteData.(map[string]interface{}) 386 if isMap { 387 // Extract vote direction and URI 388 // Create copies before taking addresses to avoid pointer to loop variable issues 389 if direction, hasDirection := voteMap["direction"].(string); hasDirection { 390 directionCopy := direction 391 viewer.Vote = &directionCopy 392 } 393 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI { 394 voteURICopy := voteURI 395 viewer.VoteURI = &voteURICopy 396 } 397 } 398 } 399 } 400 } 401 402 // Build minimal comment record to satisfy lexicon contract 403 // The record field is required by social.coves.community.comment.defs#commentView 404 commentRecord := s.buildCommentRecord(comment) 405 406 // Deserialize contentFacets from JSONB (Phase 2C) 407 // Parse facets from database JSON string to populate contentFacets field 408 var contentFacets []interface{} 409 if comment.ContentFacets != nil && *comment.ContentFacets != "" { 410 if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil { 411 // Log error but don't fail request - facets are optional 412 log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err) 413 } 414 } 415 416 // Deserialize embed from JSONB (Phase 2C) 417 // Parse embed from database JSON string to populate embed field 418 var embed interface{} 419 if comment.Embed != nil && *comment.Embed != "" { 420 var embedMap map[string]interface{} 421 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil { 422 // Log error but don't fail request - embed is optional 423 log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err) 424 } else { 425 embed = embedMap 426 } 427 } 428 429 return &CommentView{ 430 URI: comment.URI, 431 CID: comment.CID, 432 Author: authorView, 433 Record: commentRecord, 434 Post: postRef, 435 Parent: parentRef, 436 Content: comment.Content, 437 ContentFacets: contentFacets, 438 Embed: embed, 439 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 440 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 441 Stats: stats, 442 Viewer: viewer, 443 } 444} 445 446// buildDeletedCommentView creates a placeholder view for a deleted comment 447// Preserves threading structure while hiding content 448// Shows as "[deleted]" in the UI with minimal metadata 449func (s *commentService) buildDeletedCommentView(comment *Comment) *CommentView { 450 // Build minimal author view - just DID for attribution 451 // Frontend will display "[deleted]" or "[deleted by @user]" based on deletion_reason 452 authorView := &posts.AuthorView{ 453 DID: comment.CommenterDID, 454 Handle: "", // Empty - frontend handles display 455 DisplayName: nil, 456 Avatar: nil, 457 Reputation: nil, 458 } 459 460 // Build minimal stats - preserve reply count for threading indication 461 stats := &CommentStats{ 462 Upvotes: 0, 463 Downvotes: 0, 464 Score: 0, 465 ReplyCount: comment.ReplyCount, // Keep this to show threading 466 } 467 468 // Build reference to parent post (always present) 469 postRef := &CommentRef{ 470 URI: comment.RootURI, 471 CID: comment.RootCID, 472 } 473 474 // Build reference to parent comment (only if nested) 475 var parentRef *CommentRef 476 if comment.ParentURI != comment.RootURI { 477 parentRef = &CommentRef{ 478 URI: comment.ParentURI, 479 CID: comment.ParentCID, 480 } 481 } 482 483 // Format deletion timestamp for frontend 484 var deletedAtStr *string 485 if comment.DeletedAt != nil { 486 ts := comment.DeletedAt.Format(time.RFC3339) 487 deletedAtStr = &ts 488 } 489 490 return &CommentView{ 491 URI: comment.URI, 492 CID: comment.CID, 493 Author: authorView, 494 Record: nil, // No record for deleted comments 495 Post: postRef, 496 Parent: parentRef, 497 Content: "", // Blanked content 498 ContentFacets: nil, 499 Embed: nil, 500 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 501 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 502 Stats: stats, 503 Viewer: nil, // No viewer state for deleted comments 504 IsDeleted: true, 505 DeletionReason: comment.DeletionReason, 506 DeletedAt: deletedAtStr, 507 } 508} 509 510// buildCommentRecord constructs a complete CommentRecord from a Comment entity 511// Satisfies the lexicon requirement that commentView.record is a required field 512// Deserializes JSONB fields (embed, facets, labels) for complete record (Phase 2C) 513func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord { 514 record := &CommentRecord{ 515 Type: "social.coves.community.comment", 516 Reply: ReplyRef{ 517 Root: StrongRef{ 518 URI: comment.RootURI, 519 CID: comment.RootCID, 520 }, 521 Parent: StrongRef{ 522 URI: comment.ParentURI, 523 CID: comment.ParentCID, 524 }, 525 }, 526 Content: comment.Content, 527 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 528 Langs: comment.Langs, 529 } 530 531 // Deserialize facets from JSONB (Phase 2C) 532 if comment.ContentFacets != nil && *comment.ContentFacets != "" { 533 var facets []interface{} 534 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil { 535 // Log error but don't fail request - facets are optional 536 log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err) 537 } else { 538 record.Facets = facets 539 } 540 } 541 542 // Deserialize embed from JSONB (Phase 2C) 543 if comment.Embed != nil && *comment.Embed != "" { 544 var embed map[string]interface{} 545 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil { 546 // Log error but don't fail request - embed is optional 547 log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err) 548 } else { 549 record.Embed = embed 550 } 551 } 552 553 // Deserialize labels from JSONB (Phase 2C) 554 if comment.ContentLabels != nil && *comment.ContentLabels != "" { 555 var labels SelfLabels 556 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil { 557 // Log error but don't fail request - labels are optional 558 log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err) 559 } else { 560 record.Labels = &labels 561 } 562 } 563 564 return record 565} 566 567// getPDSClient creates a PDS client from an OAuth session. 568// If a custom factory was provided (for testing), uses that. 569// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling. 570func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 571 // Use custom factory if provided (e.g., for testing with password auth) 572 if s.pdsClientFactory != nil { 573 return s.pdsClientFactory(ctx, session) 574 } 575 576 // Production path: use OAuth with DPoP 577 if s.oauthClient == nil || s.oauthClient.ClientApp == nil { 578 return nil, fmt.Errorf("OAuth client not configured") 579 } 580 581 client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session) 582 if err != nil { 583 return nil, fmt.Errorf("failed to create PDS client: %w", err) 584 } 585 586 return client, nil 587} 588 589// CreateComment creates a new comment on a post or reply to another comment 590func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) { 591 // Validate content not empty 592 content := strings.TrimSpace(req.Content) 593 if content == "" { 594 return nil, ErrContentEmpty 595 } 596 597 // Validate content length (max 10000 graphemes) 598 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 599 return nil, ErrContentTooLong 600 } 601 602 // Validate reply references 603 if err := validateReplyRef(req.Reply); err != nil { 604 return nil, err 605 } 606 607 // Create PDS client for this session 608 pdsClient, err := s.getPDSClient(ctx, session) 609 if err != nil { 610 s.logger.Error("failed to create PDS client", 611 "error", err, 612 "commenter", session.AccountDID) 613 return nil, fmt.Errorf("failed to create PDS client: %w", err) 614 } 615 616 // Generate TID for the record key 617 tid := syntax.NewTIDNow(0) 618 619 // Build comment record following the lexicon schema 620 record := CommentRecord{ 621 Type: commentCollection, 622 Reply: req.Reply, 623 Content: content, 624 Facets: req.Facets, 625 Embed: req.Embed, 626 Langs: req.Langs, 627 Labels: req.Labels, 628 CreatedAt: time.Now().UTC().Format(time.RFC3339), 629 } 630 631 // Create the comment record on the user's PDS 632 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record) 633 if err != nil { 634 s.logger.Error("failed to create comment on PDS", 635 "error", err, 636 "commenter", session.AccountDID, 637 "root", req.Reply.Root.URI, 638 "parent", req.Reply.Parent.URI) 639 if pds.IsAuthError(err) { 640 return nil, ErrNotAuthorized 641 } 642 return nil, fmt.Errorf("failed to create comment: %w", err) 643 } 644 645 s.logger.Info("comment created", 646 "commenter", session.AccountDID, 647 "uri", uri, 648 "cid", cid, 649 "root", req.Reply.Root.URI, 650 "parent", req.Reply.Parent.URI) 651 652 return &CreateCommentResponse{ 653 URI: uri, 654 CID: cid, 655 }, nil 656} 657 658// UpdateComment updates an existing comment's content 659func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) { 660 // Validate URI format 661 if req.URI == "" { 662 return nil, ErrCommentNotFound 663 } 664 if !strings.HasPrefix(req.URI, "at://") { 665 return nil, ErrCommentNotFound 666 } 667 668 // Extract DID and rkey from URI (at://did/collection/rkey) 669 parts := strings.Split(req.URI, "/") 670 if len(parts) < 5 || parts[3] != commentCollection { 671 return nil, ErrCommentNotFound 672 } 673 did := parts[2] 674 rkey := parts[4] 675 676 // Verify ownership: URI must belong to the authenticated user 677 if did != session.AccountDID.String() { 678 return nil, ErrNotAuthorized 679 } 680 681 // Validate new content 682 content := strings.TrimSpace(req.Content) 683 if content == "" { 684 return nil, ErrContentEmpty 685 } 686 687 // Validate content length (max 10000 graphemes) 688 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 689 return nil, ErrContentTooLong 690 } 691 692 // Create PDS client for this session 693 pdsClient, err := s.getPDSClient(ctx, session) 694 if err != nil { 695 s.logger.Error("failed to create PDS client", 696 "error", err, 697 "commenter", session.AccountDID) 698 return nil, fmt.Errorf("failed to create PDS client: %w", err) 699 } 700 701 // Fetch existing record from PDS to get the reply refs (immutable) 702 existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey) 703 if err != nil { 704 s.logger.Error("failed to fetch existing comment from PDS", 705 "error", err, 706 "uri", req.URI, 707 "rkey", rkey) 708 if pds.IsAuthError(err) { 709 return nil, ErrNotAuthorized 710 } 711 if errors.Is(err, pds.ErrNotFound) { 712 return nil, ErrCommentNotFound 713 } 714 return nil, fmt.Errorf("failed to fetch existing comment: %w", err) 715 } 716 717 // Extract reply refs from existing record (must be preserved) 718 replyData, ok := existingRecord.Value["reply"].(map[string]interface{}) 719 if !ok { 720 s.logger.Error("invalid reply structure in existing comment", 721 "uri", req.URI) 722 return nil, fmt.Errorf("invalid existing comment structure") 723 } 724 725 // Parse reply refs 726 var reply ReplyRef 727 replyJSON, err := json.Marshal(replyData) 728 if err != nil { 729 return nil, fmt.Errorf("failed to marshal reply data: %w", err) 730 } 731 if err := json.Unmarshal(replyJSON, &reply); err != nil { 732 return nil, fmt.Errorf("failed to unmarshal reply data: %w", err) 733 } 734 735 // Extract original createdAt timestamp (immutable) 736 createdAt, _ := existingRecord.Value["createdAt"].(string) 737 if createdAt == "" { 738 createdAt = time.Now().UTC().Format(time.RFC3339) 739 } 740 741 // Build updated comment record 742 updatedRecord := CommentRecord{ 743 Type: commentCollection, 744 Reply: reply, // Preserve original reply refs 745 Content: content, 746 Facets: req.Facets, 747 Embed: req.Embed, 748 Langs: req.Langs, 749 Labels: req.Labels, 750 CreatedAt: createdAt, // Preserve original timestamp 751 } 752 753 // Update the record on PDS (putRecord) 754 // Note: This creates a new CID even though the URI stays the same 755 // TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking. 756 // PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected. 757 // However, PutRecord is not yet implemented in internal/atproto/pds/client.go. 758 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord) 759 if err != nil { 760 s.logger.Error("failed to update comment on PDS", 761 "error", err, 762 "uri", req.URI, 763 "rkey", rkey) 764 if pds.IsAuthError(err) { 765 return nil, ErrNotAuthorized 766 } 767 return nil, fmt.Errorf("failed to update comment: %w", err) 768 } 769 770 s.logger.Info("comment updated", 771 "commenter", session.AccountDID, 772 "uri", uri, 773 "new_cid", cid, 774 "old_cid", existingRecord.CID) 775 776 return &UpdateCommentResponse{ 777 URI: uri, 778 CID: cid, 779 }, nil 780} 781 782// DeleteComment soft-deletes a comment by removing it from the user's PDS 783func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error { 784 // Validate URI format 785 if req.URI == "" { 786 return ErrCommentNotFound 787 } 788 if !strings.HasPrefix(req.URI, "at://") { 789 return ErrCommentNotFound 790 } 791 792 // Extract DID and rkey from URI (at://did/collection/rkey) 793 parts := strings.Split(req.URI, "/") 794 if len(parts) < 5 || parts[3] != commentCollection { 795 return ErrCommentNotFound 796 } 797 did := parts[2] 798 rkey := parts[4] 799 800 // Verify ownership: URI must belong to the authenticated user 801 if did != session.AccountDID.String() { 802 return ErrNotAuthorized 803 } 804 805 // Create PDS client for this session 806 pdsClient, err := s.getPDSClient(ctx, session) 807 if err != nil { 808 s.logger.Error("failed to create PDS client", 809 "error", err, 810 "commenter", session.AccountDID) 811 return fmt.Errorf("failed to create PDS client: %w", err) 812 } 813 814 // Verify comment exists on PDS before deleting 815 _, err = pdsClient.GetRecord(ctx, commentCollection, rkey) 816 if err != nil { 817 s.logger.Error("failed to verify comment exists on PDS", 818 "error", err, 819 "uri", req.URI, 820 "rkey", rkey) 821 if pds.IsAuthError(err) { 822 return ErrNotAuthorized 823 } 824 if errors.Is(err, pds.ErrNotFound) { 825 return ErrCommentNotFound 826 } 827 return fmt.Errorf("failed to verify comment: %w", err) 828 } 829 830 // Delete the comment record from user's PDS 831 if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil { 832 s.logger.Error("failed to delete comment on PDS", 833 "error", err, 834 "uri", req.URI, 835 "rkey", rkey) 836 if pds.IsAuthError(err) { 837 return ErrNotAuthorized 838 } 839 return fmt.Errorf("failed to delete comment: %w", err) 840 } 841 842 s.logger.Info("comment deleted", 843 "commenter", session.AccountDID, 844 "uri", req.URI) 845 846 return nil 847} 848 849// validateReplyRef validates that reply references are well-formed 850func validateReplyRef(reply ReplyRef) error { 851 // Validate root reference 852 if reply.Root.URI == "" { 853 return ErrInvalidReply 854 } 855 if !strings.HasPrefix(reply.Root.URI, "at://") { 856 return ErrInvalidReply 857 } 858 if reply.Root.CID == "" { 859 return ErrInvalidReply 860 } 861 862 // Validate parent reference 863 if reply.Parent.URI == "" { 864 return ErrInvalidReply 865 } 866 if !strings.HasPrefix(reply.Parent.URI, "at://") { 867 return ErrInvalidReply 868 } 869 if reply.Parent.CID == "" { 870 return ErrInvalidReply 871 } 872 873 return nil 874} 875 876// buildPostView converts a Post entity to a PostView for the comment response 877// Hydrates author handle and community name per lexicon requirements 878func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView { 879 // Build author view - fetch user to get handle (required by lexicon) 880 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid 881 authorHandle := post.AuthorDID // Fallback if user not found 882 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil { 883 authorHandle = user.Handle 884 } else { 885 // Log warning but don't fail the entire request 886 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 887 } 888 889 authorView := &posts.AuthorView{ 890 DID: post.AuthorDID, 891 Handle: authorHandle, 892 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended 893 // Currently User model only has DID, Handle, PDSURL fields 894 DisplayName: nil, 895 Avatar: nil, 896 Reputation: nil, 897 } 898 899 // Build community reference - fetch community to get name and avatar (required by lexicon) 900 // The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient 901 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data. 902 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID) 903 if err != nil { 904 // This indicates a data integrity issue: post references non-existent community 905 // Log as ERROR (not warning) since this should never happen in normal operation 906 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v", 907 post.URI, post.CommunityDID, err) 908 // Use DID as fallback for both handle and name to prevent breaking the API 909 // This allows the response to be returned while surfacing the integrity issue in logs 910 community = &communities.Community{ 911 DID: post.CommunityDID, 912 Handle: post.CommunityDID, // Fallback: use DID as handle 913 Name: post.CommunityDID, // Fallback: use DID as name 914 } 915 } 916 917 // Capture handle for communityRef (required by lexicon) 918 communityHandle := community.Handle 919 920 // Determine display name: prefer DisplayName, fall back to Name, then Handle 921 var communityName string 922 if community.DisplayName != "" { 923 communityName = community.DisplayName 924 } else if community.Name != "" { 925 communityName = community.Name 926 } else { 927 communityName = community.Handle 928 } 929 930 // Build avatar URL from CID if available 931 // Avatar is stored as blob in community's repository 932 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid} 933 var avatarURL *string 934 if community.AvatarCID != "" && community.PDSURL != "" { 935 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks) 936 if !strings.HasPrefix(community.PDSURL, "https://") { 937 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID) 938 } else if !strings.HasPrefix(community.AvatarCID, "baf") { 939 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32) 940 log.Printf("Warning: Invalid CID format for community %s", community.DID) 941 } else { 942 // Use proper URL escaping to prevent injection attacks 943 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 944 strings.TrimSuffix(community.PDSURL, "/"), 945 url.QueryEscape(community.DID), 946 url.QueryEscape(community.AvatarCID)) 947 avatarURL = &avatarURLString 948 } 949 } 950 951 communityRef := &posts.CommunityRef{ 952 DID: post.CommunityDID, 953 Handle: communityHandle, 954 Name: communityName, 955 Avatar: avatarURL, 956 } 957 958 // Build aggregated statistics 959 stats := &posts.PostStats{ 960 Upvotes: post.UpvoteCount, 961 Downvotes: post.DownvoteCount, 962 Score: post.Score, 963 CommentCount: post.CommentCount, 964 } 965 966 // Build viewer state if authenticated 967 var viewer *posts.ViewerState 968 if viewerDID != nil { 969 // TODO (Phase 2B): Query viewer's vote state 970 viewer = &posts.ViewerState{ 971 Vote: nil, 972 VoteURI: nil, 973 Saved: false, 974 } 975 } 976 977 // Build minimal post record to satisfy lexicon contract 978 // The record field is required by social.coves.community.post.get#postView 979 postRecord := s.buildPostRecord(post) 980 981 return &posts.PostView{ 982 URI: post.URI, 983 CID: post.CID, 984 RKey: post.RKey, 985 Author: authorView, 986 Record: postRecord, 987 Community: communityRef, 988 Title: post.Title, 989 Text: post.Content, 990 CreatedAt: post.CreatedAt, 991 IndexedAt: post.IndexedAt, 992 EditedAt: post.EditedAt, 993 Stats: stats, 994 Viewer: viewer, 995 } 996} 997 998// buildPostRecord constructs a minimal PostRecord from a Post entity 999// Satisfies the lexicon requirement that postView.record is a required field 1000// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 1001func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 1002 record := &posts.PostRecord{ 1003 Type: "social.coves.community.post", 1004 Community: post.CommunityDID, 1005 Author: post.AuthorDID, 1006 CreatedAt: post.CreatedAt.Format(time.RFC3339), 1007 Title: post.Title, 1008 Content: post.Content, 1009 } 1010 1011 // TODO (Phase 2C): Parse JSON fields from database for complete record: 1012 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 1013 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 1014 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 1015 // These fields are stored as JSONB in the database and need proper deserialization 1016 1017 return record 1018} 1019 1020// validateGetCommentsRequest validates and normalizes request parameters 1021// Applies default values and enforces bounds per API specification 1022func validateGetCommentsRequest(req *GetCommentsRequest) error { 1023 if req == nil { 1024 return errors.New("request cannot be nil") 1025 } 1026 1027 // Validate PostURI is present and well-formed 1028 if req.PostURI == "" { 1029 return errors.New("post URI is required") 1030 } 1031 1032 if !strings.HasPrefix(req.PostURI, "at://") { 1033 return errors.New("invalid AT-URI format: must start with 'at://'") 1034 } 1035 1036 // Apply depth defaults and bounds (0-100, default 10) 1037 if req.Depth < 0 { 1038 req.Depth = 10 1039 } 1040 if req.Depth > 100 { 1041 req.Depth = 100 1042 } 1043 1044 // Apply limit defaults and bounds (1-100, default 50) 1045 if req.Limit <= 0 { 1046 req.Limit = 50 1047 } 1048 if req.Limit > 100 { 1049 req.Limit = 100 1050 } 1051 1052 // Apply sort default and validate 1053 if req.Sort == "" { 1054 req.Sort = "hot" 1055 } 1056 1057 validSorts := map[string]bool{ 1058 "hot": true, 1059 "top": true, 1060 "new": true, 1061 } 1062 if !validSorts[req.Sort] { 1063 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 1064 } 1065 1066 // Validate timeframe (only applies to "top" sort) 1067 if req.Timeframe != "" { 1068 validTimeframes := map[string]bool{ 1069 "hour": true, 1070 "day": true, 1071 "week": true, 1072 "month": true, 1073 "year": true, 1074 "all": true, 1075 } 1076 if !validTimeframes[req.Timeframe] { 1077 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 1078 } 1079 } 1080 1081 return nil 1082}