A community based topic aggregation platform built on atproto

Merge branch 'feat/comment-deletion-preserve-threads'

Implement comment deletion that preserves thread structure by keeping
tombstone records with blanked content instead of hiding comments entirely.

Features:
- Add deletion_reason enum (author, moderator) and deleted_by column
- Blank content on delete but preserve threading references
- Include deleted comments in thread queries as "[deleted]" placeholders
- Add RepositoryTx interface for atomic delete + count updates
- Add validation for deletion reason constants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+333 -62
+15 -12
internal/atproto/jetstream/comment_consumer.go
··· 260 260 // Comment was soft-deleted, now being recreated (resurrection) 261 261 // This is a NEW record with same rkey - update ALL fields including threading refs 262 262 // User may have deleted old comment and created a new one on a different parent/root 263 + // Clear deletion metadata to restore the comment 263 264 log.Printf("Resurrecting previously deleted comment: %s", comment.URI) 264 265 commentID = existingID 265 266 ··· 280 281 created_at = $12, 281 282 indexed_at = $13, 282 283 deleted_at = NULL, 284 + deletion_reason = NULL, 285 + deleted_by = NULL, 283 286 reply_count = 0 284 287 WHERE id = $14 285 288 ` ··· 420 423 } 421 424 422 425 // deleteCommentAndUpdateCounts atomically soft-deletes a comment and updates parent counts 426 + // Blanks content to preserve thread structure while respecting user privacy 427 + // The comment remains in the database but is shown as "[deleted]" in thread views 423 428 func (c *CommentEventConsumer) deleteCommentAndUpdateCounts(ctx context.Context, comment *comments.Comment) error { 424 429 tx, err := c.db.BeginTx(ctx, nil) 425 430 if err != nil { ··· 431 436 } 432 437 }() 433 438 434 - // 1. Soft-delete the comment (idempotent) 435 - deleteQuery := ` 436 - UPDATE comments 437 - SET deleted_at = $2 438 - WHERE uri = $1 AND deleted_at IS NULL 439 - ` 440 - 441 - result, err := tx.ExecContext(ctx, deleteQuery, comment.URI, time.Now()) 442 - if err != nil { 443 - return fmt.Errorf("failed to delete comment: %w", err) 439 + // 1. Soft-delete the comment: blank content but preserve structure 440 + // DELETE event from Jetstream = author deleted their own comment 441 + // Content is blanked to respect user privacy while preserving thread structure 442 + // Use the repository's transaction-aware method for DRY 443 + repoTx, ok := c.commentRepo.(comments.RepositoryTx) 444 + if !ok { 445 + return fmt.Errorf("comment repository does not support transactional operations") 444 446 } 445 447 446 - rowsAffected, err := result.RowsAffected() 448 + rowsAffected, err := repoTx.SoftDeleteWithReasonTx(ctx, tx, comment.URI, comments.DeletionReasonAuthor, comment.CommenterDID) 447 449 if err != nil { 448 - return fmt.Errorf("failed to check delete result: %w", err) 450 + return fmt.Errorf("failed to delete comment: %w", err) 449 451 } 450 452 451 453 // Idempotent: If no rows affected, comment already deleted ··· 462 464 collection := utils.ExtractCollectionFromURI(comment.ParentURI) 463 465 464 466 var updateQuery string 467 + var result sql.Result 465 468 switch collection { 466 469 case "social.coves.community.post": 467 470 // Comment on post - decrement posts.comment_count
+8
internal/core/comments/comment.go
··· 4 4 "time" 5 5 ) 6 6 7 + // Deletion reason constants 8 + const ( 9 + DeletionReasonAuthor = "author" // User deleted their own comment 10 + DeletionReasonModerator = "moderator" // Community moderator removed the comment 11 + ) 12 + 7 13 // Comment represents a comment in the AppView database 8 14 // Comments are indexed from the firehose after being written to user repositories 9 15 type Comment struct { ··· 11 17 CreatedAt time.Time `json:"createdAt" db:"created_at"` 12 18 ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` 13 19 DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 20 + DeletionReason *string `json:"deletionReason,omitempty" db:"deletion_reason"` 21 + DeletedBy *string `json:"deletedBy,omitempty" db:"deleted_by"` 14 22 ContentLabels *string `json:"labels,omitempty" db:"content_labels"` 15 23 Embed *string `json:"embed,omitempty" db:"embed"` 16 24 CommenterHandle string `json:"commenterHandle,omitempty" db:"-"`
+73 -5
internal/core/comments/comment_service.go
··· 252 252 parentsWithReplies := make([]string, 0) 253 253 254 254 for _, comment := range comments { 255 - // Skip deleted comments (soft-deleted records) 255 + var commentView *CommentView 256 + 257 + // Build appropriate view based on deletion status 256 258 if comment.DeletedAt != nil { 257 - continue 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) 258 264 } 259 - 260 - // Build the comment view with author info and stats 261 - commentView := s.buildCommentView(comment, viewerDID, voteStates, usersByDID) 262 265 263 266 threadView := &ThreadViewComment{ 264 267 Comment: commentView, ··· 270 273 commentsByURI[comment.URI] = threadView 271 274 272 275 // Collect parent URIs that have replies and depth remaining 276 + // Include deleted comments so their children are still loaded 273 277 if remainingDepth > 0 && comment.ReplyCount > 0 { 274 278 parentsWithReplies = append(parentsWithReplies, comment.URI) 275 279 } ··· 436 440 IndexedAt: comment.IndexedAt.Format(time.RFC3339), 437 441 Stats: stats, 438 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 449 + func (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, 439 507 } 440 508 } 441 509
+44 -4
internal/core/comments/comment_service_test.go
··· 5 5 "Coves/internal/core/posts" 6 6 "Coves/internal/core/users" 7 7 "context" 8 + "database/sql" 8 9 "errors" 9 10 "testing" 10 11 "time" ··· 51 52 func (m *mockCommentRepo) Delete(ctx context.Context, uri string) error { 52 53 delete(m.comments, uri) 53 54 return nil 55 + } 56 + 57 + func (m *mockCommentRepo) SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error { 58 + // Validate deletion reason 59 + if reason != DeletionReasonAuthor && reason != DeletionReasonModerator { 60 + return errors.New("invalid deletion reason: " + reason) 61 + } 62 + _, err := m.SoftDeleteWithReasonTx(ctx, nil, uri, reason, deletedByDID) 63 + return err 64 + } 65 + 66 + // SoftDeleteWithReasonTx implements RepositoryTx interface for transactional deletes 67 + func (m *mockCommentRepo) SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) { 68 + if c, ok := m.comments[uri]; ok { 69 + if c.DeletedAt != nil { 70 + // Already deleted - idempotent 71 + return 0, nil 72 + } 73 + now := time.Now() 74 + c.DeletedAt = &now 75 + c.DeletionReason = &reason 76 + c.DeletedBy = &deletedByDID 77 + c.Content = "" 78 + return 1, nil 79 + } 80 + return 0, nil 54 81 } 55 82 56 83 func (m *mockCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error) { ··· 831 858 assert.Len(t, result, 0) 832 859 } 833 860 834 - func TestCommentService_buildThreadViews_SkipsDeletedComments(t *testing.T) { 861 + func TestCommentService_buildThreadViews_IncludesDeletedCommentsAsPlaceholders(t *testing.T) { 835 862 // Setup 836 863 commentRepo := newMockCommentRepo() 837 864 userRepo := newMockUserRepo() ··· 840 867 841 868 postURI := "at://did:plc:post123/app.bsky.feed.post/test" 842 869 deletedAt := time.Now() 870 + deletionReason := DeletionReasonAuthor 843 871 844 872 // Create a deleted comment 845 873 deletedComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 846 874 deletedComment.DeletedAt = &deletedAt 875 + deletedComment.DeletionReason = &deletionReason 876 + deletedComment.Content = "" // Content is blanked on deletion 847 877 848 878 // Create a normal comment 849 879 normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) ··· 853 883 // Execute 854 884 result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil) 855 885 856 - // Verify - should only include non-deleted comment 857 - assert.Len(t, result, 1) 858 - assert.Equal(t, normalComment.URI, result[0].Comment.URI) 886 + // Verify - both comments should be included to preserve thread structure 887 + assert.Len(t, result, 2) 888 + 889 + // First comment should be the deleted one with placeholder info 890 + assert.Equal(t, deletedComment.URI, result[0].Comment.URI) 891 + assert.True(t, result[0].Comment.IsDeleted) 892 + assert.Equal(t, DeletionReasonAuthor, *result[0].Comment.DeletionReason) 893 + assert.Empty(t, result[0].Comment.Content) 894 + 895 + // Second comment should be the normal one 896 + assert.Equal(t, normalComment.URI, result[1].Comment.URI) 897 + assert.False(t, result[1].Comment.IsDeleted) 898 + assert.Nil(t, result[1].Comment.DeletionReason) 859 899 } 860 900 861 901 func TestCommentService_buildThreadViews_WithNestedReplies(t *testing.T) {
+23 -1
internal/core/comments/interfaces.go
··· 1 1 package comments 2 2 3 - import "context" 3 + import ( 4 + "context" 5 + "database/sql" 6 + ) 4 7 5 8 // Repository defines the data access interface for comments 6 9 // Used by Jetstream consumer to index comments from firehose ··· 25 28 26 29 // Delete soft-deletes a comment (sets deleted_at) 27 30 // Called by Jetstream consumer after comment is deleted from PDS 31 + // Deprecated: Use SoftDeleteWithReason for new code to preserve thread structure 28 32 Delete(ctx context.Context, uri string) error 33 + 34 + // SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure 35 + // This allows deleted comments to appear as "[deleted]" placeholders in thread views 36 + // reason: "author" (user deleted) or "moderator" (mod removed) 37 + // deletedByDID: DID of the actor who performed the deletion 38 + SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error 29 39 30 40 // ListByRoot retrieves all comments in a thread (flat) 31 41 // Used for fetching entire comment threads on posts ··· 76 86 limitPerParent int, 77 87 ) (map[string][]*Comment, error) 78 88 } 89 + 90 + // RepositoryTx provides transaction-aware operations for consumers that need atomicity 91 + // Used by Jetstream consumer to perform atomic delete + count updates 92 + // Implementations that support transactions should also implement this interface 93 + type RepositoryTx interface { 94 + // SoftDeleteWithReasonTx performs a soft delete within a transaction 95 + // If tx is nil, executes directly against the database 96 + // Returns rows affected count for callers that need to check idempotency 97 + // reason: must be DeletionReasonAuthor or DeletionReasonModerator 98 + // deletedByDID: DID of the actor who performed the deletion 99 + SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) 100 + }
+17 -13
internal/core/comments/view_models.go
··· 7 7 // CommentView represents the full view of a comment with all metadata 8 8 // Matches social.coves.community.comment.getComments#commentView lexicon 9 9 // Used in thread views and get endpoints 10 + // For deleted comments, IsDeleted=true and content-related fields are empty/nil 10 11 type CommentView struct { 11 - Embed interface{} `json:"embed,omitempty"` 12 - Record interface{} `json:"record"` 13 - Viewer *CommentViewerState `json:"viewer,omitempty"` 14 - Author *posts.AuthorView `json:"author"` 15 - Post *CommentRef `json:"post"` 16 - Parent *CommentRef `json:"parent,omitempty"` 17 - Stats *CommentStats `json:"stats"` 18 - Content string `json:"content"` 19 - CreatedAt string `json:"createdAt"` 20 - IndexedAt string `json:"indexedAt"` 21 - URI string `json:"uri"` 22 - CID string `json:"cid"` 23 - ContentFacets []interface{} `json:"contentFacets,omitempty"` 12 + Embed interface{} `json:"embed,omitempty"` 13 + Record interface{} `json:"record"` 14 + Viewer *CommentViewerState `json:"viewer,omitempty"` 15 + Author *posts.AuthorView `json:"author"` 16 + Post *CommentRef `json:"post"` 17 + Parent *CommentRef `json:"parent,omitempty"` 18 + Stats *CommentStats `json:"stats"` 19 + Content string `json:"content"` 20 + CreatedAt string `json:"createdAt"` 21 + IndexedAt string `json:"indexedAt"` 22 + URI string `json:"uri"` 23 + CID string `json:"cid"` 24 + ContentFacets []interface{} `json:"contentFacets,omitempty"` 25 + IsDeleted bool `json:"isDeleted,omitempty"` 26 + DeletionReason *string `json:"deletionReason,omitempty"` 27 + DeletedAt *string `json:"deletedAt,omitempty"` 24 28 } 25 29 26 30 // ThreadViewComment represents a comment with its nested replies
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
··· 1 + -- +goose Up 2 + -- Add deletion reason tracking to preserve thread structure while respecting privacy 3 + -- When comments are deleted, we blank content but keep the record for threading 4 + 5 + -- Create enum type for deletion reasons 6 + CREATE TYPE deletion_reason AS ENUM ('author', 'moderator'); 7 + 8 + -- Add new columns to comments table 9 + ALTER TABLE comments ADD COLUMN deletion_reason deletion_reason; 10 + ALTER TABLE comments ADD COLUMN deleted_by TEXT; 11 + 12 + -- Add comments for new columns 13 + COMMENT ON COLUMN comments.deletion_reason IS 'Reason for deletion: author (user deleted), moderator (community mod removed)'; 14 + COMMENT ON COLUMN comments.deleted_by IS 'DID of the actor who performed the deletion'; 15 + 16 + -- Backfill existing deleted comments as author-deleted 17 + -- This handles existing soft-deleted comments gracefully 18 + UPDATE comments 19 + SET deletion_reason = 'author', 20 + deleted_by = commenter_did 21 + WHERE deleted_at IS NOT NULL AND deletion_reason IS NULL; 22 + 23 + -- Modify existing indexes to NOT filter deleted_at IS NULL 24 + -- This allows deleted comments to appear in thread queries for structure preservation 25 + -- Note: We drop and recreate to change the partial index condition 26 + 27 + -- Drop old partial indexes that exclude deleted comments 28 + DROP INDEX IF EXISTS idx_comments_root; 29 + DROP INDEX IF EXISTS idx_comments_parent; 30 + DROP INDEX IF EXISTS idx_comments_parent_score; 31 + DROP INDEX IF EXISTS idx_comments_uri_active; 32 + 33 + -- Recreate indexes without the deleted_at filter (include all comments for threading) 34 + CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC); 35 + CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC); 36 + CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC); 37 + CREATE INDEX idx_comments_uri_lookup ON comments(uri); 38 + 39 + -- Add index for querying by deletion_reason (for moderation dashboard) 40 + CREATE INDEX idx_comments_deleted_reason ON comments(deletion_reason, deleted_at DESC) 41 + WHERE deleted_at IS NOT NULL; 42 + 43 + -- Add index for querying by deleted_by (for moderation audit/filtering) 44 + CREATE INDEX idx_comments_deleted_by ON comments(deleted_by, deleted_at DESC) 45 + WHERE deleted_at IS NOT NULL; 46 + 47 + -- +goose Down 48 + -- Remove deletion metadata columns and restore original indexes 49 + 50 + DROP INDEX IF EXISTS idx_comments_deleted_by; 51 + DROP INDEX IF EXISTS idx_comments_deleted_reason; 52 + DROP INDEX IF EXISTS idx_comments_uri_lookup; 53 + DROP INDEX IF EXISTS idx_comments_parent_score; 54 + DROP INDEX IF EXISTS idx_comments_parent; 55 + DROP INDEX IF EXISTS idx_comments_root; 56 + 57 + -- Restore original partial indexes (excluding deleted comments) 58 + CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) WHERE deleted_at IS NULL; 59 + CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) WHERE deleted_at IS NULL; 60 + CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) WHERE deleted_at IS NULL; 61 + CREATE INDEX idx_comments_uri_active ON comments(uri) WHERE deleted_at IS NULL; 62 + 63 + ALTER TABLE comments DROP COLUMN IF EXISTS deleted_by; 64 + ALTER TABLE comments DROP COLUMN IF EXISTS deletion_reason; 65 + 66 + DROP TYPE IF EXISTS deletion_reason;
+87 -27
internal/db/postgres/comment_repo.go
··· 120 120 id, uri, cid, rkey, commenter_did, 121 121 root_uri, root_cid, parent_uri, parent_cid, 122 122 content, content_facets, embed, content_labels, langs, 123 - created_at, indexed_at, deleted_at, 123 + created_at, indexed_at, deleted_at, deletion_reason, deleted_by, 124 124 upvote_count, downvote_count, score, reply_count 125 125 FROM comments 126 126 WHERE uri = $1 ··· 133 133 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 134 134 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 135 135 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 136 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 136 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 137 137 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 138 138 ) 139 139 ··· 152 152 // Delete soft-deletes a comment (sets deleted_at) 153 153 // Called by Jetstream consumer after comment is deleted from PDS 154 154 // Idempotent: Returns success if comment already deleted 155 + // Deprecated: Use SoftDeleteWithReason for new code to preserve thread structure 155 156 func (r *postgresCommentRepo) Delete(ctx context.Context, uri string) error { 156 157 query := ` 157 158 UPDATE comments ··· 177 178 return nil 178 179 } 179 180 180 - // ListByRoot retrieves all active comments in a thread (flat) 181 + // SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure 182 + // This allows deleted comments to appear as "[deleted]" placeholders in thread views 183 + // Idempotent: Returns success if comment already deleted 184 + // Validates that reason is a known deletion reason constant 185 + func (r *postgresCommentRepo) SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error { 186 + // Validate deletion reason 187 + if reason != comments.DeletionReasonAuthor && reason != comments.DeletionReasonModerator { 188 + return fmt.Errorf("invalid deletion reason: %s", reason) 189 + } 190 + 191 + _, err := r.SoftDeleteWithReasonTx(ctx, nil, uri, reason, deletedByDID) 192 + return err 193 + } 194 + 195 + // SoftDeleteWithReasonTx performs a soft delete within an optional transaction 196 + // If tx is nil, executes directly against the database 197 + // Returns rows affected count for callers that need to check idempotency 198 + // This method is used by both the repository and the Jetstream consumer 199 + func (r *postgresCommentRepo) SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) { 200 + query := ` 201 + UPDATE comments 202 + SET 203 + content = '', 204 + content_facets = NULL, 205 + embed = NULL, 206 + content_labels = NULL, 207 + deleted_at = NOW(), 208 + deletion_reason = $2, 209 + deleted_by = $3 210 + WHERE uri = $1 AND deleted_at IS NULL 211 + ` 212 + 213 + var result sql.Result 214 + var err error 215 + 216 + if tx != nil { 217 + result, err = tx.ExecContext(ctx, query, uri, reason, deletedByDID) 218 + } else { 219 + result, err = r.db.ExecContext(ctx, query, uri, reason, deletedByDID) 220 + } 221 + 222 + if err != nil { 223 + return 0, fmt.Errorf("failed to soft delete comment: %w", err) 224 + } 225 + 226 + rowsAffected, err := result.RowsAffected() 227 + if err != nil { 228 + return 0, fmt.Errorf("failed to check delete result: %w", err) 229 + } 230 + 231 + return rowsAffected, nil 232 + } 233 + 234 + // ListByRoot retrieves all comments in a thread (flat), including deleted ones 181 235 // Used for fetching entire comment threads on posts 236 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 182 237 func (r *postgresCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*comments.Comment, error) { 183 238 query := ` 184 239 SELECT 185 240 id, uri, cid, rkey, commenter_did, 186 241 root_uri, root_cid, parent_uri, parent_cid, 187 242 content, content_facets, embed, content_labels, langs, 188 - created_at, indexed_at, deleted_at, 243 + created_at, indexed_at, deleted_at, deletion_reason, deleted_by, 189 244 upvote_count, downvote_count, score, reply_count 190 245 FROM comments 191 - WHERE root_uri = $1 AND deleted_at IS NULL 246 + WHERE root_uri = $1 192 247 ORDER BY created_at ASC 193 248 LIMIT $2 OFFSET $3 194 249 ` ··· 212 267 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 213 268 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 214 269 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 215 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 270 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 216 271 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 217 272 ) 218 273 if err != nil { ··· 230 285 return result, nil 231 286 } 232 287 233 - // ListByParent retrieves direct replies to a post or comment 288 + // ListByParent retrieves direct replies to a post or comment, including deleted ones 234 289 // Used for building nested/threaded comment views 290 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 235 291 func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*comments.Comment, error) { 236 292 query := ` 237 293 SELECT 238 294 id, uri, cid, rkey, commenter_did, 239 295 root_uri, root_cid, parent_uri, parent_cid, 240 296 content, content_facets, embed, content_labels, langs, 241 - created_at, indexed_at, deleted_at, 297 + created_at, indexed_at, deleted_at, deletion_reason, deleted_by, 242 298 upvote_count, downvote_count, score, reply_count 243 299 FROM comments 244 - WHERE parent_uri = $1 AND deleted_at IS NULL 300 + WHERE parent_uri = $1 245 301 ORDER BY created_at ASC 246 302 LIMIT $2 OFFSET $3 247 303 ` ··· 265 321 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 266 322 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 267 323 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 268 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 324 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 269 325 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 270 326 ) 271 327 if err != nil { ··· 302 358 } 303 359 304 360 // ListByCommenter retrieves all active comments by a specific user 305 - // Future: Used for user comment history 361 + // Used for user comment history - filters out deleted comments 306 362 func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*comments.Comment, error) { 307 363 query := ` 308 364 SELECT 309 365 id, uri, cid, rkey, commenter_did, 310 366 root_uri, root_cid, parent_uri, parent_cid, 311 367 content, content_facets, embed, content_labels, langs, 312 - created_at, indexed_at, deleted_at, 368 + created_at, indexed_at, deleted_at, deletion_reason, deleted_by, 313 369 upvote_count, downvote_count, score, reply_count 314 370 FROM comments 315 371 WHERE commenter_did = $1 AND deleted_at IS NULL ··· 336 392 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 337 393 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 338 394 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 339 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 395 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 340 396 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 341 397 ) 342 398 if err != nil { ··· 391 447 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 392 448 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 393 449 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 394 - c.created_at, c.indexed_at, c.deleted_at, 450 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 395 451 c.upvote_count, c.downvote_count, c.score, c.reply_count, 396 452 log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 397 453 COALESCE(u.handle, c.commenter_did) as author_handle ··· 402 458 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 403 459 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 404 460 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 405 - c.created_at, c.indexed_at, c.deleted_at, 461 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 406 462 c.upvote_count, c.downvote_count, c.score, c.reply_count, 407 463 NULL::numeric as hot_rank, 408 464 COALESCE(u.handle, c.commenter_did) as author_handle ··· 411 467 412 468 // Build complete query with JOINs and filters 413 469 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 470 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 414 471 query := fmt.Sprintf(` 415 472 %s 416 473 LEFT JOIN users u ON c.commenter_did = u.did 417 - WHERE c.parent_uri = $1 AND c.deleted_at IS NULL 474 + WHERE c.parent_uri = $1 418 475 %s 419 476 %s 420 477 ORDER BY %s ··· 449 506 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 450 507 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 451 508 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 452 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 509 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 453 510 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 454 511 &hotRank, &authorHandle, 455 512 ) ··· 687 744 688 745 // GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query 689 746 // Returns map[uri]*Comment for efficient lookups without N+1 queries 747 + // Includes deleted comments to preserve thread structure 690 748 func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) { 691 749 if len(uris) == 0 { 692 750 return make(map[string]*comments.Comment), nil ··· 694 752 695 753 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 696 754 // COALESCE falls back to DID when handle is NULL (user not yet in users table) 755 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 697 756 query := ` 698 757 SELECT 699 758 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 700 759 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 701 760 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 702 - c.created_at, c.indexed_at, c.deleted_at, 761 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 703 762 c.upvote_count, c.downvote_count, c.score, c.reply_count, 704 763 COALESCE(u.handle, c.commenter_did) as author_handle 705 764 FROM comments c 706 765 LEFT JOIN users u ON c.commenter_did = u.did 707 - WHERE c.uri = ANY($1) AND c.deleted_at IS NULL 766 + WHERE c.uri = ANY($1) 708 767 ` 709 768 710 769 rows, err := r.db.QueryContext(ctx, query, pq.Array(uris)) ··· 727 786 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 728 787 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 729 788 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 730 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 789 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 731 790 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 732 791 &authorHandle, 733 792 ) ··· 769 828 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 770 829 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 771 830 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 772 - c.created_at, c.indexed_at, c.deleted_at, 831 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 773 832 c.upvote_count, c.downvote_count, c.score, c.reply_count, 774 833 log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 775 834 COALESCE(u.handle, c.commenter_did) as author_handle` ··· 780 839 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 781 840 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 782 841 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 783 - c.created_at, c.indexed_at, c.deleted_at, 842 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 784 843 c.upvote_count, c.downvote_count, c.score, c.reply_count, 785 844 NULL::numeric as hot_rank, 786 845 COALESCE(u.handle, c.commenter_did) as author_handle` ··· 790 849 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 791 850 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 792 851 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 793 - c.created_at, c.indexed_at, c.deleted_at, 852 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 794 853 c.upvote_count, c.downvote_count, c.score, c.reply_count, 795 854 NULL::numeric as hot_rank, 796 855 COALESCE(u.handle, c.commenter_did) as author_handle` ··· 801 860 c.id, c.uri, c.cid, c.rkey, c.commenter_did, 802 861 c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 803 862 c.content, c.content_facets, c.embed, c.content_labels, c.langs, 804 - c.created_at, c.indexed_at, c.deleted_at, 863 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 805 864 c.upvote_count, c.downvote_count, c.score, c.reply_count, 806 865 log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 807 866 COALESCE(u.handle, c.commenter_did) as author_handle` ··· 812 871 // Use window function to limit results per parent 813 872 // This is more efficient than LIMIT in a subquery per parent 814 873 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 874 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 815 875 query := fmt.Sprintf(` 816 876 WITH ranked_comments AS ( 817 877 SELECT ··· 822 882 ) as rn 823 883 FROM comments c 824 884 LEFT JOIN users u ON c.commenter_did = u.did 825 - WHERE c.parent_uri = ANY($1) AND c.deleted_at IS NULL 885 + WHERE c.parent_uri = ANY($1) 826 886 ) 827 887 SELECT 828 888 id, uri, cid, rkey, commenter_did, 829 889 root_uri, root_cid, parent_uri, parent_cid, 830 890 content, content_facets, embed, content_labels, langs, 831 - created_at, indexed_at, deleted_at, 891 + created_at, indexed_at, deleted_at, deletion_reason, deleted_by, 832 892 upvote_count, downvote_count, score, reply_count, 833 893 hot_rank, author_handle 834 894 FROM ranked_comments ··· 858 918 &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 859 919 &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 860 920 &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 861 - &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 921 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 862 922 &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 863 923 &hotRank, &authorHandle, 864 924 )