A community based topic aggregation platform built on atproto
1package comments
2
3import (
4 "Coves/internal/core/posts"
5 "context"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10)
11
12// Service defines the business logic interface for comment operations
13// Orchestrates repository calls and builds view models for API responses
14type Service interface {
15 // GetComments retrieves and builds a threaded comment tree for a post
16 // Supports hot, top, and new sorting with configurable depth and pagination
17 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
18}
19
20// GetCommentsRequest defines the parameters for fetching comments
21type GetCommentsRequest struct {
22 PostURI string // AT-URI of the post to fetch comments for
23 Sort string // "hot", "top", "new" - sorting algorithm
24 Timeframe string // "hour", "day", "week", "month", "year", "all" - for "top" sort only
25 Depth int // 0-100 - how many levels of nested replies to load (default 10)
26 Limit int // 1-100 - max top-level comments per page (default 50)
27 Cursor *string // Pagination cursor from previous response
28 ViewerDID *string // Optional DID of authenticated viewer (for vote state)
29}
30
31// commentService implements the Service interface
32// Coordinates between repository layer and view model construction
33type commentService struct {
34 commentRepo Repository // Comment data access
35 userRepo interface{} // User lookup (stubbed for now - Phase 2B)
36 postRepo interface{} // Post lookup (stubbed for now - Phase 2B)
37}
38
39// NewCommentService creates a new comment service instance
40// userRepo and postRepo are interface{} for now to allow incremental implementation
41func NewCommentService(commentRepo Repository, userRepo, postRepo interface{}) Service {
42 return &commentService{
43 commentRepo: commentRepo,
44 userRepo: userRepo,
45 postRepo: postRepo,
46 }
47}
48
49// GetComments retrieves comments for a post with threading and pagination
50// Algorithm:
51// 1. Validate input parameters and apply defaults
52// 2. Fetch top-level comments with specified sorting
53// 3. Recursively load nested replies up to depth limit
54// 4. Build view models with author info and stats
55// 5. Return response with pagination cursor
56func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
57 // 1. Validate inputs and apply defaults/bounds
58 if err := validateGetCommentsRequest(req); err != nil {
59 return nil, fmt.Errorf("invalid request: %w", err)
60 }
61
62 // 2. Fetch post for context (stubbed for now - just create minimal response)
63 // Future: s.fetchPost(ctx, req.PostURI)
64 // For now, we'll return nil for Post field per the instructions
65
66 // 3. Fetch top-level comments with pagination
67 // Uses repository's hot rank sorting and cursor-based pagination
68 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
69 ctx,
70 req.PostURI,
71 req.Sort,
72 req.Timeframe,
73 req.Limit,
74 req.Cursor,
75 )
76 if err != nil {
77 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
78 }
79
80 // 4. Build threaded view with nested replies up to depth limit
81 // This iteratively loads child comments and builds the tree structure
82 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
83
84 // 5. Return response with comments, post reference, and cursor
85 return &GetCommentsResponse{
86 Comments: threadViews,
87 Post: nil, // TODO: Fetch and include PostView (Phase 2B)
88 Cursor: nextCursor,
89 }, nil
90}
91
92// buildThreadViews recursively constructs threaded comment views with nested replies
93// Loads replies iteratively up to the specified depth limit
94// Each level fetches a limited number of replies to prevent N+1 query explosions
95func (s *commentService) buildThreadViews(
96 ctx context.Context,
97 comments []*Comment,
98 remainingDepth int,
99 sort string,
100 viewerDID *string,
101) []*ThreadViewComment {
102 // Always return an empty slice, never nil (important for JSON serialization)
103 result := make([]*ThreadViewComment, 0, len(comments))
104
105 if len(comments) == 0 {
106 return result
107 }
108
109 // Convert each comment to a thread view
110 for _, comment := range comments {
111 // Skip deleted comments (soft-deleted records)
112 if comment.DeletedAt != nil {
113 continue
114 }
115
116 // Build the comment view with author info and stats
117 commentView := s.buildCommentView(comment, viewerDID)
118
119 threadView := &ThreadViewComment{
120 Comment: commentView,
121 Replies: nil,
122 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
123 }
124
125 // Recursively load replies if depth remains and comment has replies
126 if remainingDepth > 0 && comment.ReplyCount > 0 {
127 // Load first 5 replies per comment (configurable constant)
128 // This prevents excessive nesting while showing conversation flow
129 const repliesPerLevel = 5
130
131 replies, _, err := s.commentRepo.ListByParentWithHotRank(
132 ctx,
133 comment.URI,
134 sort,
135 "", // No timeframe filter for nested replies
136 repliesPerLevel,
137 nil, // No cursor for nested replies (top 5 only)
138 )
139
140 // Only recurse if we successfully fetched replies
141 if err == nil && len(replies) > 0 {
142 threadView.Replies = s.buildThreadViews(
143 ctx,
144 replies,
145 remainingDepth-1,
146 sort,
147 viewerDID,
148 )
149
150 // HasMore indicates if there are additional replies beyond what we loaded
151 threadView.HasMore = comment.ReplyCount > len(replies)
152 }
153 }
154
155 result = append(result, threadView)
156 }
157
158 return result
159}
160
161// buildCommentView converts a Comment entity to a CommentView with full metadata
162// Constructs author view, stats, and references to parent post/comment
163func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView {
164 // Build author view from comment data
165 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN
166 authorView := &posts.AuthorView{
167 DID: comment.CommenterDID,
168 Handle: comment.CommenterHandle,
169 // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B)
170 }
171
172 // Build aggregated statistics
173 stats := &CommentStats{
174 Upvotes: comment.UpvoteCount,
175 Downvotes: comment.DownvoteCount,
176 Score: comment.Score,
177 ReplyCount: comment.ReplyCount,
178 }
179
180 // Build reference to parent post (always present)
181 postRef := &CommentRef{
182 URI: comment.RootURI,
183 CID: comment.RootCID,
184 }
185
186 // Build reference to parent comment (only if nested)
187 // Top-level comments have ParentURI == RootURI (both point to the post)
188 var parentRef *CommentRef
189 if comment.ParentURI != comment.RootURI {
190 parentRef = &CommentRef{
191 URI: comment.ParentURI,
192 CID: comment.ParentCID,
193 }
194 }
195
196 // Build viewer state (stubbed for now - Phase 2B)
197 // Future: Fetch viewer's vote state from GetVoteStateForComments
198 var viewer *CommentViewerState
199 if viewerDID != nil {
200 // TODO: Query voter state
201 // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI})
202 // For now, return empty viewer state to indicate authenticated request
203 viewer = &CommentViewerState{
204 Vote: nil,
205 VoteURI: nil,
206 }
207 }
208
209 return &CommentView{
210 URI: comment.URI,
211 CID: comment.CID,
212 Author: authorView,
213 Record: nil, // TODO: Parse and include original record if needed (Phase 2B)
214 Post: postRef,
215 Parent: parentRef,
216 Content: comment.Content,
217 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
218 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
219 Stats: stats,
220 Viewer: viewer,
221 }
222}
223
224// validateGetCommentsRequest validates and normalizes request parameters
225// Applies default values and enforces bounds per API specification
226func validateGetCommentsRequest(req *GetCommentsRequest) error {
227 if req == nil {
228 return errors.New("request cannot be nil")
229 }
230
231 // Validate PostURI is present and well-formed
232 if req.PostURI == "" {
233 return errors.New("post URI is required")
234 }
235
236 if !strings.HasPrefix(req.PostURI, "at://") {
237 return errors.New("invalid AT-URI format: must start with 'at://'")
238 }
239
240 // Apply depth defaults and bounds (0-100, default 10)
241 if req.Depth < 0 {
242 req.Depth = 10
243 }
244 if req.Depth > 100 {
245 req.Depth = 100
246 }
247
248 // Apply limit defaults and bounds (1-100, default 50)
249 if req.Limit <= 0 {
250 req.Limit = 50
251 }
252 if req.Limit > 100 {
253 req.Limit = 100
254 }
255
256 // Apply sort default and validate
257 if req.Sort == "" {
258 req.Sort = "hot"
259 }
260
261 validSorts := map[string]bool{
262 "hot": true,
263 "top": true,
264 "new": true,
265 }
266 if !validSorts[req.Sort] {
267 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
268 }
269
270 // Validate timeframe (only applies to "top" sort)
271 if req.Timeframe != "" {
272 validTimeframes := map[string]bool{
273 "hour": true,
274 "day": true,
275 "week": true,
276 "month": true,
277 "year": true,
278 "all": true,
279 }
280 if !validTimeframes[req.Timeframe] {
281 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
282 }
283 }
284
285 return nil
286}