A community based topic aggregation platform built on atproto
1package comments
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "net/url"
10 "strings"
11 "time"
12
13 "Coves/internal/core/communities"
14 "Coves/internal/core/posts"
15 "Coves/internal/core/users"
16)
17
18const (
19 // DefaultRepliesPerParent defines how many nested replies to load per parent comment
20 // This balances UX (showing enough context) with performance (limiting query size)
21 // Can be made configurable via constructor if needed in the future
22 DefaultRepliesPerParent = 5
23)
24
25// Service defines the business logic interface for comment operations
26// Orchestrates repository calls and builds view models for API responses
27type Service interface {
28 // GetComments retrieves and builds a threaded comment tree for a post
29 // Supports hot, top, and new sorting with configurable depth and pagination
30 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
31}
32
33// GetCommentsRequest defines the parameters for fetching comments
34type GetCommentsRequest struct {
35 Cursor *string
36 ViewerDID *string
37 PostURI string
38 Sort string
39 Timeframe string
40 Depth int
41 Limit int
42}
43
44// commentService implements the Service interface
45// Coordinates between repository layer and view model construction
46type commentService struct {
47 commentRepo Repository // Comment data access
48 userRepo users.UserRepository // User lookup for author hydration
49 postRepo posts.Repository // Post lookup for building post views
50 communityRepo communities.Repository // Community lookup for community hydration
51}
52
53// NewCommentService creates a new comment service instance
54// All repositories are required for proper view construction per lexicon requirements
55func NewCommentService(
56 commentRepo Repository,
57 userRepo users.UserRepository,
58 postRepo posts.Repository,
59 communityRepo communities.Repository,
60) Service {
61 return &commentService{
62 commentRepo: commentRepo,
63 userRepo: userRepo,
64 postRepo: postRepo,
65 communityRepo: communityRepo,
66 }
67}
68
69// GetComments retrieves comments for a post with threading and pagination
70// Algorithm:
71// 1. Validate input parameters and apply defaults
72// 2. Fetch top-level comments with specified sorting
73// 3. Recursively load nested replies up to depth limit
74// 4. Build view models with author info and stats
75// 5. Return response with pagination cursor
76func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
77 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
78 if err := validateGetCommentsRequest(req); err != nil {
79 return nil, fmt.Errorf("invalid request: %w", err)
80 }
81
82 // Add timeout to prevent runaway queries with deep nesting
83 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
84 defer cancel()
85
86 // 2. Fetch post for context
87 post, err := s.postRepo.GetByURI(ctx, req.PostURI)
88 if err != nil {
89 // Translate post not-found errors to comment-layer errors for proper HTTP status
90 if posts.IsNotFound(err) {
91 return nil, ErrRootNotFound
92 }
93 return nil, fmt.Errorf("failed to fetch post: %w", err)
94 }
95
96 // Build post view for response (hydrates author handle and community name)
97 postView := s.buildPostView(ctx, post, req.ViewerDID)
98
99 // 3. Fetch top-level comments with pagination
100 // Uses repository's hot rank sorting and cursor-based pagination
101 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
102 ctx,
103 req.PostURI,
104 req.Sort,
105 req.Timeframe,
106 req.Limit,
107 req.Cursor,
108 )
109 if err != nil {
110 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
111 }
112
113 // 4. Build threaded view with nested replies up to depth limit
114 // This iteratively loads child comments and builds the tree structure
115 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
116
117 // 5. Return response with comments, post reference, and cursor
118 return &GetCommentsResponse{
119 Comments: threadViews,
120 Post: postView,
121 Cursor: nextCursor,
122 }, nil
123}
124
125// buildThreadViews constructs threaded comment views with nested replies using batch loading
126// Uses batch queries to prevent N+1 query problem when loading nested replies
127// Loads replies level-by-level up to the specified depth limit
128func (s *commentService) buildThreadViews(
129 ctx context.Context,
130 comments []*Comment,
131 remainingDepth int,
132 sort string,
133 viewerDID *string,
134) []*ThreadViewComment {
135 // Always return an empty slice, never nil (important for JSON serialization)
136 result := make([]*ThreadViewComment, 0, len(comments))
137
138 if len(comments) == 0 {
139 return result
140 }
141
142 // Batch fetch vote states for all comments at this level (Phase 2B)
143 var voteStates map[string]interface{}
144 if viewerDID != nil {
145 commentURIs := make([]string, 0, len(comments))
146 for _, comment := range comments {
147 if comment.DeletedAt == nil {
148 commentURIs = append(commentURIs, comment.URI)
149 }
150 }
151
152 if len(commentURIs) > 0 {
153 var err error
154 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, commentURIs)
155 if err != nil {
156 // Log error but don't fail the request - vote state is optional
157 log.Printf("Warning: Failed to fetch vote states for comments: %v", err)
158 }
159 }
160 }
161
162 // Batch fetch user data for all comment authors (Phase 2C)
163 // Collect unique author DIDs to prevent duplicate queries
164 authorDIDs := make([]string, 0, len(comments))
165 seenDIDs := make(map[string]bool)
166 for _, comment := range comments {
167 if comment.DeletedAt == nil && !seenDIDs[comment.CommenterDID] {
168 authorDIDs = append(authorDIDs, comment.CommenterDID)
169 seenDIDs[comment.CommenterDID] = true
170 }
171 }
172
173 // Fetch all users in one query to avoid N+1 problem
174 var usersByDID map[string]*users.User
175 if len(authorDIDs) > 0 {
176 var err error
177 usersByDID, err = s.userRepo.GetByDIDs(ctx, authorDIDs)
178 if err != nil {
179 // Log error but don't fail the request - user data is optional
180 log.Printf("Warning: Failed to batch fetch users for comment authors: %v", err)
181 usersByDID = make(map[string]*users.User)
182 }
183 } else {
184 usersByDID = make(map[string]*users.User)
185 }
186
187 // Build thread views for current level
188 threadViews := make([]*ThreadViewComment, 0, len(comments))
189 commentsByURI := make(map[string]*ThreadViewComment)
190 parentsWithReplies := make([]string, 0)
191
192 for _, comment := range comments {
193 // Skip deleted comments (soft-deleted records)
194 if comment.DeletedAt != nil {
195 continue
196 }
197
198 // Build the comment view with author info and stats
199 commentView := s.buildCommentView(comment, viewerDID, voteStates, usersByDID)
200
201 threadView := &ThreadViewComment{
202 Comment: commentView,
203 Replies: nil,
204 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
205 }
206
207 threadViews = append(threadViews, threadView)
208 commentsByURI[comment.URI] = threadView
209
210 // Collect parent URIs that have replies and depth remaining
211 if remainingDepth > 0 && comment.ReplyCount > 0 {
212 parentsWithReplies = append(parentsWithReplies, comment.URI)
213 }
214 }
215
216 // Batch load all replies for this level in a single query
217 if len(parentsWithReplies) > 0 {
218 repliesByParent, err := s.commentRepo.ListByParentsBatch(
219 ctx,
220 parentsWithReplies,
221 sort,
222 DefaultRepliesPerParent,
223 )
224
225 // Process replies if batch query succeeded
226 if err == nil {
227 // Group child comments by parent for recursive processing
228 for parentURI, replies := range repliesByParent {
229 threadView := commentsByURI[parentURI]
230 if threadView != nil && len(replies) > 0 {
231 // Recursively build views for child comments
232 threadView.Replies = s.buildThreadViews(
233 ctx,
234 replies,
235 remainingDepth-1,
236 sort,
237 viewerDID,
238 )
239
240 // Update HasMore based on actual reply count vs loaded count
241 // Get the original comment to check reply count
242 for _, comment := range comments {
243 if comment.URI == parentURI {
244 threadView.HasMore = comment.ReplyCount > len(replies)
245 break
246 }
247 }
248 }
249 }
250 }
251 }
252
253 return threadViews
254}
255
256// buildCommentView converts a Comment entity to a CommentView with full metadata
257// Constructs author view, stats, and references to parent post/comment
258// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments)
259// usersByDID map contains pre-loaded user data for batch author hydration (Phase 2C)
260func (s *commentService) buildCommentView(
261 comment *Comment,
262 viewerDID *string,
263 voteStates map[string]interface{},
264 usersByDID map[string]*users.User,
265) *CommentView {
266 // Build author view from comment data with full user hydration (Phase 2C)
267 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN (fallback)
268 // Prefer handle from usersByDID map for consistency
269 authorHandle := comment.CommenterHandle
270 if user, found := usersByDID[comment.CommenterDID]; found {
271 authorHandle = user.Handle
272 }
273
274 authorView := &posts.AuthorView{
275 DID: comment.CommenterDID,
276 Handle: authorHandle,
277 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
278 // Currently User model only has DID, Handle, PDSURL fields
279 DisplayName: nil,
280 Avatar: nil,
281 Reputation: nil,
282 }
283
284 // Build aggregated statistics
285 stats := &CommentStats{
286 Upvotes: comment.UpvoteCount,
287 Downvotes: comment.DownvoteCount,
288 Score: comment.Score,
289 ReplyCount: comment.ReplyCount,
290 }
291
292 // Build reference to parent post (always present)
293 postRef := &CommentRef{
294 URI: comment.RootURI,
295 CID: comment.RootCID,
296 }
297
298 // Build reference to parent comment (only if nested)
299 // Top-level comments have ParentURI == RootURI (both point to the post)
300 var parentRef *CommentRef
301 if comment.ParentURI != comment.RootURI {
302 parentRef = &CommentRef{
303 URI: comment.ParentURI,
304 CID: comment.ParentCID,
305 }
306 }
307
308 // Build viewer state - populate from vote states map (Phase 2B)
309 var viewer *CommentViewerState
310 if viewerDID != nil {
311 viewer = &CommentViewerState{
312 Vote: nil,
313 VoteURI: nil,
314 }
315
316 // Check if viewer has voted on this comment
317 if voteStates != nil {
318 if voteData, ok := voteStates[comment.URI]; ok {
319 voteMap, isMap := voteData.(map[string]interface{})
320 if isMap {
321 // Extract vote direction and URI
322 // Create copies before taking addresses to avoid pointer to loop variable issues
323 if direction, hasDirection := voteMap["direction"].(string); hasDirection {
324 directionCopy := direction
325 viewer.Vote = &directionCopy
326 }
327 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI {
328 voteURICopy := voteURI
329 viewer.VoteURI = &voteURICopy
330 }
331 }
332 }
333 }
334 }
335
336 // Build minimal comment record to satisfy lexicon contract
337 // The record field is required by social.coves.community.comment.defs#commentView
338 commentRecord := s.buildCommentRecord(comment)
339
340 // Deserialize contentFacets from JSONB (Phase 2C)
341 // Parse facets from database JSON string to populate contentFacets field
342 var contentFacets []interface{}
343 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
344 if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil {
345 // Log error but don't fail request - facets are optional
346 log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err)
347 }
348 }
349
350 // Deserialize embed from JSONB (Phase 2C)
351 // Parse embed from database JSON string to populate embed field
352 var embed interface{}
353 if comment.Embed != nil && *comment.Embed != "" {
354 var embedMap map[string]interface{}
355 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil {
356 // Log error but don't fail request - embed is optional
357 log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err)
358 } else {
359 embed = embedMap
360 }
361 }
362
363 return &CommentView{
364 URI: comment.URI,
365 CID: comment.CID,
366 Author: authorView,
367 Record: commentRecord,
368 Post: postRef,
369 Parent: parentRef,
370 Content: comment.Content,
371 ContentFacets: contentFacets,
372 Embed: embed,
373 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
374 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
375 Stats: stats,
376 Viewer: viewer,
377 }
378}
379
380// buildCommentRecord constructs a complete CommentRecord from a Comment entity
381// Satisfies the lexicon requirement that commentView.record is a required field
382// Deserializes JSONB fields (embed, facets, labels) for complete record (Phase 2C)
383func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
384 record := &CommentRecord{
385 Type: "social.coves.feed.comment",
386 Reply: ReplyRef{
387 Root: StrongRef{
388 URI: comment.RootURI,
389 CID: comment.RootCID,
390 },
391 Parent: StrongRef{
392 URI: comment.ParentURI,
393 CID: comment.ParentCID,
394 },
395 },
396 Content: comment.Content,
397 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
398 Langs: comment.Langs,
399 }
400
401 // Deserialize facets from JSONB (Phase 2C)
402 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
403 var facets []interface{}
404 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil {
405 // Log error but don't fail request - facets are optional
406 log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err)
407 } else {
408 record.Facets = facets
409 }
410 }
411
412 // Deserialize embed from JSONB (Phase 2C)
413 if comment.Embed != nil && *comment.Embed != "" {
414 var embed map[string]interface{}
415 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil {
416 // Log error but don't fail request - embed is optional
417 log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err)
418 } else {
419 record.Embed = embed
420 }
421 }
422
423 // Deserialize labels from JSONB (Phase 2C)
424 if comment.ContentLabels != nil && *comment.ContentLabels != "" {
425 var labels SelfLabels
426 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil {
427 // Log error but don't fail request - labels are optional
428 log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err)
429 } else {
430 record.Labels = &labels
431 }
432 }
433
434 return record
435}
436
437// buildPostView converts a Post entity to a PostView for the comment response
438// Hydrates author handle and community name per lexicon requirements
439func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
440 // Build author view - fetch user to get handle (required by lexicon)
441 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
442 authorHandle := post.AuthorDID // Fallback if user not found
443 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
444 authorHandle = user.Handle
445 } else {
446 // Log warning but don't fail the entire request
447 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
448 }
449
450 authorView := &posts.AuthorView{
451 DID: post.AuthorDID,
452 Handle: authorHandle,
453 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
454 // Currently User model only has DID, Handle, PDSURL fields
455 DisplayName: nil,
456 Avatar: nil,
457 Reputation: nil,
458 }
459
460 // Build community reference - fetch community to get name and avatar (required by lexicon)
461 // The lexicon marks communityRef.name as required, so DIDs are insufficient
462 communityName := post.CommunityDID // Fallback if community not found
463 var avatarURL *string
464
465 if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
466 // Use display name if available, otherwise fall back to handle or short name
467 if community.DisplayName != "" {
468 communityName = community.DisplayName
469 } else if community.Name != "" {
470 communityName = community.Name
471 } else {
472 communityName = community.Handle
473 }
474
475 // Build avatar URL from CID if available
476 // Avatar is stored as blob in community's repository
477 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
478 if community.AvatarCID != "" && community.PDSURL != "" {
479 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks)
480 if !strings.HasPrefix(community.PDSURL, "https://") {
481 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID)
482 } else if !strings.HasPrefix(community.AvatarCID, "baf") {
483 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32)
484 log.Printf("Warning: Invalid CID format for community %s", community.DID)
485 } else {
486 // Use proper URL escaping to prevent injection attacks
487 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
488 strings.TrimSuffix(community.PDSURL, "/"),
489 url.QueryEscape(community.DID),
490 url.QueryEscape(community.AvatarCID))
491 avatarURL = &avatarURLString
492 }
493 }
494 } else {
495 // Log warning but don't fail the entire request
496 log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
497 }
498
499 communityRef := &posts.CommunityRef{
500 DID: post.CommunityDID,
501 Name: communityName,
502 Avatar: avatarURL,
503 }
504
505 // Build aggregated statistics
506 stats := &posts.PostStats{
507 Upvotes: post.UpvoteCount,
508 Downvotes: post.DownvoteCount,
509 Score: post.Score,
510 CommentCount: post.CommentCount,
511 }
512
513 // Build viewer state if authenticated
514 var viewer *posts.ViewerState
515 if viewerDID != nil {
516 // TODO (Phase 2B): Query viewer's vote state
517 viewer = &posts.ViewerState{
518 Vote: nil,
519 VoteURI: nil,
520 Saved: false,
521 }
522 }
523
524 // Build minimal post record to satisfy lexicon contract
525 // The record field is required by social.coves.community.post.get#postView
526 postRecord := s.buildPostRecord(post)
527
528 return &posts.PostView{
529 URI: post.URI,
530 CID: post.CID,
531 RKey: post.RKey,
532 Author: authorView,
533 Record: postRecord,
534 Community: communityRef,
535 Title: post.Title,
536 Text: post.Content,
537 CreatedAt: post.CreatedAt,
538 IndexedAt: post.IndexedAt,
539 EditedAt: post.EditedAt,
540 Stats: stats,
541 Viewer: viewer,
542 }
543}
544
545// buildPostRecord constructs a minimal PostRecord from a Post entity
546// Satisfies the lexicon requirement that postView.record is a required field
547// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
548func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
549 record := &posts.PostRecord{
550 Type: "social.coves.community.post",
551 Community: post.CommunityDID,
552 Author: post.AuthorDID,
553 CreatedAt: post.CreatedAt.Format(time.RFC3339),
554 Title: post.Title,
555 Content: post.Content,
556 }
557
558 // TODO (Phase 2C): Parse JSON fields from database for complete record:
559 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
560 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
561 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
562 // These fields are stored as JSONB in the database and need proper deserialization
563
564 return record
565}
566
567// validateGetCommentsRequest validates and normalizes request parameters
568// Applies default values and enforces bounds per API specification
569func validateGetCommentsRequest(req *GetCommentsRequest) error {
570 if req == nil {
571 return errors.New("request cannot be nil")
572 }
573
574 // Validate PostURI is present and well-formed
575 if req.PostURI == "" {
576 return errors.New("post URI is required")
577 }
578
579 if !strings.HasPrefix(req.PostURI, "at://") {
580 return errors.New("invalid AT-URI format: must start with 'at://'")
581 }
582
583 // Apply depth defaults and bounds (0-100, default 10)
584 if req.Depth < 0 {
585 req.Depth = 10
586 }
587 if req.Depth > 100 {
588 req.Depth = 100
589 }
590
591 // Apply limit defaults and bounds (1-100, default 50)
592 if req.Limit <= 0 {
593 req.Limit = 50
594 }
595 if req.Limit > 100 {
596 req.Limit = 100
597 }
598
599 // Apply sort default and validate
600 if req.Sort == "" {
601 req.Sort = "hot"
602 }
603
604 validSorts := map[string]bool{
605 "hot": true,
606 "top": true,
607 "new": true,
608 }
609 if !validSorts[req.Sort] {
610 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
611 }
612
613 // Validate timeframe (only applies to "top" sort)
614 if req.Timeframe != "" {
615 validTimeframes := map[string]bool{
616 "hour": true,
617 "day": true,
618 "week": true,
619 "month": true,
620 "year": true,
621 "all": true,
622 }
623 if !validTimeframes[req.Timeframe] {
624 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
625 }
626 }
627
628 return nil
629}