+118
-111
CLAUDE.md
+118
-111
CLAUDE.md
···-You are a distinguished senior architect conducting a thorough code review for Coves, a forum-like atProto social media platform.-- Ensure there is proper test coverage that adequately tests atproto write forward architecture-- **atProto architecture**: - Ensure architecture follows atProto recommendations with WRITE FORWARD ARCHITECTURE (Appview -> PDS -> Relay -> Appview -> App DB (if necessary))-Then provide detailed feedback organized by: 1. ๐จ **Critical Issues** (must fix) 2. โ ๏ธ **Important Issues** (should fix) 3. ๐ก **Suggestions** (consider for improvement) 4. โ **Good Practices Observed** (reinforce positive patterns)-Remember: The goal is to ship quality code quickly. Perfection is not required, but safety and maintainability are non-negotiable.
···+Project: Coves Builder You are a distinguished developer actively building Coves, a forum-like atProto social media platform. Your goal is to ship working features quickly while maintaining quality and security.+**This project uses [bd (beads)](https://github.com/steveyegge/beads) for ALL issue tracking.**+- Descriptive Naming: Use full words over abbreviations (e.g., CommunityGovernance not CommGov)+- [ ] ย **Record Types**: Define custom lexicons (e.g.,ย `social.coves.post`,ย `social.coves.community`)+Remember: We're building a working product. Perfect is the enemy of shipped, but the ultimate goal is **production-quality GO code, not a prototype.**+Every line of code should be something you'd be proud to ship in a production system. Quality over speed. Completeness over convenience.
+76
-16
internal/atproto/jetstream/vote_consumer.go
+76
-16
internal/atproto/jetstream/vote_consumer.go
······-func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error {·········log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)···
······+// Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent)+func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) (bool, error) {···+if err := tx.QueryRowContext(ctx, checkQuery, vote.VoterDID, vote.SubjectURI, vote.URI).Scan(&existingDirection); err != nil && err != sql.ErrNoRows {+if _, err := tx.ExecContext(ctx, softDeleteQuery, vote.VoterDID, vote.SubjectURI, vote.URI); err != nil {+decrementQuery = `UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE posts SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+log.Printf("Cleaned up stale vote for %s on %s (was %s)", vote.VoterDID, vote.SubjectURI, existingDirection.String)······log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)···
+38
internal/core/comments/types.go
+38
internal/core/comments/types.go
···
···
+130
internal/api/handlers/comments/create_comment.go
+130
internal/api/handlers/comments/create_comment.go
···
···+// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+80
internal/api/handlers/comments/delete_comment.go
+80
internal/api/handlers/comments/delete_comment.go
···
···+// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+34
-2
internal/api/handlers/comments/errors.go
+34
-2
internal/api/handlers/comments/errors.go
······
······+writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")+writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")+writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+112
internal/api/handlers/comments/update_comment.go
+112
internal/api/handlers/comments/update_comment.go
···
···+// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+35
internal/api/routes/comment.go
+35
internal/api/routes/comment.go
···
···+func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+4
-2
tests/integration/comment_query_test.go
+4
-2
tests/integration/comment_query_test.go
······
···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+6
-3
tests/integration/comment_vote_test.go
+6
-3
tests/integration/comment_vote_test.go
·········
···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+2
-1
tests/integration/concurrent_scenarios_test.go
+2
-1
tests/integration/concurrent_scenarios_test.go
···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+1
-1
go.mod
+1
-1
go.mod
···
···
+2
go.sum
+2
go.sum
···github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
···github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
···
···+COMMENT ON COLUMN comments.deletion_reason IS 'Reason for deletion: author (user deleted), moderator (community mod removed)';+CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) WHERE deleted_at IS NULL;+CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) WHERE deleted_at IS NULL;+CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+17
-13
internal/core/comments/view_models.go
+17
-13
internal/core/comments/view_models.go
···
···
+23
-1
internal/core/comments/interfaces.go
+23
-1
internal/core/comments/interfaces.go
·········
······+// SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure···+SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error)
+87
-27
internal/db/postgres/comment_repo.go
+87
-27
internal/db/postgres/comment_repo.go
············func (r *postgresCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*comments.Comment, error) {······func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*comments.Comment, error) {······func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*comments.Comment, error) {······log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,······// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)······func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)······log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,·········log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)······
······+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,······+// SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure+func (r *postgresCommentRepo) SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error {+func (r *postgresCommentRepo) SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) {func (r *postgresCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,······// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,·········log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)······+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,
+33
internal/atproto/pds/client.go
+33
internal/atproto/pds/client.go
·········
···+PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)······+func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {
+231
internal/atproto/pds/client_test.go
+231
internal/atproto/pds/client_test.go
···+err: &atclient.APIError{StatusCode: 409, Name: "InvalidSwap", Message: "Record CID mismatch"},···
+3
internal/atproto/pds/errors.go
+3
internal/atproto/pds/errors.go
···
+5
-6
internal/core/comments/comment_service.go
+5
-6
internal/core/comments/comment_service.go
···-// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.-// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.···
···+uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)···
+73
internal/api/handlers/common/viewer_state.go
+73
internal/api/handlers/common/viewer_state.go
···
···+// This allows the helper to work with different feed post types (discover, timeline, communityFeed).
+3
-36
internal/api/handlers/communityFeed/get_community.go
+3
-36
internal/api/handlers/communityFeed/get_community.go
······
······
+11
-4
internal/api/handlers/discover/get_discover.go
+11
-4
internal/api/handlers/discover/get_discover.go
·········
······+func NewGetDiscoverHandler(service discover.Service, voteService votes.Service) *GetDiscoverHandler {···
+3
-34
internal/api/handlers/timeline/get_timeline.go
+3
-34
internal/api/handlers/timeline/get_timeline.go
······
······
+9
-4
internal/api/routes/discover.go
+9
-4
internal/api/routes/discover.go
·········
·········+r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.feed.getDiscover", getDiscoverHandler.HandleGetDiscover)
+5
internal/core/communityFeeds/types.go
+5
internal/core/communityFeeds/types.go
···
+5
internal/core/discover/types.go
+5
internal/core/discover/types.go
···
+5
internal/core/timeline/types.go
+5
internal/core/timeline/types.go
···
+193
-5
tests/integration/discover_test.go
+193
-5
tests/integration/discover_test.go
·····················req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil)···
······+func (m *mockVoteService) CreateVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {+func (m *mockVoteService) DeleteVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.DeleteVoteRequest) error {+func (m *mockVoteService) EnsureCachePopulated(_ context.Context, _ *oauthlib.ClientSessionData) error {+func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {···+handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state···+handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state·········req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil)···+// TestGetDiscover_ViewerVoteState tests that authenticated users see their vote state on posts+communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("votes-%d", testID), fmt.Sprintf("alice-%d.test", testID))+post1URI := createTestPost(t, db, communityDID, "did:plc:author1", "Post with upvote", 10, time.Now().Add(-1*time.Hour))+post2URI := createTestPost(t, db, communityDID, "did:plc:author2", "Post with downvote", 5, time.Now().Add(-2*time.Hour))+_ = createTestPost(t, db, communityDID, "did:plc:author3", "Post without vote", 3, time.Now().Add(-3*time.Hour))+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)+assert.Contains(t, *feedPost.Post.Viewer.VoteURI, "vote1", "Post1 should have correct vote URI")+// TestGetDiscover_NoViewerStateWithoutAuth tests that unauthenticated users don't get viewer state+communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("noauth-%d", testID), fmt.Sprintf("alice-%d.test", testID))+mockVotes.AddVote("did:plc:someuser", postURI, "up", "at://did:plc:someuser/social.coves.vote/vote1")+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)
+11
-11
tests/integration/feed_test.go
+11
-11
tests/integration/feed_test.go
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)··················
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)··················
+7
-7
tests/integration/timeline_test.go
+7
-7
tests/integration/timeline_test.go
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)······
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)······
+1
-1
tests/integration/user_journey_e2e_test.go
+1
-1
tests/integration/user_journey_e2e_test.go
···routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators
···routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators