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 "errors"
9 "fmt"
10 "log"
11 "strings"
12 "time"
13)
14
15// Service defines the business logic interface for comment operations
16// Orchestrates repository calls and builds view models for API responses
17type Service interface {
18 // GetComments retrieves and builds a threaded comment tree for a post
19 // Supports hot, top, and new sorting with configurable depth and pagination
20 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
21}
22
23// GetCommentsRequest defines the parameters for fetching comments
24type GetCommentsRequest struct {
25 Cursor *string
26 ViewerDID *string
27 PostURI string
28 Sort string
29 Timeframe string
30 Depth int
31 Limit int
32}
33
34// commentService implements the Service interface
35// Coordinates between repository layer and view model construction
36type commentService struct {
37 commentRepo Repository // Comment data access
38 userRepo users.UserRepository // User lookup for author hydration
39 postRepo posts.Repository // Post lookup for building post views
40 communityRepo communities.Repository // Community lookup for community hydration
41}
42
43// NewCommentService creates a new comment service instance
44// All repositories are required for proper view construction per lexicon requirements
45func NewCommentService(
46 commentRepo Repository,
47 userRepo users.UserRepository,
48 postRepo posts.Repository,
49 communityRepo communities.Repository,
50) Service {
51 return &commentService{
52 commentRepo: commentRepo,
53 userRepo: userRepo,
54 postRepo: postRepo,
55 communityRepo: communityRepo,
56 }
57}
58
59// GetComments retrieves comments for a post with threading and pagination
60// Algorithm:
61// 1. Validate input parameters and apply defaults
62// 2. Fetch top-level comments with specified sorting
63// 3. Recursively load nested replies up to depth limit
64// 4. Build view models with author info and stats
65// 5. Return response with pagination cursor
66func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
67 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
68 if err := validateGetCommentsRequest(req); err != nil {
69 return nil, fmt.Errorf("invalid request: %w", err)
70 }
71
72 // Add timeout to prevent runaway queries with deep nesting
73 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
74 defer cancel()
75
76 // 2. Fetch post for context
77 post, err := s.postRepo.GetByURI(ctx, req.PostURI)
78 if err != nil {
79 // Translate post not-found errors to comment-layer errors for proper HTTP status
80 if posts.IsNotFound(err) {
81 return nil, ErrRootNotFound
82 }
83 return nil, fmt.Errorf("failed to fetch post: %w", err)
84 }
85
86 // Build post view for response (hydrates author handle and community name)
87 postView := s.buildPostView(ctx, post, req.ViewerDID)
88
89 // 3. Fetch top-level comments with pagination
90 // Uses repository's hot rank sorting and cursor-based pagination
91 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
92 ctx,
93 req.PostURI,
94 req.Sort,
95 req.Timeframe,
96 req.Limit,
97 req.Cursor,
98 )
99 if err != nil {
100 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
101 }
102
103 // 4. Build threaded view with nested replies up to depth limit
104 // This iteratively loads child comments and builds the tree structure
105 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
106
107 // 5. Return response with comments, post reference, and cursor
108 return &GetCommentsResponse{
109 Comments: threadViews,
110 Post: postView,
111 Cursor: nextCursor,
112 }, nil
113}
114
115// buildThreadViews constructs threaded comment views with nested replies using batch loading
116// Uses batch queries to prevent N+1 query problem when loading nested replies
117// Loads replies level-by-level up to the specified depth limit
118func (s *commentService) buildThreadViews(
119 ctx context.Context,
120 comments []*Comment,
121 remainingDepth int,
122 sort string,
123 viewerDID *string,
124) []*ThreadViewComment {
125 // Always return an empty slice, never nil (important for JSON serialization)
126 result := make([]*ThreadViewComment, 0, len(comments))
127
128 if len(comments) == 0 {
129 return result
130 }
131
132 // Build thread views for current level
133 threadViews := make([]*ThreadViewComment, 0, len(comments))
134 commentsByURI := make(map[string]*ThreadViewComment)
135 parentsWithReplies := make([]string, 0)
136
137 for _, comment := range comments {
138 // Skip deleted comments (soft-deleted records)
139 if comment.DeletedAt != nil {
140 continue
141 }
142
143 // Build the comment view with author info and stats
144 commentView := s.buildCommentView(comment, viewerDID)
145
146 threadView := &ThreadViewComment{
147 Comment: commentView,
148 Replies: nil,
149 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
150 }
151
152 threadViews = append(threadViews, threadView)
153 commentsByURI[comment.URI] = threadView
154
155 // Collect parent URIs that have replies and depth remaining
156 if remainingDepth > 0 && comment.ReplyCount > 0 {
157 parentsWithReplies = append(parentsWithReplies, comment.URI)
158 }
159 }
160
161 // Batch load all replies for this level in a single query
162 if len(parentsWithReplies) > 0 {
163 const repliesPerParent = 5 // Load top 5 replies per comment
164
165 repliesByParent, err := s.commentRepo.ListByParentsBatch(
166 ctx,
167 parentsWithReplies,
168 sort,
169 repliesPerParent,
170 )
171
172 // Process replies if batch query succeeded
173 if err == nil {
174 // Group child comments by parent for recursive processing
175 for parentURI, replies := range repliesByParent {
176 threadView := commentsByURI[parentURI]
177 if threadView != nil && len(replies) > 0 {
178 // Recursively build views for child comments
179 threadView.Replies = s.buildThreadViews(
180 ctx,
181 replies,
182 remainingDepth-1,
183 sort,
184 viewerDID,
185 )
186
187 // Update HasMore based on actual reply count vs loaded count
188 // Get the original comment to check reply count
189 for _, comment := range comments {
190 if comment.URI == parentURI {
191 threadView.HasMore = comment.ReplyCount > len(replies)
192 break
193 }
194 }
195 }
196 }
197 }
198 }
199
200 return threadViews
201}
202
203// buildCommentView converts a Comment entity to a CommentView with full metadata
204// Constructs author view, stats, and references to parent post/comment
205func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView {
206 // Build author view from comment data
207 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN
208 authorView := &posts.AuthorView{
209 DID: comment.CommenterDID,
210 Handle: comment.CommenterHandle,
211 // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B)
212 }
213
214 // Build aggregated statistics
215 stats := &CommentStats{
216 Upvotes: comment.UpvoteCount,
217 Downvotes: comment.DownvoteCount,
218 Score: comment.Score,
219 ReplyCount: comment.ReplyCount,
220 }
221
222 // Build reference to parent post (always present)
223 postRef := &CommentRef{
224 URI: comment.RootURI,
225 CID: comment.RootCID,
226 }
227
228 // Build reference to parent comment (only if nested)
229 // Top-level comments have ParentURI == RootURI (both point to the post)
230 var parentRef *CommentRef
231 if comment.ParentURI != comment.RootURI {
232 parentRef = &CommentRef{
233 URI: comment.ParentURI,
234 CID: comment.ParentCID,
235 }
236 }
237
238 // Build viewer state (stubbed for now - Phase 2B)
239 // Future: Fetch viewer's vote state from GetVoteStateForComments
240 var viewer *CommentViewerState
241 if viewerDID != nil {
242 // TODO: Query voter state
243 // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI})
244 // For now, return empty viewer state to indicate authenticated request
245 viewer = &CommentViewerState{
246 Vote: nil,
247 VoteURI: nil,
248 }
249 }
250
251 // Build minimal comment record to satisfy lexicon contract
252 // The record field is required by social.coves.community.comment.defs#commentView
253 commentRecord := s.buildCommentRecord(comment)
254
255 return &CommentView{
256 URI: comment.URI,
257 CID: comment.CID,
258 Author: authorView,
259 Record: commentRecord,
260 Post: postRef,
261 Parent: parentRef,
262 Content: comment.Content,
263 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
264 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
265 Stats: stats,
266 Viewer: viewer,
267 }
268}
269
270// buildCommentRecord constructs a minimal CommentRecord from a Comment entity
271// Satisfies the lexicon requirement that commentView.record is a required field
272// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
273func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
274 record := &CommentRecord{
275 Type: "social.coves.feed.comment",
276 Reply: ReplyRef{
277 Root: StrongRef{
278 URI: comment.RootURI,
279 CID: comment.RootCID,
280 },
281 Parent: StrongRef{
282 URI: comment.ParentURI,
283 CID: comment.ParentCID,
284 },
285 },
286 Content: comment.Content,
287 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
288 Langs: comment.Langs,
289 }
290
291 // TODO (Phase 2C): Parse JSON fields from database for complete record:
292 // - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{})
293 // - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{})
294 // - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels)
295 // These fields are stored as JSONB in the database and need proper deserialization
296
297 return record
298}
299
300// buildPostView converts a Post entity to a PostView for the comment response
301// Hydrates author handle and community name per lexicon requirements
302func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
303 // Build author view - fetch user to get handle (required by lexicon)
304 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
305 authorHandle := post.AuthorDID // Fallback if user not found
306 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
307 authorHandle = user.Handle
308 } else {
309 // Log warning but don't fail the entire request
310 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
311 }
312
313 authorView := &posts.AuthorView{
314 DID: post.AuthorDID,
315 Handle: authorHandle,
316 // TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile
317 }
318
319 // Build community reference - fetch community to get name (required by lexicon)
320 // The lexicon marks communityRef.name as required, so DIDs are insufficient
321 communityName := post.CommunityDID // Fallback if community not found
322 if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
323 communityName = community.Handle // Use handle as display name
324 // TODO (Phase 2C): Use community.DisplayName or community.Name if available
325 } else {
326 // Log warning but don't fail the entire request
327 log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
328 }
329
330 communityRef := &posts.CommunityRef{
331 DID: post.CommunityDID,
332 Name: communityName,
333 // TODO (Phase 2C): Add Avatar from community profile
334 }
335
336 // Build aggregated statistics
337 stats := &posts.PostStats{
338 Upvotes: post.UpvoteCount,
339 Downvotes: post.DownvoteCount,
340 Score: post.Score,
341 CommentCount: post.CommentCount,
342 }
343
344 // Build viewer state if authenticated
345 var viewer *posts.ViewerState
346 if viewerDID != nil {
347 // TODO (Phase 2B): Query viewer's vote state
348 viewer = &posts.ViewerState{
349 Vote: nil,
350 VoteURI: nil,
351 Saved: false,
352 }
353 }
354
355 // Build minimal post record to satisfy lexicon contract
356 // The record field is required by social.coves.community.post.get#postView
357 postRecord := s.buildPostRecord(post)
358
359 return &posts.PostView{
360 URI: post.URI,
361 CID: post.CID,
362 RKey: post.RKey,
363 Author: authorView,
364 Record: postRecord,
365 Community: communityRef,
366 Title: post.Title,
367 Text: post.Content,
368 CreatedAt: post.CreatedAt,
369 IndexedAt: post.IndexedAt,
370 EditedAt: post.EditedAt,
371 Stats: stats,
372 Viewer: viewer,
373 }
374}
375
376// buildPostRecord constructs a minimal PostRecord from a Post entity
377// Satisfies the lexicon requirement that postView.record is a required field
378// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
379func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
380 record := &posts.PostRecord{
381 Type: "social.coves.community.post",
382 Community: post.CommunityDID,
383 Author: post.AuthorDID,
384 CreatedAt: post.CreatedAt.Format(time.RFC3339),
385 Title: post.Title,
386 Content: post.Content,
387 }
388
389 // TODO (Phase 2C): Parse JSON fields from database for complete record:
390 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
391 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
392 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
393 // These fields are stored as JSONB in the database and need proper deserialization
394
395 return record
396}
397
398// validateGetCommentsRequest validates and normalizes request parameters
399// Applies default values and enforces bounds per API specification
400func validateGetCommentsRequest(req *GetCommentsRequest) error {
401 if req == nil {
402 return errors.New("request cannot be nil")
403 }
404
405 // Validate PostURI is present and well-formed
406 if req.PostURI == "" {
407 return errors.New("post URI is required")
408 }
409
410 if !strings.HasPrefix(req.PostURI, "at://") {
411 return errors.New("invalid AT-URI format: must start with 'at://'")
412 }
413
414 // Apply depth defaults and bounds (0-100, default 10)
415 if req.Depth < 0 {
416 req.Depth = 10
417 }
418 if req.Depth > 100 {
419 req.Depth = 100
420 }
421
422 // Apply limit defaults and bounds (1-100, default 50)
423 if req.Limit <= 0 {
424 req.Limit = 50
425 }
426 if req.Limit > 100 {
427 req.Limit = 100
428 }
429
430 // Apply sort default and validate
431 if req.Sort == "" {
432 req.Sort = "hot"
433 }
434
435 validSorts := map[string]bool{
436 "hot": true,
437 "top": true,
438 "new": true,
439 }
440 if !validSorts[req.Sort] {
441 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
442 }
443
444 // Validate timeframe (only applies to "top" sort)
445 if req.Timeframe != "" {
446 validTimeframes := map[string]bool{
447 "hour": true,
448 "day": true,
449 "week": true,
450 "month": true,
451 "year": true,
452 "all": true,
453 }
454 if !validTimeframes[req.Timeframe] {
455 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
456 }
457 }
458
459 return nil
460}