A community based topic aggregation platform built on atproto

refactor(api): align facets with Bluesky pattern and improve resilience

Remove redundant contentFacets/textFacets from view structs to match
Bluesky's API pattern where facets are accessed via the record field.
This simplifies the API surface and eliminates data duplication.

Changes:
- Remove ContentFacets from CommentView, access via record.Facets
- Remove TextFacets from PostView, access via record.facets
- Update lexicon schemas (comment/defs.json, post/get.json)
- Replace log.Printf with structured slog.Warn/slog.Error throughout
- Convert hard errors for optional JSON fields to warning logs
- Update tests to use record-based facet access pattern

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

+69 -84
-8
internal/atproto/lexicon/social/coves/community/comment/defs.json
··· 40 40 "type": "string", 41 41 "description": "Comment text content" 42 42 }, 43 - "contentFacets": { 44 - "type": "array", 45 - "description": "Rich text annotations for mentions, links, formatting", 46 - "items": { 47 - "type": "ref", 48 - "ref": "social.coves.richtext.facet" 49 - } 50 - }, 51 43 "embed": { 52 44 "type": "union", 53 45 "description": "Embedded content in the comment (images or quoted post)",
-7
internal/atproto/lexicon/social/coves/community/post/get.json
··· 72 72 "text": { 73 73 "type": "string" 74 74 }, 75 - "textFacets": { 76 - "type": "array", 77 - "items": { 78 - "type": "ref", 79 - "ref": "social.coves.richtext.facet" 80 - } 81 - }, 82 75 "embed": { 83 76 "type": "union", 84 77 "description": "Embedded content (images, video, link preview, or quoted post)",
+26 -39
internal/core/comments/comment_service.go
··· 8 8 "encoding/json" 9 9 "errors" 10 10 "fmt" 11 - "log" 12 11 "log/slog" 13 12 "net/url" 14 13 "strings" ··· 220 219 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, commentURIs) 221 220 if err != nil { 222 221 // Log error but don't fail the request - vote state is optional 223 - log.Printf("Warning: Failed to fetch vote states for comments: %v", err) 222 + slog.Warn("failed to fetch vote states for comments", "error", err) 224 223 } 225 224 } 226 225 } ··· 243 242 usersByDID, err = s.userRepo.GetByDIDs(ctx, authorDIDs) 244 243 if err != nil { 245 244 // Log error but don't fail the request - user data is optional 246 - log.Printf("Warning: Failed to batch fetch users for comment authors: %v", err) 245 + slog.Warn("failed to batch fetch users for comment authors", "error", err) 247 246 usersByDID = make(map[string]*users.User) 248 247 } 249 248 } else { ··· 407 406 // The record field is required by social.coves.community.comment.defs#commentView 408 407 commentRecord := s.buildCommentRecord(comment) 409 408 410 - // Deserialize contentFacets from JSONB (Phase 2C) 411 - // Parse facets from database JSON string to populate contentFacets field 412 - var contentFacets []interface{} 413 - if comment.ContentFacets != nil && *comment.ContentFacets != "" { 414 - if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil { 415 - // Log error but don't fail request - facets are optional 416 - log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err) 417 - } 418 - } 419 - 420 - // Deserialize embed from JSONB (Phase 2C) 409 + // Deserialize embed from JSONB 421 410 // Parse embed from database JSON string to populate embed field 422 411 var embed interface{} 423 412 if comment.Embed != nil && *comment.Embed != "" { 424 413 var embedMap map[string]interface{} 425 414 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil { 426 415 // Log error but don't fail request - embed is optional 427 - log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err) 416 + slog.Warn("failed to unmarshal embed for comment", "comment_uri", comment.URI, "error", err) 428 417 } else { 429 418 embed = embedMap 430 419 } 431 420 } 432 421 433 422 return &CommentView{ 434 - URI: comment.URI, 435 - CID: comment.CID, 436 - Author: authorView, 437 - Record: commentRecord, 438 - Post: postRef, 439 - Parent: parentRef, 440 - Content: comment.Content, 441 - ContentFacets: contentFacets, 442 - Embed: embed, 443 - CreatedAt: comment.CreatedAt.Format(time.RFC3339), 444 - IndexedAt: comment.IndexedAt.Format(time.RFC3339), 445 - Stats: stats, 446 - Viewer: viewer, 423 + URI: comment.URI, 424 + CID: comment.CID, 425 + Author: authorView, 426 + Record: commentRecord, 427 + Post: postRef, 428 + Parent: parentRef, 429 + Content: comment.Content, 430 + Embed: embed, 431 + CreatedAt: comment.CreatedAt.Format(time.RFC3339), 432 + IndexedAt: comment.IndexedAt.Format(time.RFC3339), 433 + Stats: stats, 434 + Viewer: viewer, 447 435 } 448 436 } 449 437 ··· 499 487 Post: postRef, 500 488 Parent: parentRef, 501 489 Content: "", // Blanked content 502 - ContentFacets: nil, 503 490 Embed: nil, 504 491 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 505 492 IndexedAt: comment.IndexedAt.Format(time.RFC3339), ··· 537 524 var facets []interface{} 538 525 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil { 539 526 // Log error but don't fail request - facets are optional 540 - log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err) 527 + slog.Warn("failed to unmarshal facets for comment record", "comment_uri", comment.URI, "error", err) 541 528 } else { 542 529 record.Facets = facets 543 530 } ··· 548 535 var embed map[string]interface{} 549 536 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil { 550 537 // Log error but don't fail request - embed is optional 551 - log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err) 538 + slog.Warn("failed to unmarshal embed for comment record", "comment_uri", comment.URI, "error", err) 552 539 } else { 553 540 record.Embed = embed 554 541 } ··· 559 546 var labels SelfLabels 560 547 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil { 561 548 // Log error but don't fail request - labels are optional 562 - log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err) 549 + slog.Warn("failed to unmarshal labels for comment record", "comment_uri", comment.URI, "error", err) 563 550 } else { 564 551 record.Labels = &labels 565 552 } ··· 886 873 authorHandle = user.Handle 887 874 } else { 888 875 // Log warning but don't fail the entire request 889 - log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 876 + slog.Warn("failed to fetch user for post author", "author_did", post.AuthorDID, "error", err) 890 877 } 891 878 892 879 authorView := &posts.AuthorView{ ··· 906 893 if err != nil { 907 894 // This indicates a data integrity issue: post references non-existent community 908 895 // Log as ERROR (not warning) since this should never happen in normal operation 909 - log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v", 910 - post.URI, post.CommunityDID, err) 896 + slog.Error("data integrity issue - post references non-existent community", 897 + "post_uri", post.URI, "community_did", post.CommunityDID, "error", err) 911 898 // Use DID as fallback for both handle and name to prevent breaking the API 912 899 // This allows the response to be returned while surfacing the integrity issue in logs 913 900 community = &communities.Community{ ··· 937 924 if community.AvatarCID != "" && community.PDSURL != "" { 938 925 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks) 939 926 if !strings.HasPrefix(community.PDSURL, "https://") { 940 - log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID) 927 + slog.Warn("skipping non-HTTPS PDS URL for community", "community_did", community.DID) 941 928 } else if !strings.HasPrefix(community.AvatarCID, "baf") { 942 929 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32) 943 - log.Printf("Warning: Invalid CID format for community %s", community.DID) 930 + slog.Warn("invalid CID format for community avatar", "community_did", community.DID, "avatar_cid", community.AvatarCID) 944 931 } else { 945 932 // Use proper URL escaping to prevent injection attacks 946 933 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", ··· 1088 1075 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *req.ViewerDID, commentURIs) 1089 1076 if err != nil { 1090 1077 // Log error but don't fail the request - vote state is optional 1091 - log.Printf("Warning: Failed to fetch vote states for actor comments: %v", err) 1078 + slog.Warn("failed to fetch vote states for actor comments", "error", err) 1092 1079 } 1093 1080 } 1094 1081 ··· 1100 1087 user, err := s.userRepo.GetByDID(ctx, req.ActorDID) 1101 1088 if err != nil { 1102 1089 // Log error but don't fail request - user data is optional 1103 - log.Printf("Warning: Failed to fetch user for actor %s: %v", req.ActorDID, err) 1090 + slog.Warn("failed to fetch user for actor", "actor_did", req.ActorDID, "error", err) 1104 1091 } else if user != nil { 1105 1092 usersByDID[user.DID] = user 1106 1093 }
+13 -7
internal/core/comments/comment_service_test.go
··· 1345 1345 1346 1346 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1347 1347 1348 - assert.NotNil(t, result.ContentFacets) 1349 - assert.Len(t, result.ContentFacets, 1) 1348 + // Facets are accessed via record.Facets (following Bluesky pattern) 1349 + assert.NotNil(t, result.Record) 1350 + record := result.Record.(*CommentRecord) 1351 + assert.NotNil(t, record.Facets) 1352 + assert.Len(t, record.Facets, 1) 1350 1353 } 1351 1354 1352 1355 func TestBuildCommentView_ValidEmbedDeserialization(t *testing.T) { ··· 1404 1407 1405 1408 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1406 1409 1407 - // Should not panic, should log warning and return view with nil facets 1410 + // Should not panic, should log warning and return view with nil facets in record 1408 1411 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1409 1412 1410 1413 assert.NotNil(t, result) 1411 - assert.Nil(t, result.ContentFacets) 1414 + // Facets are accessed via record.Facets - malformed JSON results in nil 1415 + record := result.Record.(*CommentRecord) 1416 + assert.Nil(t, record.Facets) 1412 1417 } 1413 1418 1414 1419 func TestBuildCommentView_EmptyStringVsNilHandling(t *testing.T) { ··· 1468 1473 1469 1474 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1470 1475 1476 + // Facets are accessed via record.Facets (following Bluesky pattern) 1477 + record := result.Record.(*CommentRecord) 1471 1478 if tt.expectFacetsNil { 1472 - assert.Nil(t, result.ContentFacets) 1479 + assert.Nil(t, record.Facets) 1473 1480 } else { 1474 - assert.NotNil(t, result.ContentFacets) 1481 + assert.NotNil(t, record.Facets) 1475 1482 } 1476 1483 1477 1484 if tt.expectEmbedNil { ··· 1480 1487 assert.NotNil(t, result.Embed) 1481 1488 } 1482 1489 1483 - record := service.buildCommentRecord(comment) 1484 1490 if tt.expectRecordLabels { 1485 1491 assert.NotNil(t, record.Labels) 1486 1492 } else {
-1
internal/core/comments/view_models.go
··· 21 21 IndexedAt string `json:"indexedAt"` 22 22 URI string `json:"uri"` 23 23 CID string `json:"cid"` 24 - ContentFacets []interface{} `json:"contentFacets,omitempty"` 25 24 IsDeleted bool `json:"isDeleted,omitempty"` 26 25 DeletionReason *string `json:"deletionReason,omitempty"` 27 26 DeletedAt *string `json:"deletedAt,omitempty"`
-1
internal/core/posts/post.go
··· 100 100 RKey string `json:"rkey"` 101 101 CID string `json:"cid"` 102 102 URI string `json:"uri"` 103 - TextFacets []interface{} `json:"textFacets,omitempty"` 104 103 UpvoteCount int `json:"-"` 105 104 DownvoteCount int `json:"-"` 106 105 Score int `json:"-"`
+5 -8
internal/db/postgres/feed_repo_base.go
··· 362 362 postView.Title = nullStringPtr(title) 363 363 postView.Text = nullStringPtr(content) 364 364 365 - // Parse facets JSON 365 + // Parse facets JSON into local variable (will be added to record below) 366 366 // Log errors but continue - a single malformed post shouldn't break the entire feed 367 + var facetArray []interface{} 367 368 if facets.Valid { 368 - var facetArray []interface{} 369 369 if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 370 370 slog.Warn("[FEED] failed to parse facets JSON", 371 371 "post_uri", postView.URI, 372 372 "error", err, 373 373 ) 374 - } else { 375 - postView.TextFacets = facetArray 376 374 } 377 375 } 378 376 ··· 413 411 if content.Valid { 414 412 record["content"] = content.String 415 413 } 416 - // Reuse already-parsed facets and embed from PostView (parsed above with logging) 417 - // This avoids double parsing and ensures consistent error handling 418 - if postView.TextFacets != nil { 419 - record["facets"] = postView.TextFacets 414 + // Add facets to record if present 415 + if facetArray != nil { 416 + record["facets"] = facetArray 420 417 } 421 418 if postView.Embed != nil { 422 419 record["embed"] = postView.Embed
+25 -13
internal/db/postgres/post_repo.go
··· 6 6 "encoding/base64" 7 7 "encoding/json" 8 8 "fmt" 9 - "log" 9 + "log/slog" 10 10 "strings" 11 11 "time" 12 12 ··· 217 217 } 218 218 defer func() { 219 219 if err := rows.Close(); err != nil { 220 - log.Printf("WARN: failed to close rows: %v", err) 220 + slog.Warn("failed to close rows", "error", err) 221 221 } 222 222 }() 223 223 ··· 356 356 postView.EditedAt = &editedAt.Time 357 357 } 358 358 359 - // Parse facets JSON 359 + // Parse facets JSON into local variable (will be added to record below) 360 + // Log errors but continue - malformed optional fields shouldn't break the response 361 + var facetArray []interface{} 360 362 if facets.Valid { 361 - var facetArray []interface{} 362 363 if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 363 - return nil, fmt.Errorf("failed to parse facets JSON for post %s: %w", postView.URI, err) 364 + slog.Warn("failed to parse facets JSON", 365 + "post_uri", postView.URI, 366 + "error", err, 367 + ) 364 368 } 365 - postView.TextFacets = facetArray 366 369 } 367 370 368 371 // Parse embed JSON 372 + // Log errors but continue - malformed optional fields shouldn't break the response 369 373 if embed.Valid { 370 374 var embedData interface{} 371 375 if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { 372 - return nil, fmt.Errorf("failed to parse embed JSON for post %s: %w", postView.URI, err) 376 + slog.Warn("failed to parse embed JSON", 377 + "post_uri", postView.URI, 378 + "error", err, 379 + ) 380 + } else { 381 + postView.Embed = embedData 373 382 } 374 - postView.Embed = embedData 375 383 } 376 384 377 385 // Build stats ··· 397 405 if content.Valid { 398 406 record["content"] = content.String 399 407 } 400 - // Reuse already-parsed facets and embed from PostView to avoid double parsing 401 - if facets.Valid { 402 - record["facets"] = postView.TextFacets 408 + // Add facets to record if present 409 + if facetArray != nil { 410 + record["facets"] = facetArray 403 411 } 404 412 if embed.Valid { 405 413 record["embed"] = postView.Embed ··· 408 416 // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 409 417 var selfLabels posts.SelfLabels 410 418 if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { 411 - return nil, fmt.Errorf("failed to parse labels JSON for post %s: %w", postView.URI, err) 419 + slog.Warn("failed to parse labels JSON", 420 + "post_uri", postView.URI, 421 + "error", err, 422 + ) 423 + } else { 424 + record["labels"] = selfLabels 412 425 } 413 - record["labels"] = selfLabels 414 426 } 415 427 416 428 postView.Record = record