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