A community based topic aggregation platform built on atproto
at main 35 kB view raw
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 with optimistic locking via swapRecord CID 754 uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID) 755 if err != nil { 756 s.logger.Error("failed to update comment on PDS", 757 "error", err, 758 "uri", req.URI, 759 "rkey", rkey) 760 if pds.IsAuthError(err) { 761 return nil, ErrNotAuthorized 762 } 763 if errors.Is(err, pds.ErrConflict) { 764 return nil, ErrConcurrentModification 765 } 766 return nil, fmt.Errorf("failed to update comment: %w", err) 767 } 768 769 s.logger.Info("comment updated", 770 "commenter", session.AccountDID, 771 "uri", uri, 772 "new_cid", cid, 773 "old_cid", existingRecord.CID) 774 775 return &UpdateCommentResponse{ 776 URI: uri, 777 CID: cid, 778 }, nil 779} 780 781// DeleteComment soft-deletes a comment by removing it from the user's PDS 782func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error { 783 // Validate URI format 784 if req.URI == "" { 785 return ErrCommentNotFound 786 } 787 if !strings.HasPrefix(req.URI, "at://") { 788 return ErrCommentNotFound 789 } 790 791 // Extract DID and rkey from URI (at://did/collection/rkey) 792 parts := strings.Split(req.URI, "/") 793 if len(parts) < 5 || parts[3] != commentCollection { 794 return ErrCommentNotFound 795 } 796 did := parts[2] 797 rkey := parts[4] 798 799 // Verify ownership: URI must belong to the authenticated user 800 if did != session.AccountDID.String() { 801 return ErrNotAuthorized 802 } 803 804 // Create PDS client for this session 805 pdsClient, err := s.getPDSClient(ctx, session) 806 if err != nil { 807 s.logger.Error("failed to create PDS client", 808 "error", err, 809 "commenter", session.AccountDID) 810 return fmt.Errorf("failed to create PDS client: %w", err) 811 } 812 813 // Verify comment exists on PDS before deleting 814 _, err = pdsClient.GetRecord(ctx, commentCollection, rkey) 815 if err != nil { 816 s.logger.Error("failed to verify comment exists on PDS", 817 "error", err, 818 "uri", req.URI, 819 "rkey", rkey) 820 if pds.IsAuthError(err) { 821 return ErrNotAuthorized 822 } 823 if errors.Is(err, pds.ErrNotFound) { 824 return ErrCommentNotFound 825 } 826 return fmt.Errorf("failed to verify comment: %w", err) 827 } 828 829 // Delete the comment record from user's PDS 830 if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil { 831 s.logger.Error("failed to delete comment on PDS", 832 "error", err, 833 "uri", req.URI, 834 "rkey", rkey) 835 if pds.IsAuthError(err) { 836 return ErrNotAuthorized 837 } 838 return fmt.Errorf("failed to delete comment: %w", err) 839 } 840 841 s.logger.Info("comment deleted", 842 "commenter", session.AccountDID, 843 "uri", req.URI) 844 845 return nil 846} 847 848// validateReplyRef validates that reply references are well-formed 849func validateReplyRef(reply ReplyRef) error { 850 // Validate root reference 851 if reply.Root.URI == "" { 852 return ErrInvalidReply 853 } 854 if !strings.HasPrefix(reply.Root.URI, "at://") { 855 return ErrInvalidReply 856 } 857 if reply.Root.CID == "" { 858 return ErrInvalidReply 859 } 860 861 // Validate parent reference 862 if reply.Parent.URI == "" { 863 return ErrInvalidReply 864 } 865 if !strings.HasPrefix(reply.Parent.URI, "at://") { 866 return ErrInvalidReply 867 } 868 if reply.Parent.CID == "" { 869 return ErrInvalidReply 870 } 871 872 return nil 873} 874 875// buildPostView converts a Post entity to a PostView for the comment response 876// Hydrates author handle and community name per lexicon requirements 877func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView { 878 // Build author view - fetch user to get handle (required by lexicon) 879 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid 880 authorHandle := post.AuthorDID // Fallback if user not found 881 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil { 882 authorHandle = user.Handle 883 } else { 884 // Log warning but don't fail the entire request 885 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 886 } 887 888 authorView := &posts.AuthorView{ 889 DID: post.AuthorDID, 890 Handle: authorHandle, 891 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended 892 // Currently User model only has DID, Handle, PDSURL fields 893 DisplayName: nil, 894 Avatar: nil, 895 Reputation: nil, 896 } 897 898 // Build community reference - fetch community to get name and avatar (required by lexicon) 899 // The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient 900 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data. 901 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID) 902 if err != nil { 903 // This indicates a data integrity issue: post references non-existent community 904 // Log as ERROR (not warning) since this should never happen in normal operation 905 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v", 906 post.URI, post.CommunityDID, err) 907 // Use DID as fallback for both handle and name to prevent breaking the API 908 // This allows the response to be returned while surfacing the integrity issue in logs 909 community = &communities.Community{ 910 DID: post.CommunityDID, 911 Handle: post.CommunityDID, // Fallback: use DID as handle 912 Name: post.CommunityDID, // Fallback: use DID as name 913 } 914 } 915 916 // Capture handle for communityRef (required by lexicon) 917 communityHandle := community.Handle 918 919 // Determine display name: prefer DisplayName, fall back to Name, then Handle 920 var communityName string 921 if community.DisplayName != "" { 922 communityName = community.DisplayName 923 } else if community.Name != "" { 924 communityName = community.Name 925 } else { 926 communityName = community.Handle 927 } 928 929 // Build avatar URL from CID if available 930 // Avatar is stored as blob in community's repository 931 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid} 932 var avatarURL *string 933 if community.AvatarCID != "" && community.PDSURL != "" { 934 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks) 935 if !strings.HasPrefix(community.PDSURL, "https://") { 936 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID) 937 } else if !strings.HasPrefix(community.AvatarCID, "baf") { 938 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32) 939 log.Printf("Warning: Invalid CID format for community %s", community.DID) 940 } else { 941 // Use proper URL escaping to prevent injection attacks 942 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 943 strings.TrimSuffix(community.PDSURL, "/"), 944 url.QueryEscape(community.DID), 945 url.QueryEscape(community.AvatarCID)) 946 avatarURL = &avatarURLString 947 } 948 } 949 950 communityRef := &posts.CommunityRef{ 951 DID: post.CommunityDID, 952 Handle: communityHandle, 953 Name: communityName, 954 Avatar: avatarURL, 955 } 956 957 // Build aggregated statistics 958 stats := &posts.PostStats{ 959 Upvotes: post.UpvoteCount, 960 Downvotes: post.DownvoteCount, 961 Score: post.Score, 962 CommentCount: post.CommentCount, 963 } 964 965 // Build viewer state if authenticated 966 var viewer *posts.ViewerState 967 if viewerDID != nil { 968 // TODO (Phase 2B): Query viewer's vote state 969 viewer = &posts.ViewerState{ 970 Vote: nil, 971 VoteURI: nil, 972 Saved: false, 973 } 974 } 975 976 // Build minimal post record to satisfy lexicon contract 977 // The record field is required by social.coves.community.post.get#postView 978 postRecord := s.buildPostRecord(post) 979 980 return &posts.PostView{ 981 URI: post.URI, 982 CID: post.CID, 983 RKey: post.RKey, 984 Author: authorView, 985 Record: postRecord, 986 Community: communityRef, 987 Title: post.Title, 988 Text: post.Content, 989 CreatedAt: post.CreatedAt, 990 IndexedAt: post.IndexedAt, 991 EditedAt: post.EditedAt, 992 Stats: stats, 993 Viewer: viewer, 994 } 995} 996 997// buildPostRecord constructs a minimal PostRecord from a Post entity 998// Satisfies the lexicon requirement that postView.record is a required field 999// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 1000func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 1001 record := &posts.PostRecord{ 1002 Type: "social.coves.community.post", 1003 Community: post.CommunityDID, 1004 Author: post.AuthorDID, 1005 CreatedAt: post.CreatedAt.Format(time.RFC3339), 1006 Title: post.Title, 1007 Content: post.Content, 1008 } 1009 1010 // TODO (Phase 2C): Parse JSON fields from database for complete record: 1011 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 1012 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 1013 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 1014 // These fields are stored as JSONB in the database and need proper deserialization 1015 1016 return record 1017} 1018 1019// validateGetCommentsRequest validates and normalizes request parameters 1020// Applies default values and enforces bounds per API specification 1021func validateGetCommentsRequest(req *GetCommentsRequest) error { 1022 if req == nil { 1023 return errors.New("request cannot be nil") 1024 } 1025 1026 // Validate PostURI is present and well-formed 1027 if req.PostURI == "" { 1028 return errors.New("post URI is required") 1029 } 1030 1031 if !strings.HasPrefix(req.PostURI, "at://") { 1032 return errors.New("invalid AT-URI format: must start with 'at://'") 1033 } 1034 1035 // Apply depth defaults and bounds (0-100, default 10) 1036 if req.Depth < 0 { 1037 req.Depth = 10 1038 } 1039 if req.Depth > 100 { 1040 req.Depth = 100 1041 } 1042 1043 // Apply limit defaults and bounds (1-100, default 50) 1044 if req.Limit <= 0 { 1045 req.Limit = 50 1046 } 1047 if req.Limit > 100 { 1048 req.Limit = 100 1049 } 1050 1051 // Apply sort default and validate 1052 if req.Sort == "" { 1053 req.Sort = "hot" 1054 } 1055 1056 validSorts := map[string]bool{ 1057 "hot": true, 1058 "top": true, 1059 "new": true, 1060 } 1061 if !validSorts[req.Sort] { 1062 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 1063 } 1064 1065 // Validate timeframe (only applies to "top" sort) 1066 if req.Timeframe != "" { 1067 validTimeframes := map[string]bool{ 1068 "hour": true, 1069 "day": true, 1070 "week": true, 1071 "month": true, 1072 "year": true, 1073 "all": true, 1074 } 1075 if !validTimeframes[req.Timeframe] { 1076 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 1077 } 1078 } 1079 1080 return nil 1081}