diff --git a/cmd/server/main.go b/cmd/server/main.go index f0a69a8..3994b16 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,7 @@ import ( "Coves/internal/atproto/identity" "Coves/internal/atproto/jetstream" "Coves/internal/core/aggregators" + "Coves/internal/core/comments" "Coves/internal/core/communities" "Coves/internal/core/communityFeeds" "Coves/internal/core/discover" @@ -31,7 +32,7 @@ import ( "github.com/pressly/goose/v3" commentsAPI "Coves/internal/api/handlers/comments" - "Coves/internal/core/comments" + postgresRepo "Coves/internal/db/postgres" ) diff --git a/internal/api/handlers/comments/get_comments.go b/internal/api/handlers/comments/get_comments.go index 351ccc4..cd8cec3 100644 --- a/internal/api/handlers/comments/get_comments.go +++ b/internal/api/handlers/comments/get_comments.go @@ -25,13 +25,13 @@ type Service interface { // GetCommentsRequest represents the query parameters for fetching comments // Matches social.coves.feed.getComments lexicon input type GetCommentsRequest struct { - PostURI string `json:"post"` // Required: AT-URI of the post - Sort string `json:"sort,omitempty"` // Optional: "hot", "top", "new" (default: "hot") - Timeframe string `json:"timeframe,omitempty"` // Optional: For "top" sort - "hour", "day", "week", "month", "year", "all" - Depth int `json:"depth,omitempty"` // Optional: Max nesting depth (default: 10) - Limit int `json:"limit,omitempty"` // Optional: Max comments per page (default: 50, max: 100) - Cursor *string `json:"cursor,omitempty"` // Optional: Pagination cursor - ViewerDID *string `json:"-"` // Internal: Extracted from auth token + Cursor *string `json:"cursor,omitempty"` + ViewerDID *string `json:"-"` + PostURI string `json:"post"` + Sort string `json:"sort,omitempty"` + Timeframe string `json:"timeframe,omitempty"` + Depth int `json:"depth,omitempty"` + Limit int `json:"limit,omitempty"` } // NewGetCommentsHandler creates a new handler for fetching comments diff --git a/internal/api/handlers/comments/middleware.go b/internal/api/handlers/comments/middleware.go index 010e09a..afad433 100644 --- a/internal/api/handlers/comments/middleware.go +++ b/internal/api/handlers/comments/middleware.go @@ -9,9 +9,10 @@ import ( // This ensures comment handlers can access viewer identity when available, but don't require authentication. // // Usage in router setup: -// commentHandler := comments.NewGetCommentsHandler(commentService) -// router.Handle("/xrpc/social.coves.feed.getComments", -// comments.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments)) +// +// commentHandler := comments.NewGetCommentsHandler(commentService) +// router.Handle("/xrpc/social.coves.feed.getComments", +// comments.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments)) // // The middleware extracts the viewer DID from the Authorization header if present and valid, // making it available via middleware.GetUserDID(r) in the handler. diff --git a/internal/atproto/jetstream/comment_consumer.go b/internal/atproto/jetstream/comment_consumer.go index b3463e1..40f3f51 100644 --- a/internal/atproto/jetstream/comment_consumer.go +++ b/internal/atproto/jetstream/comment_consumer.go @@ -300,7 +300,6 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, time.Now(), commentID, ) - if err != nil { return fmt.Errorf("failed to resurrect comment: %w", err) } @@ -329,7 +328,6 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs), comment.CreatedAt, time.Now(), ).Scan(&commentID) - if err != nil { return fmt.Errorf("failed to insert comment: %w", err) } @@ -593,14 +591,14 @@ func validateATURI(uri string) error { // CommentRecordFromJetstream represents a comment record as received from Jetstream // Matches social.coves.feed.comment lexicon type CommentRecordFromJetstream struct { - Type string `json:"$type"` + Labels interface{} `json:"labels,omitempty"` + Embed map[string]interface{} `json:"embed,omitempty"` Reply ReplyRefFromJetstream `json:"reply"` + Type string `json:"$type"` Content string `json:"content"` + CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - Embed map[string]interface{} `json:"embed,omitempty"` Langs []string `json:"langs,omitempty"` - Labels interface{} `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` } // ReplyRefFromJetstream represents the threading structure @@ -638,7 +636,7 @@ func parseCommentRecord(record map[string]interface{}) (*CommentRecordFromJetstr // Returns nil pointers for empty/nil fields (DRY helper to avoid duplication) func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) { // Serialize facets if present - if commentRecord.Facets != nil && len(commentRecord.Facets) > 0 { + if len(commentRecord.Facets) > 0 { if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil { facetsStr := string(facetsBytes) facetsJSON = &facetsStr @@ -646,7 +644,7 @@ func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJ } // Serialize embed if present - if commentRecord.Embed != nil && len(commentRecord.Embed) > 0 { + if len(commentRecord.Embed) > 0 { if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil { embedStr := string(embedBytes) embedJSON = &embedStr diff --git a/internal/atproto/jetstream/community_consumer.go b/internal/atproto/jetstream/community_consumer.go index 1145754..3a94d3c 100644 --- a/internal/atproto/jetstream/community_consumer.go +++ b/internal/atproto/jetstream/community_consumer.go @@ -19,8 +19,10 @@ import ( // CommunityEventConsumer consumes community-related events from Jetstream type CommunityEventConsumer struct { - repo communities.Repository // Repository for community operations - identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDs + repo communities.Repository // Repository for community operations + identityResolver interface { + Resolve(context.Context, string) (*identity.Identity, error) + } // For resolving handles from DIDs httpClient *http.Client // Shared HTTP client with connection pooling didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches @@ -38,7 +40,10 @@ type cachedDIDDoc struct { // instanceDID: The DID of this Coves instance (for hostedBy verification) // skipVerification: Skip did:web verification (for dev mode) // identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests) -func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer { +func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface { + Resolve(context.Context, string) (*identity.Identity, error) +}, +) *CommunityEventConsumer { // Create bounded LRU cache for DID document verification results // Max 1000 entries to prevent unbounded memory growth (PR review feedback) // Each entry ~100 bytes → max ~100KB memory overhead diff --git a/internal/atproto/jetstream/post_consumer.go b/internal/atproto/jetstream/post_consumer.go index 899fa38..a7ee2fc 100644 --- a/internal/atproto/jetstream/post_consumer.go +++ b/internal/atproto/jetstream/post_consumer.go @@ -15,7 +15,7 @@ import ( // PostEventConsumer consumes post-related events from Jetstream // Currently handles only CREATE operations for social.coves.community.post // UPDATE and DELETE handlers will be added when those features are implemented -type PostEventConsumer struct{ +type PostEventConsumer struct { postRepo posts.Repository communityRepo communities.Repository userService users.UserService @@ -200,19 +200,19 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin // PostRecordFromJetstream represents a post record as received from Jetstream // Matches the structure written to PDS via social.coves.community.post -type PostRecordFromJetstream struct{ +type PostRecordFromJetstream struct { OriginalAuthor interface{} `json:"originalAuthor,omitempty"` FederatedFrom interface{} `json:"federatedFrom,omitempty"` Location interface{} `json:"location,omitempty"` Title *string `json:"title,omitempty"` Content *string `json:"content,omitempty"` Embed map[string]interface{} `json:"embed,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` Type string `json:"$type"` Community string `json:"community"` Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - Labels *posts.SelfLabels `json:"labels,omitempty"` } // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream diff --git a/internal/core/comments/comment.go b/internal/core/comments/comment.go index 636a241..ef0ac53 100644 --- a/internal/core/comments/comment.go +++ b/internal/core/comments/comment.go @@ -7,53 +7,42 @@ import ( // Comment represents a comment in the AppView database // Comments are indexed from the firehose after being written to user repositories type Comment struct { - ID int64 `json:"id" db:"id"` - URI string `json:"uri" db:"uri"` - CID string `json:"cid" db:"cid"` - RKey string `json:"rkey" db:"rkey"` - CommenterDID string `json:"commenterDid" db:"commenter_did"` - - // Author info (hydrated from users table for view building) - // Only populated by ListByParentWithHotRank, not persisted in comments table - CommenterHandle string `json:"commenterHandle,omitempty" db:"-"` - - // Threading (reply references) - RootURI string `json:"rootUri" db:"root_uri"` - RootCID string `json:"rootCid" db:"root_cid"` - ParentURI string `json:"parentUri" db:"parent_uri"` - ParentCID string `json:"parentCid" db:"parent_cid"` - - // Content - Content string `json:"content" db:"content"` - ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` - Embed *string `json:"embed,omitempty" db:"embed"` - ContentLabels *string `json:"labels,omitempty" db:"content_labels"` - Langs []string `json:"langs,omitempty" db:"langs"` - - // Timestamps - CreatedAt time.Time `json:"createdAt" db:"created_at"` - IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` - DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` - - // Stats (denormalized for performance) - UpvoteCount int `json:"upvoteCount" db:"upvote_count"` - DownvoteCount int `json:"downvoteCount" db:"downvote_count"` - Score int `json:"score" db:"score"` - ReplyCount int `json:"replyCount" db:"reply_count"` + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` + Embed *string `json:"embed,omitempty" db:"embed"` + CommenterHandle string `json:"commenterHandle,omitempty" db:"-"` + CommenterDID string `json:"commenterDid" db:"commenter_did"` + ParentURI string `json:"parentUri" db:"parent_uri"` + ParentCID string `json:"parentCid" db:"parent_cid"` + Content string `json:"content" db:"content"` + RootURI string `json:"rootUri" db:"root_uri"` + URI string `json:"uri" db:"uri"` + RootCID string `json:"rootCid" db:"root_cid"` + CID string `json:"cid" db:"cid"` + RKey string `json:"rkey" db:"rkey"` + Langs []string `json:"langs,omitempty" db:"langs"` + ID int64 `json:"id" db:"id"` + UpvoteCount int `json:"upvoteCount" db:"upvote_count"` + DownvoteCount int `json:"downvoteCount" db:"downvote_count"` + Score int `json:"score" db:"score"` + ReplyCount int `json:"replyCount" db:"reply_count"` } // CommentRecord represents the atProto record structure indexed from Jetstream // This is the data structure that gets stored in the user's repository // Matches social.coves.feed.comment lexicon type CommentRecord struct { - Type string `json:"$type"` - Reply ReplyRef `json:"reply"` - Content string `json:"content"` - Facets []interface{} `json:"facets,omitempty"` - Embed map[string]interface{} `json:"embed,omitempty"` - Langs []string `json:"langs,omitempty"` - Labels *SelfLabels `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` + Embed map[string]interface{} `json:"embed,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` + Reply ReplyRef `json:"reply"` + Type string `json:"$type"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + Facets []interface{} `json:"facets,omitempty"` + Langs []string `json:"langs,omitempty"` } // ReplyRef represents the threading structure from the comment lexicon @@ -79,6 +68,6 @@ type SelfLabels struct { // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel // Neg is optional and negates the label when true type SelfLabel struct { - Val string `json:"val"` // Required: label value (max 128 chars) - Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true + Neg *bool `json:"neg,omitempty"` + Val string `json:"val"` } diff --git a/internal/core/comments/comment_service.go b/internal/core/comments/comment_service.go index 7f382f2..c7dc86c 100644 --- a/internal/core/comments/comment_service.go +++ b/internal/core/comments/comment_service.go @@ -22,22 +22,22 @@ type Service interface { // GetCommentsRequest defines the parameters for fetching comments type GetCommentsRequest struct { - PostURI string // AT-URI of the post to fetch comments for - Sort string // "hot", "top", "new" - sorting algorithm - Timeframe string // "hour", "day", "week", "month", "year", "all" - for "top" sort only - Depth int // 0-100 - how many levels of nested replies to load (default 10) - Limit int // 1-100 - max top-level comments per page (default 50) - Cursor *string // Pagination cursor from previous response - ViewerDID *string // Optional DID of authenticated viewer (for vote state) + Cursor *string + ViewerDID *string + PostURI string + Sort string + Timeframe string + Depth int + Limit int } // commentService implements the Service interface // Coordinates between repository layer and view model construction type commentService struct { - commentRepo Repository // Comment data access - userRepo users.UserRepository // User lookup for author hydration - postRepo posts.Repository // Post lookup for building post views - communityRepo communities.Repository // Community lookup for community hydration + commentRepo Repository // Comment data access + userRepo users.UserRepository // User lookup for author hydration + postRepo posts.Repository // Post lookup for building post views + communityRepo communities.Repository // Community lookup for community hydration } // NewCommentService creates a new comment service instance diff --git a/internal/core/comments/view_models.go b/internal/core/comments/view_models.go index 7d6943a..ca900a6 100644 --- a/internal/core/comments/view_models.go +++ b/internal/core/comments/view_models.go @@ -8,19 +8,19 @@ import ( // Matches social.coves.feed.getComments#commentView lexicon // Used in thread views and get endpoints type CommentView struct { - URI string `json:"uri"` - CID string `json:"cid"` + Embed interface{} `json:"embed,omitempty"` + Record interface{} `json:"record"` + Viewer *CommentViewerState `json:"viewer,omitempty"` Author *posts.AuthorView `json:"author"` - Record interface{} `json:"record"` // Original record verbatim - Post *CommentRef `json:"post"` // Reference to parent post - Parent *CommentRef `json:"parent,omitempty"` // Parent comment if nested + Post *CommentRef `json:"post"` + Parent *CommentRef `json:"parent,omitempty"` + Stats *CommentStats `json:"stats"` Content string `json:"content"` + CreatedAt string `json:"createdAt"` + IndexedAt string `json:"indexedAt"` + URI string `json:"uri"` + CID string `json:"cid"` ContentFacets []interface{} `json:"contentFacets,omitempty"` - Embed interface{} `json:"embed,omitempty"` - CreatedAt string `json:"createdAt"` // RFC3339 - IndexedAt string `json:"indexedAt"` // RFC3339 - Stats *CommentStats `json:"stats"` - Viewer *CommentViewerState `json:"viewer,omitempty"` } // ThreadViewComment represents a comment with its nested replies @@ -59,7 +59,7 @@ type CommentViewerState struct { // Matches social.coves.feed.getComments lexicon output // Includes the full comment thread tree and original post reference type GetCommentsResponse struct { + Post interface{} `json:"post"` + Cursor *string `json:"cursor,omitempty"` Comments []*ThreadViewComment `json:"comments"` - Post interface{} `json:"post"` // PostView from post handler - Cursor *string `json:"cursor,omitempty"` // Pagination cursor } diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go index e0f74d2..ef68ca4 100644 --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -13,8 +13,8 @@ type SelfLabels struct { // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel // Neg is optional and negates the label when true type SelfLabel struct { - Val string `json:"val"` // Required: label value (max 128 chars) - Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true + Neg *bool `json:"neg,omitempty"` + Val string `json:"val"` } // Post represents a post in the AppView database @@ -50,15 +50,15 @@ type CreatePostRequest struct { Title *string `json:"title,omitempty"` Content *string `json:"content,omitempty"` Embed map[string]interface{} `json:"embed,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` Community string `json:"community"` AuthorDID string `json:"authorDid"` Facets []interface{} `json:"facets,omitempty"` - Labels *SelfLabels `json:"labels,omitempty"` } // CreatePostResponse represents the response from creating a post // Matches social.coves.community.post.create lexicon output schema -type CreatePostResponse struct{ +type CreatePostResponse struct { URI string `json:"uri"` // AT-URI of created post CID string `json:"cid"` // CID of created post } @@ -72,12 +72,12 @@ type PostRecord struct { Title *string `json:"title,omitempty"` Content *string `json:"content,omitempty"` Embed map[string]interface{} `json:"embed,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` Type string `json:"$type"` Community string `json:"community"` Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - Labels *SelfLabels `json:"labels,omitempty"` } // PostView represents the full view of a post with all metadata diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go index 8e8b719..ff698ec 100644 --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -259,9 +259,9 @@ func (s *postService) createPostOnPDS( // IMPORTANT: repo is set to community DID, not author DID // This writes the post to the community's repository payload := map[string]interface{}{ - "repo": community.DID, // Community's repository + "repo": community.DID, // Community's repository "collection": "social.coves.community.post", // Collection type - "record": record, // The post record + "record": record, // The post record // "rkey" omitted - PDS will auto-generate TID } diff --git a/internal/db/postgres/comment_repo.go b/internal/db/postgres/comment_repo.go index 98937be..64a5b68 100644 --- a/internal/db/postgres/comment_repo.go +++ b/internal/db/postgres/comment_repo.go @@ -353,6 +353,7 @@ func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID return result, nil } + // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) // Uses cursor-based pagination with composite keys for consistent ordering diff --git a/tests/integration/comment_consumer_test.go b/tests/integration/comment_consumer_test.go index 46f22ef..9fda772 100644 --- a/tests/integration/comment_consumer_test.go +++ b/tests/integration/comment_consumer_test.go @@ -45,7 +45,7 @@ func TestCommentConsumer_CreateComment(t *testing.T) { RKey: rkey, CID: "bafytest123", Record: map[string]interface{}{ - "$type": "social.coves.feed.comment", + "$type": "social.coves.feed.comment", "content": "This is a test comment on a post!", "reply": map[string]interface{}{ "root": map[string]interface{}{ @@ -119,7 +119,7 @@ func TestCommentConsumer_CreateComment(t *testing.T) { RKey: rkey, CID: "bafytest456", Record: map[string]interface{}{ - "$type": "social.coves.feed.comment", + "$type": "social.coves.feed.comment", "content": "Idempotent test comment", "reply": map[string]interface{}{ "root": map[string]interface{}{ diff --git a/tests/integration/comment_query_test.go b/tests/integration/comment_query_test.go index 3b3d197..ef041c1 100644 --- a/tests/integration/comment_query_test.go +++ b/tests/integration/comment_query_test.go @@ -851,13 +851,13 @@ func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCom } type testGetCommentsRequest struct { + Cursor *string + ViewerDID *string PostURI string Sort string Timeframe string Depth int Limit int - Cursor *string - ViewerDID *string } func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter { diff --git a/tests/integration/community_consumer_test.go b/tests/integration/community_consumer_test.go index 7a9acd1..5039918 100644 --- a/tests/integration/community_consumer_test.go +++ b/tests/integration/community_consumer_test.go @@ -369,13 +369,10 @@ func TestCommunityConsumer_IgnoresNonCommunityEvents(t *testing.T) { // mockIdentityResolver is a test double for identity resolution type mockIdentityResolver struct { - // Map of DID -> handle for successful resolutions resolutions map[string]string - // If true, Resolve returns an error - shouldFail bool - // Track calls to verify invocation - callCount int - lastDID string + lastDID string + callCount int + shouldFail bool } func newMockIdentityResolver() *mockIdentityResolver { diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 81e47ac..3527889 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -70,18 +70,6 @@ func setupTestDB(t *testing.T) *sql.DB { return db } -// setupIdentityResolver creates an identity resolver configured for local PLC testing -func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } { - plcURL := os.Getenv("PLC_DIRECTORY_URL") - if plcURL == "" { - plcURL = "http://localhost:3002" // Local PLC directory - } - - config := identity.DefaultConfig() - config.PLCURL = plcURL - return identity.NewResolver(db, config) -} - // generateTestDID generates a unique test DID for integration tests // V2.0: No longer uses DID generator - just creates valid did:plc strings func generateTestDID(suffix string) string { diff --git a/tests/lexicon_validation_test.go b/tests/lexicon_validation_test.go index ed0afbc..3ddf070 100644 --- a/tests/lexicon_validation_test.go +++ b/tests/lexicon_validation_test.go @@ -72,12 +72,12 @@ func TestLexiconCrossReferences(t *testing.T) { // Test specific cross-references that should work crossRefs := map[string]string{ - "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema", - "social.coves.community.rules#rule": "rule definition in community rules", - "social.coves.actor.defs#profileView": "profileView definition in actor defs", - "social.coves.actor.defs#profileStats": "profileStats definition in actor defs", - "social.coves.actor.defs#viewerState": "viewerState definition in actor defs", - "social.coves.community.defs#communityView": "communityView definition in community defs", + "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema", + "social.coves.community.rules#rule": "rule definition in community rules", + "social.coves.actor.defs#profileView": "profileView definition in actor defs", + "social.coves.actor.defs#profileStats": "profileStats definition in actor defs", + "social.coves.actor.defs#viewerState": "viewerState definition in actor defs", + "social.coves.community.defs#communityView": "communityView definition in community defs", "social.coves.community.defs#communityStats": "communityStats definition in community defs", }