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