+11
-3
cmd/server/main.go
+11
-3
cmd/server/main.go
···+commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil)+log.Println("✅ Comment service initialized (with author/community hydration and write support)")···
+124
docs/PRD_BACKLOG.md
+124
docs/PRD_BACKLOG.md
···+The PDS client (`internal/atproto/pds/client.go`) only has `CreateRecord` but lacks `PutRecord`. This means updates use `CreateRecord` with an existing rkey, which:+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) {+`UpdateComment` in `internal/core/comments/comment_service.go` uses `CreateRecord` for updates instead of `PutRecord`. This lacks optimistic locking and is semantically incorrect.+// 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)
+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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+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) {
+8
-8
internal/core/comments/comment.go
+8
-8
internal/core/comments/comment.go
···
+376
-4
internal/core/comments/comment_service.go
+376
-4
internal/core/comments/comment_service.go
······+// Used to allow injection of different auth mechanisms (OAuth for production, password for tests).+type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error)···+CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error)+UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error)+DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error······+// NewCommentServiceWithPDSFactory creates a comment service with a custom PDS client factory.···+// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling.+func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {+func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) {+func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) {+// 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.+func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error {
+22
-22
internal/core/comments/comment_service_test.go
+22
-22
internal/core/comments/comment_service_test.go
···························-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil)···-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil)···parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil)···comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User))···-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User))···comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)···-service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)+service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
+1208
internal/core/comments/comment_write_service_test.go
+1208
internal/core/comments/comment_write_service_test.go
···+func (m *mockPDSClient) CreateRecord(ctx context.Context, collection, rkey string, record interface{}) (string, string, error) {+func (m *mockPDSClient) GetRecord(ctx context.Context, collection, rkey string) (*pds.RecordResponse, error) {+func (m *mockPDSClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*pds.ListRecordsResponse, error) {+func (f *mockPDSClientFactory) create(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {+t.Errorf("Expected root URI to be preserved as %s, got %s", originalRootURI, updatedRecord.Reply.Root.URI)+t.Errorf("Expected root CID to be preserved as %s, got %s", originalRootCID, updatedRecord.Reply.Root.CID)+t.Errorf("Expected content to be updated to 'Updated content', got %s", updatedRecord.Content)
+2
-2
internal/core/comments/errors.go
+2
-2
internal/core/comments/errors.go
···
+38
internal/core/comments/types.go
+38
internal/core/comments/types.go
···
+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)
+808
tests/integration/comment_write_test.go
+808
tests/integration/comment_write_test.go
···+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",+t.Fatalf("Comment record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body))+t.Errorf("Expected content 'This is a test comment on the post', got %v", pdsRecord.Value["content"])+t.Errorf("Expected content 'This is a test comment on the post', got %s", indexedComment.Content)+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at)+`, parentCommentURI, parentCommentCID, "parent123", userDID, postURI, postCID, postURI, postCID, "Parent comment")+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")
+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)