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 // Skip deleted comments (soft-deleted records) 256 if comment.DeletedAt != nil { 257 continue 258 } 259 260 // Build the comment view with author info and stats 261 commentView := s.buildCommentView(comment, viewerDID, voteStates, usersByDID) 262 263 threadView := &ThreadViewComment{ 264 Comment: commentView, 265 Replies: nil, 266 HasMore: comment.ReplyCount > 0 && remainingDepth == 0, 267 } 268 269 threadViews = append(threadViews, threadView) 270 commentsByURI[comment.URI] = threadView 271 272 // Collect parent URIs that have replies and depth remaining 273 if remainingDepth > 0 && comment.ReplyCount > 0 { 274 parentsWithReplies = append(parentsWithReplies, comment.URI) 275 } 276 } 277 278 // Batch load all replies for this level in a single query 279 if len(parentsWithReplies) > 0 { 280 repliesByParent, err := s.commentRepo.ListByParentsBatch( 281 ctx, 282 parentsWithReplies, 283 sort, 284 DefaultRepliesPerParent, 285 ) 286 287 // Process replies if batch query succeeded 288 if err == nil { 289 // Group child comments by parent for recursive processing 290 for parentURI, replies := range repliesByParent { 291 threadView := commentsByURI[parentURI] 292 if threadView != nil && len(replies) > 0 { 293 // Recursively build views for child comments 294 threadView.Replies = s.buildThreadViews( 295 ctx, 296 replies, 297 remainingDepth-1, 298 sort, 299 viewerDID, 300 ) 301 302 // Update HasMore based on actual reply count vs loaded count 303 // Get the original comment to check reply count 304 for _, comment := range comments { 305 if comment.URI == parentURI { 306 threadView.HasMore = comment.ReplyCount > len(replies) 307 break 308 } 309 } 310 } 311 } 312 } 313 } 314 315 return threadViews 316} 317 318// buildCommentView converts a Comment entity to a CommentView with full metadata 319// Constructs author view, stats, and references to parent post/comment 320// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments) 321// usersByDID map contains pre-loaded user data for batch author hydration (Phase 2C) 322func (s *commentService) buildCommentView( 323 comment *Comment, 324 viewerDID *string, 325 voteStates map[string]interface{}, 326 usersByDID map[string]*users.User, 327) *CommentView { 328 // Build author view from comment data with full user hydration (Phase 2C) 329 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN (fallback) 330 // Prefer handle from usersByDID map for consistency 331 authorHandle := comment.CommenterHandle 332 if user, found := usersByDID[comment.CommenterDID]; found { 333 authorHandle = user.Handle 334 } 335 336 authorView := &posts.AuthorView{ 337 DID: comment.CommenterDID, 338 Handle: authorHandle, 339 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended 340 // Currently User model only has DID, Handle, PDSURL fields 341 DisplayName: nil, 342 Avatar: nil, 343 Reputation: nil, 344 } 345 346 // Build aggregated statistics 347 stats := &CommentStats{ 348 Upvotes: comment.UpvoteCount, 349 Downvotes: comment.DownvoteCount, 350 Score: comment.Score, 351 ReplyCount: comment.ReplyCount, 352 } 353 354 // Build reference to parent post (always present) 355 postRef := &CommentRef{ 356 URI: comment.RootURI, 357 CID: comment.RootCID, 358 } 359 360 // Build reference to parent comment (only if nested) 361 // Top-level comments have ParentURI == RootURI (both point to the post) 362 var parentRef *CommentRef 363 if comment.ParentURI != comment.RootURI { 364 parentRef = &CommentRef{ 365 URI: comment.ParentURI, 366 CID: comment.ParentCID, 367 } 368 } 369 370 // Build viewer state - populate from vote states map (Phase 2B) 371 var viewer *CommentViewerState 372 if viewerDID != nil { 373 viewer = &CommentViewerState{ 374 Vote: nil, 375 VoteURI: nil, 376 } 377 378 // Check if viewer has voted on this comment 379 if voteStates != nil { 380 if voteData, ok := voteStates[comment.URI]; ok { 381 voteMap, isMap := voteData.(map[string]interface{}) 382 if isMap { 383 // Extract vote direction and URI 384 // Create copies before taking addresses to avoid pointer to loop variable issues 385 if direction, hasDirection := voteMap["direction"].(string); hasDirection { 386 directionCopy := direction 387 viewer.Vote = &directionCopy 388 } 389 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI { 390 voteURICopy := voteURI 391 viewer.VoteURI = &voteURICopy 392 } 393 } 394 } 395 } 396 } 397 398 // Build minimal comment record to satisfy lexicon contract 399 // The record field is required by social.coves.community.comment.defs#commentView 400 commentRecord := s.buildCommentRecord(comment) 401 402 // Deserialize contentFacets from JSONB (Phase 2C) 403 // Parse facets from database JSON string to populate contentFacets field 404 var contentFacets []interface{} 405 if comment.ContentFacets != nil && *comment.ContentFacets != "" { 406 if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil { 407 // Log error but don't fail request - facets are optional 408 log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err) 409 } 410 } 411 412 // Deserialize embed from JSONB (Phase 2C) 413 // Parse embed from database JSON string to populate embed field 414 var embed interface{} 415 if comment.Embed != nil && *comment.Embed != "" { 416 var embedMap map[string]interface{} 417 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil { 418 // Log error but don't fail request - embed is optional 419 log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err) 420 } else { 421 embed = embedMap 422 } 423 } 424 425 return &CommentView{ 426 URI: comment.URI, 427 CID: comment.CID, 428 Author: authorView, 429 Record: commentRecord, 430 Post: postRef, 431 Parent: parentRef, 432 Content: comment.Content, 433 ContentFacets: contentFacets, 434 Embed: embed, 435 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 436 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 437 Stats: stats, 438 Viewer: viewer, 439 } 440} 441 442// buildCommentRecord constructs a complete CommentRecord from a Comment entity 443// Satisfies the lexicon requirement that commentView.record is a required field 444// Deserializes JSONB fields (embed, facets, labels) for complete record (Phase 2C) 445func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord { 446 record := &CommentRecord{ 447 Type: "social.coves.community.comment", 448 Reply: ReplyRef{ 449 Root: StrongRef{ 450 URI: comment.RootURI, 451 CID: comment.RootCID, 452 }, 453 Parent: StrongRef{ 454 URI: comment.ParentURI, 455 CID: comment.ParentCID, 456 }, 457 }, 458 Content: comment.Content, 459 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 460 Langs: comment.Langs, 461 } 462 463 // Deserialize facets from JSONB (Phase 2C) 464 if comment.ContentFacets != nil && *comment.ContentFacets != "" { 465 var facets []interface{} 466 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil { 467 // Log error but don't fail request - facets are optional 468 log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err) 469 } else { 470 record.Facets = facets 471 } 472 } 473 474 // Deserialize embed from JSONB (Phase 2C) 475 if comment.Embed != nil && *comment.Embed != "" { 476 var embed map[string]interface{} 477 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil { 478 // Log error but don't fail request - embed is optional 479 log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err) 480 } else { 481 record.Embed = embed 482 } 483 } 484 485 // Deserialize labels from JSONB (Phase 2C) 486 if comment.ContentLabels != nil && *comment.ContentLabels != "" { 487 var labels SelfLabels 488 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil { 489 // Log error but don't fail request - labels are optional 490 log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err) 491 } else { 492 record.Labels = &labels 493 } 494 } 495 496 return record 497} 498 499// getPDSClient creates a PDS client from an OAuth session. 500// If a custom factory was provided (for testing), uses that. 501// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling. 502func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 503 // Use custom factory if provided (e.g., for testing with password auth) 504 if s.pdsClientFactory != nil { 505 return s.pdsClientFactory(ctx, session) 506 } 507 508 // Production path: use OAuth with DPoP 509 if s.oauthClient == nil || s.oauthClient.ClientApp == nil { 510 return nil, fmt.Errorf("OAuth client not configured") 511 } 512 513 client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session) 514 if err != nil { 515 return nil, fmt.Errorf("failed to create PDS client: %w", err) 516 } 517 518 return client, nil 519} 520 521// CreateComment creates a new comment on a post or reply to another comment 522func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) { 523 // Validate content not empty 524 content := strings.TrimSpace(req.Content) 525 if content == "" { 526 return nil, ErrContentEmpty 527 } 528 529 // Validate content length (max 10000 graphemes) 530 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 531 return nil, ErrContentTooLong 532 } 533 534 // Validate reply references 535 if err := validateReplyRef(req.Reply); err != nil { 536 return nil, err 537 } 538 539 // Create PDS client for this session 540 pdsClient, err := s.getPDSClient(ctx, session) 541 if err != nil { 542 s.logger.Error("failed to create PDS client", 543 "error", err, 544 "commenter", session.AccountDID) 545 return nil, fmt.Errorf("failed to create PDS client: %w", err) 546 } 547 548 // Generate TID for the record key 549 tid := syntax.NewTIDNow(0) 550 551 // Build comment record following the lexicon schema 552 record := CommentRecord{ 553 Type: commentCollection, 554 Reply: req.Reply, 555 Content: content, 556 Facets: req.Facets, 557 Embed: req.Embed, 558 Langs: req.Langs, 559 Labels: req.Labels, 560 CreatedAt: time.Now().UTC().Format(time.RFC3339), 561 } 562 563 // Create the comment record on the user's PDS 564 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record) 565 if err != nil { 566 s.logger.Error("failed to create comment on PDS", 567 "error", err, 568 "commenter", session.AccountDID, 569 "root", req.Reply.Root.URI, 570 "parent", req.Reply.Parent.URI) 571 if pds.IsAuthError(err) { 572 return nil, ErrNotAuthorized 573 } 574 return nil, fmt.Errorf("failed to create comment: %w", err) 575 } 576 577 s.logger.Info("comment created", 578 "commenter", session.AccountDID, 579 "uri", uri, 580 "cid", cid, 581 "root", req.Reply.Root.URI, 582 "parent", req.Reply.Parent.URI) 583 584 return &CreateCommentResponse{ 585 URI: uri, 586 CID: cid, 587 }, nil 588} 589 590// UpdateComment updates an existing comment's content 591func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) { 592 // Validate URI format 593 if req.URI == "" { 594 return nil, ErrCommentNotFound 595 } 596 if !strings.HasPrefix(req.URI, "at://") { 597 return nil, ErrCommentNotFound 598 } 599 600 // Extract DID and rkey from URI (at://did/collection/rkey) 601 parts := strings.Split(req.URI, "/") 602 if len(parts) < 5 || parts[3] != commentCollection { 603 return nil, ErrCommentNotFound 604 } 605 did := parts[2] 606 rkey := parts[4] 607 608 // Verify ownership: URI must belong to the authenticated user 609 if did != session.AccountDID.String() { 610 return nil, ErrNotAuthorized 611 } 612 613 // Validate new content 614 content := strings.TrimSpace(req.Content) 615 if content == "" { 616 return nil, ErrContentEmpty 617 } 618 619 // Validate content length (max 10000 graphemes) 620 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 621 return nil, ErrContentTooLong 622 } 623 624 // Create PDS client for this session 625 pdsClient, err := s.getPDSClient(ctx, session) 626 if err != nil { 627 s.logger.Error("failed to create PDS client", 628 "error", err, 629 "commenter", session.AccountDID) 630 return nil, fmt.Errorf("failed to create PDS client: %w", err) 631 } 632 633 // Fetch existing record from PDS to get the reply refs (immutable) 634 existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey) 635 if err != nil { 636 s.logger.Error("failed to fetch existing comment from PDS", 637 "error", err, 638 "uri", req.URI, 639 "rkey", rkey) 640 if pds.IsAuthError(err) { 641 return nil, ErrNotAuthorized 642 } 643 if errors.Is(err, pds.ErrNotFound) { 644 return nil, ErrCommentNotFound 645 } 646 return nil, fmt.Errorf("failed to fetch existing comment: %w", err) 647 } 648 649 // Extract reply refs from existing record (must be preserved) 650 replyData, ok := existingRecord.Value["reply"].(map[string]interface{}) 651 if !ok { 652 s.logger.Error("invalid reply structure in existing comment", 653 "uri", req.URI) 654 return nil, fmt.Errorf("invalid existing comment structure") 655 } 656 657 // Parse reply refs 658 var reply ReplyRef 659 replyJSON, err := json.Marshal(replyData) 660 if err != nil { 661 return nil, fmt.Errorf("failed to marshal reply data: %w", err) 662 } 663 if err := json.Unmarshal(replyJSON, &reply); err != nil { 664 return nil, fmt.Errorf("failed to unmarshal reply data: %w", err) 665 } 666 667 // Extract original createdAt timestamp (immutable) 668 createdAt, _ := existingRecord.Value["createdAt"].(string) 669 if createdAt == "" { 670 createdAt = time.Now().UTC().Format(time.RFC3339) 671 } 672 673 // Build updated comment record 674 updatedRecord := CommentRecord{ 675 Type: commentCollection, 676 Reply: reply, // Preserve original reply refs 677 Content: content, 678 Facets: req.Facets, 679 Embed: req.Embed, 680 Langs: req.Langs, 681 Labels: req.Labels, 682 CreatedAt: createdAt, // Preserve original timestamp 683 } 684 685 // Update the record on PDS (putRecord) 686 // Note: This creates a new CID even though the URI stays the same 687 // TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking. 688 // PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected. 689 // However, PutRecord is not yet implemented in internal/atproto/pds/client.go. 690 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord) 691 if err != nil { 692 s.logger.Error("failed to update comment on PDS", 693 "error", err, 694 "uri", req.URI, 695 "rkey", rkey) 696 if pds.IsAuthError(err) { 697 return nil, ErrNotAuthorized 698 } 699 return nil, fmt.Errorf("failed to update comment: %w", err) 700 } 701 702 s.logger.Info("comment updated", 703 "commenter", session.AccountDID, 704 "uri", uri, 705 "new_cid", cid, 706 "old_cid", existingRecord.CID) 707 708 return &UpdateCommentResponse{ 709 URI: uri, 710 CID: cid, 711 }, nil 712} 713 714// DeleteComment soft-deletes a comment by removing it from the user's PDS 715func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error { 716 // Validate URI format 717 if req.URI == "" { 718 return ErrCommentNotFound 719 } 720 if !strings.HasPrefix(req.URI, "at://") { 721 return ErrCommentNotFound 722 } 723 724 // Extract DID and rkey from URI (at://did/collection/rkey) 725 parts := strings.Split(req.URI, "/") 726 if len(parts) < 5 || parts[3] != commentCollection { 727 return ErrCommentNotFound 728 } 729 did := parts[2] 730 rkey := parts[4] 731 732 // Verify ownership: URI must belong to the authenticated user 733 if did != session.AccountDID.String() { 734 return ErrNotAuthorized 735 } 736 737 // Create PDS client for this session 738 pdsClient, err := s.getPDSClient(ctx, session) 739 if err != nil { 740 s.logger.Error("failed to create PDS client", 741 "error", err, 742 "commenter", session.AccountDID) 743 return fmt.Errorf("failed to create PDS client: %w", err) 744 } 745 746 // Verify comment exists on PDS before deleting 747 _, err = pdsClient.GetRecord(ctx, commentCollection, rkey) 748 if err != nil { 749 s.logger.Error("failed to verify comment exists on PDS", 750 "error", err, 751 "uri", req.URI, 752 "rkey", rkey) 753 if pds.IsAuthError(err) { 754 return ErrNotAuthorized 755 } 756 if errors.Is(err, pds.ErrNotFound) { 757 return ErrCommentNotFound 758 } 759 return fmt.Errorf("failed to verify comment: %w", err) 760 } 761 762 // Delete the comment record from user's PDS 763 if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil { 764 s.logger.Error("failed to delete comment on PDS", 765 "error", err, 766 "uri", req.URI, 767 "rkey", rkey) 768 if pds.IsAuthError(err) { 769 return ErrNotAuthorized 770 } 771 return fmt.Errorf("failed to delete comment: %w", err) 772 } 773 774 s.logger.Info("comment deleted", 775 "commenter", session.AccountDID, 776 "uri", req.URI) 777 778 return nil 779} 780 781// validateReplyRef validates that reply references are well-formed 782func validateReplyRef(reply ReplyRef) error { 783 // Validate root reference 784 if reply.Root.URI == "" { 785 return ErrInvalidReply 786 } 787 if !strings.HasPrefix(reply.Root.URI, "at://") { 788 return ErrInvalidReply 789 } 790 if reply.Root.CID == "" { 791 return ErrInvalidReply 792 } 793 794 // Validate parent reference 795 if reply.Parent.URI == "" { 796 return ErrInvalidReply 797 } 798 if !strings.HasPrefix(reply.Parent.URI, "at://") { 799 return ErrInvalidReply 800 } 801 if reply.Parent.CID == "" { 802 return ErrInvalidReply 803 } 804 805 return nil 806} 807 808// buildPostView converts a Post entity to a PostView for the comment response 809// Hydrates author handle and community name per lexicon requirements 810func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView { 811 // Build author view - fetch user to get handle (required by lexicon) 812 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid 813 authorHandle := post.AuthorDID // Fallback if user not found 814 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil { 815 authorHandle = user.Handle 816 } else { 817 // Log warning but don't fail the entire request 818 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 819 } 820 821 authorView := &posts.AuthorView{ 822 DID: post.AuthorDID, 823 Handle: authorHandle, 824 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended 825 // Currently User model only has DID, Handle, PDSURL fields 826 DisplayName: nil, 827 Avatar: nil, 828 Reputation: nil, 829 } 830 831 // Build community reference - fetch community to get name and avatar (required by lexicon) 832 // The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient 833 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data. 834 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID) 835 if err != nil { 836 // This indicates a data integrity issue: post references non-existent community 837 // Log as ERROR (not warning) since this should never happen in normal operation 838 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v", 839 post.URI, post.CommunityDID, err) 840 // Use DID as fallback for both handle and name to prevent breaking the API 841 // This allows the response to be returned while surfacing the integrity issue in logs 842 community = &communities.Community{ 843 DID: post.CommunityDID, 844 Handle: post.CommunityDID, // Fallback: use DID as handle 845 Name: post.CommunityDID, // Fallback: use DID as name 846 } 847 } 848 849 // Capture handle for communityRef (required by lexicon) 850 communityHandle := community.Handle 851 852 // Determine display name: prefer DisplayName, fall back to Name, then Handle 853 var communityName string 854 if community.DisplayName != "" { 855 communityName = community.DisplayName 856 } else if community.Name != "" { 857 communityName = community.Name 858 } else { 859 communityName = community.Handle 860 } 861 862 // Build avatar URL from CID if available 863 // Avatar is stored as blob in community's repository 864 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid} 865 var avatarURL *string 866 if community.AvatarCID != "" && community.PDSURL != "" { 867 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks) 868 if !strings.HasPrefix(community.PDSURL, "https://") { 869 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID) 870 } else if !strings.HasPrefix(community.AvatarCID, "baf") { 871 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32) 872 log.Printf("Warning: Invalid CID format for community %s", community.DID) 873 } else { 874 // Use proper URL escaping to prevent injection attacks 875 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 876 strings.TrimSuffix(community.PDSURL, "/"), 877 url.QueryEscape(community.DID), 878 url.QueryEscape(community.AvatarCID)) 879 avatarURL = &avatarURLString 880 } 881 } 882 883 communityRef := &posts.CommunityRef{ 884 DID: post.CommunityDID, 885 Handle: communityHandle, 886 Name: communityName, 887 Avatar: avatarURL, 888 } 889 890 // Build aggregated statistics 891 stats := &posts.PostStats{ 892 Upvotes: post.UpvoteCount, 893 Downvotes: post.DownvoteCount, 894 Score: post.Score, 895 CommentCount: post.CommentCount, 896 } 897 898 // Build viewer state if authenticated 899 var viewer *posts.ViewerState 900 if viewerDID != nil { 901 // TODO (Phase 2B): Query viewer's vote state 902 viewer = &posts.ViewerState{ 903 Vote: nil, 904 VoteURI: nil, 905 Saved: false, 906 } 907 } 908 909 // Build minimal post record to satisfy lexicon contract 910 // The record field is required by social.coves.community.post.get#postView 911 postRecord := s.buildPostRecord(post) 912 913 return &posts.PostView{ 914 URI: post.URI, 915 CID: post.CID, 916 RKey: post.RKey, 917 Author: authorView, 918 Record: postRecord, 919 Community: communityRef, 920 Title: post.Title, 921 Text: post.Content, 922 CreatedAt: post.CreatedAt, 923 IndexedAt: post.IndexedAt, 924 EditedAt: post.EditedAt, 925 Stats: stats, 926 Viewer: viewer, 927 } 928} 929 930// buildPostRecord constructs a minimal PostRecord from a Post entity 931// Satisfies the lexicon requirement that postView.record is a required field 932// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 933func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 934 record := &posts.PostRecord{ 935 Type: "social.coves.community.post", 936 Community: post.CommunityDID, 937 Author: post.AuthorDID, 938 CreatedAt: post.CreatedAt.Format(time.RFC3339), 939 Title: post.Title, 940 Content: post.Content, 941 } 942 943 // TODO (Phase 2C): Parse JSON fields from database for complete record: 944 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 945 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 946 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 947 // These fields are stored as JSONB in the database and need proper deserialization 948 949 return record 950} 951 952// validateGetCommentsRequest validates and normalizes request parameters 953// Applies default values and enforces bounds per API specification 954func validateGetCommentsRequest(req *GetCommentsRequest) error { 955 if req == nil { 956 return errors.New("request cannot be nil") 957 } 958 959 // Validate PostURI is present and well-formed 960 if req.PostURI == "" { 961 return errors.New("post URI is required") 962 } 963 964 if !strings.HasPrefix(req.PostURI, "at://") { 965 return errors.New("invalid AT-URI format: must start with 'at://'") 966 } 967 968 // Apply depth defaults and bounds (0-100, default 10) 969 if req.Depth < 0 { 970 req.Depth = 10 971 } 972 if req.Depth > 100 { 973 req.Depth = 100 974 } 975 976 // Apply limit defaults and bounds (1-100, default 50) 977 if req.Limit <= 0 { 978 req.Limit = 50 979 } 980 if req.Limit > 100 { 981 req.Limit = 100 982 } 983 984 // Apply sort default and validate 985 if req.Sort == "" { 986 req.Sort = "hot" 987 } 988 989 validSorts := map[string]bool{ 990 "hot": true, 991 "top": true, 992 "new": true, 993 } 994 if !validSorts[req.Sort] { 995 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 996 } 997 998 // Validate timeframe (only applies to "top" sort) 999 if req.Timeframe != "" { 1000 validTimeframes := map[string]bool{ 1001 "hour": true, 1002 "day": true, 1003 "week": true, 1004 "month": true, 1005 "year": true, 1006 "all": true, 1007 } 1008 if !validTimeframes[req.Timeframe] { 1009 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 1010 } 1011 } 1012 1013 return nil 1014}