diff --git a/app/cmd/routes.go b/app/cmd/routes.go index 4fdd01da5..9ea59ccc8 100644 --- a/app/cmd/routes.go +++ b/app/cmd/routes.go @@ -202,6 +202,7 @@ func routes(r *web.Engine) *web.Engine { publicApi.Get("/api/v1/posts/:number", apiv1.GetPost()) publicApi.Get("/api/v1/posts/:number/comments", apiv1.ListComments()) publicApi.Get("/api/v1/posts/:number/comments/:id", apiv1.GetComment()) + publicApi.Get("/api/v1/comments", apiv1.AllComments()) publicApi.Get("/api/v1/taggable-users", apiv1.ListTaggableUsers()) } diff --git a/app/handlers/apiv1/comment.go b/app/handlers/apiv1/comment.go new file mode 100644 index 000000000..c68a7b06f --- /dev/null +++ b/app/handlers/apiv1/comment.go @@ -0,0 +1,71 @@ +package apiv1 + +import ( + "time" + + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/web" +) + +type commentsResponse struct { + Data []*entity.CommentRef `json:"data"` + Pagination *paginationInfo `json:"pagination"` +} + +type paginationInfo struct { + HasNext bool `json:"hasNext"` + NextCursor string `json:"nextCursor,omitempty"` +} + +// AllComments returns a list of all comments regardless of the post +func AllComments() web.HandlerFunc { + return func(c *web.Context) error { + var since time.Time + + if sinceParam, err := time.Parse(time.RFC3339, c.QueryParam("since")); err == nil { + since = sinceParam + } + + // Default limit to 50, max 100 + limit := 50 + if limitParam, err := c.QueryParamAsInt("limit"); err == nil && limitParam > 0 { + limit = limitParam + if limit > 100 { + limit = 100 + } + } + + getComments := &query.GetCommentRefs{ + Since: since, + Limit: limit, + } + if err := bus.Dispatch(c, getComments); err != nil { + return c.Failure(err) + } + + // Determine if there are more results by checking if we got a full page + hasNext := len(getComments.Result) == limit + var nextCursor string + if hasNext && len(getComments.Result) > 0 { + lastComment := getComments.Result[len(getComments.Result)-1] + // Use the created_at time as the cursor, but prefer edited_at if available + cursorTime := lastComment.CreatedAt + if lastComment.EditedAt != nil { + cursorTime = *lastComment.EditedAt + } + nextCursor = cursorTime.Format(time.RFC3339) + } + + response := commentsResponse{ + Data: getComments.Result, + Pagination: &paginationInfo{ + HasNext: hasNext, + NextCursor: nextCursor, + }, + } + + return c.Ok(response) + } +} diff --git a/app/handlers/apiv1/comment_test.go b/app/handlers/apiv1/comment_test.go new file mode 100644 index 000000000..452aff22e --- /dev/null +++ b/app/handlers/apiv1/comment_test.go @@ -0,0 +1,269 @@ +package apiv1_test + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + "github.com/getfider/fider/app/handlers/apiv1" + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/query" + . "github.com/getfider/fider/app/pkg/assert" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/mock" +) + +// Shared response structs for testing +type paginationInfo struct { + HasNext bool `json:"hasNext"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type commentsResponse struct { + Data []*entity.CommentRef `json:"data"` + Pagination *paginationInfo `json:"pagination"` +} + +func TestAllCommentsHandler(t *testing.T) { + RegisterT(t) + + // Create 8 test comment refs (less than limit to ensure hasNext = false) + testComments := make([]*entity.CommentRef, 8) + for i := 0; i < 8; i++ { + testComments[i] = &entity.CommentRef{ + ID: i + 1, + CreatedAt: time.Now().Add(time.Duration(i) * time.Minute), + UserID: 1, + PostID: 1, + } + } + + bus.AddHandler(func(ctx context.Context, q *query.GetCommentRefs) error { + q.Result = testComments + return nil + }) + + code, body := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + WithURL("/api/v1/comments?limit=10"). + Execute(apiv1.AllComments()) + + Expect(code).Equals(http.StatusOK) + + // Properly unmarshal the JSON response + var response commentsResponse + err := json.Unmarshal(body.Body.Bytes(), &response) + Expect(err).IsNil() + + // Test that the response has the correct structure + Expect(response.Data).IsNotNil() + Expect(len(response.Data)).Equals(8) // Should have exactly 8 items + Expect(response.Pagination).IsNotNil() + + // Since we got fewer results than the limit (8 < 10), hasNext should be false + Expect(response.Pagination.HasNext).IsFalse() + Expect(response.Pagination.NextCursor).Equals("") // No cursor when no next page +} + +func TestAllCommentsHandler_WithLimit(t *testing.T) { + RegisterT(t) + + // Create 5 test comment refs (less than the requested limit) + testComments := make([]*entity.CommentRef, 5) + for i := 0; i < 5; i++ { + testComments[i] = &entity.CommentRef{ + ID: i + 1, + CreatedAt: time.Now().Add(time.Duration(i) * time.Minute), + UserID: 1, + PostID: 1, + } + } + + bus.AddHandler(func(ctx context.Context, q *query.GetCommentRefs) error { + // Should receive the requested limit of 20 + Expect(q.Limit).Equals(20) + q.Result = testComments + return nil + }) + + code, body := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + WithURL("/api/v1/comments?limit=20"). + Execute(apiv1.AllComments()) + + Expect(code).Equals(http.StatusOK) + + // Properly unmarshal the JSON response + var response commentsResponse + err := json.Unmarshal(body.Body.Bytes(), &response) + Expect(err).IsNil() + + // Test that the response has the correct structure + Expect(response.Data).IsNotNil() + Expect(len(response.Data)).Equals(5) // Should have exactly 5 items + Expect(response.Pagination).IsNotNil() + + // Since we got fewer results than the limit (5 < 20), hasNext should be false + Expect(response.Pagination.HasNext).IsFalse() + Expect(response.Pagination.NextCursor).Equals("") // No cursor when no next page +} + +func TestAllCommentsHandler_WithPagination(t *testing.T) { + RegisterT(t) + + // Create 20 test comment refs (equal to limit to simulate full page) + testComments := make([]*entity.CommentRef, 20) + baseTime := time.Now() + for i := 0; i < 20; i++ { + testComments[i] = &entity.CommentRef{ + ID: i + 1, + CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), + UserID: 1, + PostID: 1, + } + } + + bus.AddHandler(func(ctx context.Context, q *query.GetCommentRefs) error { + // Should receive the requested limit of 20 + Expect(q.Limit).Equals(20) + q.Result = testComments + return nil + }) + + code, body := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + WithURL("/api/v1/comments?limit=20"). + Execute(apiv1.AllComments()) + + Expect(code).Equals(http.StatusOK) + + // Properly unmarshal the JSON response + var response commentsResponse + err := json.Unmarshal(body.Body.Bytes(), &response) + Expect(err).IsNil() + + // Test that the response has the correct structure + Expect(response.Data).IsNotNil() + Expect(len(response.Data)).Equals(20) // Should have exactly 20 items + Expect(response.Pagination).IsNotNil() + + // Since we got exactly the limit (20 == 20), hasNext should be true (might be more pages) + Expect(response.Pagination.HasNext).IsTrue() + Expect(response.Pagination.NextCursor).IsNotEmpty() // Should have cursor for next page + + // The cursor should be the timestamp of the last comment + expectedCursor := testComments[19].CreatedAt.Format(time.RFC3339) + Expect(response.Pagination.NextCursor).Equals(expectedCursor) +} + +func TestAllCommentsHandler_FullPaginationFlow(t *testing.T) { + RegisterT(t) + + // Create 15 test comment refs with incrementing timestamps + testComments := make([]*entity.CommentRef, 15) + baseTime := time.Date(2023, 10, 1, 10, 0, 0, 0, time.UTC) // Use fixed time for predictability + for i := 0; i < 15; i++ { + testComments[i] = &entity.CommentRef{ + ID: i + 1, + CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), + UserID: 1, + PostID: 1, + } + } + + // Set up mock handler that will be called twice + callCount := 0 + bus.AddHandler(func(ctx context.Context, q *query.GetCommentRefs) error { + callCount++ + + if callCount == 1 { + // First call: return first 10 comments (limit=10, no since filter) + Expect(q.Limit).Equals(10) + Expect(q.Since.IsZero()).IsTrue() // No since parameter on first call + q.Result = testComments[:10] // Return first 10 comments + } else if callCount == 2 { + // Second call: return remaining 5 comments (limit=10, with since filter) + Expect(q.Limit).Equals(10) + Expect(q.Since.IsZero()).IsFalse() // Should have since parameter + // The since parameter should match the timestamp of the 10th comment + expectedSince := testComments[9].CreatedAt // 10th comment (index 9) + // Compare timestamps with some tolerance for RFC3339 parsing + timeDiff := q.Since.Sub(expectedSince) + if timeDiff < 0 { + timeDiff = -timeDiff + } + Expect(timeDiff < time.Second).IsTrue() // Should be within 1 second + + // Return comments that would come after the since timestamp + // In real implementation, this would filter by COALESCE(edited_at, created_at) >= since + q.Result = testComments[10:] // Return remaining 5 comments (indexes 10-14) + } + + return nil + }) + + // FIRST API CALL: Get first page (10 comments, limit 10) + code1, body1 := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + WithURL("/api/v1/comments?limit=10"). + Execute(apiv1.AllComments()) + + Expect(code1).Equals(http.StatusOK) + + var response1 commentsResponse + err1 := json.Unmarshal(body1.Body.Bytes(), &response1) + Expect(err1).IsNil() + + // Validate first page response + Expect(response1.Data).IsNotNil() + Expect(len(response1.Data)).Equals(10) // Should have exactly 10 items + Expect(response1.Pagination).IsNotNil() + + // Since we got exactly the limit (10 == 10), hasNext should be true + Expect(response1.Pagination.HasNext).IsTrue() + Expect(response1.Pagination.NextCursor).IsNotEmpty() + + // The cursor should be the timestamp of the last comment from first page + expectedCursor1 := testComments[9].CreatedAt.Format(time.RFC3339) // 10th comment (index 9) + Expect(response1.Pagination.NextCursor).Equals(expectedCursor1) + + // SECOND API CALL: Get second page using the cursor from first page + secondPageURL := "/api/v1/comments?limit=10&since=" + url.QueryEscape(response1.Pagination.NextCursor) + code2, body2 := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + WithURL(secondPageURL). + Execute(apiv1.AllComments()) + + Expect(code2).Equals(http.StatusOK) + + var response2 commentsResponse + err2 := json.Unmarshal(body2.Body.Bytes(), &response2) + Expect(err2).IsNil() + + // Validate second page response + Expect(response2.Data).IsNotNil() + Expect(len(response2.Data)).Equals(5) // Should have exactly 5 items (remaining comments) + Expect(response2.Pagination).IsNotNil() + + // Since we got fewer than the limit (5 < 10), hasNext should be false + Expect(response2.Pagination.HasNext).IsFalse() + Expect(response2.Pagination.NextCursor).Equals("") // No cursor when no more pages + + // Validate that we got all 15 comments across both pages + // First page should have comments 1-10, second page should have comments 11-15 + Expect(response1.Data[0].ID).Equals(1) // First comment from first page + Expect(response1.Data[9].ID).Equals(10) // Last comment from first page + Expect(response2.Data[0].ID).Equals(11) // First comment from second page + Expect(response2.Data[4].ID).Equals(15) // Last comment from second page (index 4 = 5th item) + + // Verify that we made exactly 2 calls to the handler + Expect(callCount).Equals(2) +} diff --git a/app/models/entity/comment_ref.go b/app/models/entity/comment_ref.go new file mode 100644 index 000000000..ec16a87c7 --- /dev/null +++ b/app/models/entity/comment_ref.go @@ -0,0 +1,13 @@ +package entity + +import ( + "time" +) + +type CommentRef struct { + ID int `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UserID int `json:"userId"` + PostID int `json:"postId"` + EditedAt *time.Time `json:"editedAt,omitempty"` +} diff --git a/app/models/query/comment.go b/app/models/query/comment.go index 7ae389b0d..f52034508 100644 --- a/app/models/query/comment.go +++ b/app/models/query/comment.go @@ -1,6 +1,8 @@ package query import ( + "time" + "github.com/getfider/fider/app/models/entity" ) @@ -15,3 +17,10 @@ type GetCommentsByPost struct { Result []*entity.Comment } + +type GetCommentRefs struct { + Since time.Time + Limit int + + Result []*entity.CommentRef +} diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index eaaa17d73..da8279fa3 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -3,6 +3,8 @@ package postgres import ( "context" "encoding/json" + "fmt" + "strings" "time" "github.com/getfider/fider/app/models/cmd" @@ -23,6 +25,14 @@ type dbComment struct { ReactionCounts dbx.NullString `db:"reaction_counts"` } +type dbCommentRef struct { + ID int `db:"id"` + CreatedAt time.Time `db:"created_at"` + UserId int `db:"user_id"` + PostId int `db:"post_id"` + EditedAt dbx.NullTime `db:"edited_at"` +} + func (c *dbComment) toModel(ctx context.Context) *entity.Comment { comment := &entity.Comment{ ID: c.ID, @@ -42,12 +52,25 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment { return comment } +func (c *dbCommentRef) toModel(ctx context.Context) *entity.CommentRef { + comment := &entity.CommentRef{ + ID: c.ID, + CreatedAt: c.CreatedAt, + UserID: c.UserId, + PostID: c.PostId, + } + if c.EditedAt.Valid { + comment.EditedAt = &c.EditedAt.Time + } + return comment +} + func addNewComment(ctx context.Context, c *cmd.AddNewComment) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { var id int if err := trx.Get(&id, ` - INSERT INTO comments (tenant_id, post_id, content, user_id, created_at) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO comments (tenant_id, post_id, content, user_id, created_at) + VALUES ($1, $2, $3, $4, $5) RETURNING id `, tenant.ID, c.Post.ID, c.Content, user.ID, time.Now()); err != nil { return errors.Wrap(err, "failed add new comment") @@ -98,7 +121,7 @@ func toggleCommentReaction(ctx context.Context, c *cmd.ToggleCommentReaction) er func updateComment(ctx context.Context, c *cmd.UpdateComment) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { _, err := trx.Execute(` - UPDATE comments SET content = $1, edited_at = $2, edited_by_id = $3 + UPDATE comments SET content = $1, edited_at = $2, edited_by_id = $3 WHERE id = $4 AND tenant_id = $5`, c.Content, time.Now(), user.ID, c.CommentID, tenant.ID) if err != nil { return errors.Wrap(err, "failed update comment") @@ -125,18 +148,18 @@ func getCommentByID(ctx context.Context, q *query.GetCommentByID) error { comment := dbComment{} err := trx.Get(&comment, - `SELECT c.id, - c.content, - c.created_at, - c.edited_at, - u.id AS user_id, + `SELECT c.id, + c.content, + c.created_at, + c.edited_at, + u.id AS user_id, u.name AS user_name, u.email AS user_email, - u.role AS user_role, + u.role AS user_role, u.status AS user_status, u.avatar_type AS user_avatar_type, - u.avatar_bkey AS user_avatar_bkey, - e.id AS edited_by_id, + u.avatar_bkey AS user_avatar_bkey, + e.id AS edited_by_id, e.name AS edited_by_name, e.email AS edited_by_email, e.role AS edited_by_role, @@ -174,9 +197,9 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { } err := trx.Select(&comments, ` - WITH agg_attachments AS ( - SELECT - c.id as comment_id, + WITH agg_attachments AS ( + SELECT + c.id as comment_id, ARRAY_REMOVE(ARRAY_AGG(at.attachment_bkey), NULL) as attachment_bkeys FROM attachments at INNER JOIN comments c @@ -186,10 +209,10 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { WHERE at.post_id = $1 AND at.tenant_id = $2 AND at.comment_id IS NOT NULL - GROUP BY c.id + GROUP BY c.id ), agg_reactions AS ( - SELECT + SELECT comment_id, json_agg(json_build_object( 'emoji', emoji, @@ -197,9 +220,9 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { 'includesMe', CASE WHEN $3 = ANY(user_ids) THEN true ELSE false END ) ORDER BY count DESC) as reaction_counts FROM ( - SELECT - comment_id, - emoji, + SELECT + comment_id, + emoji, COUNT(*) as count, array_agg(user_id) as user_ids FROM reactions @@ -208,23 +231,23 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { ) r GROUP BY comment_id ) - SELECT c.id, - c.content, - c.created_at, - c.edited_at, - u.id AS user_id, + SELECT c.id, + c.content, + c.created_at, + c.edited_at, + u.id AS user_id, u.name AS user_name, u.email AS user_email, - u.role AS user_role, - u.status AS user_status, - u.avatar_type AS user_avatar_type, - u.avatar_bkey AS user_avatar_bkey, - e.id AS edited_by_id, + u.role AS user_role, + u.status AS user_status, + u.avatar_type AS user_avatar_type, + u.avatar_bkey AS user_avatar_bkey, + e.id AS edited_by_id, e.name AS edited_by_name, e.email AS edited_by_email, e.role AS edited_by_role, e.status AS edited_by_status, - e.avatar_type AS edited_by_avatar_type, + e.avatar_type AS edited_by_avatar_type, e.avatar_bkey AS edited_by_avatar_bkey, at.attachment_bkeys, ar.reaction_counts @@ -257,3 +280,42 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { return nil }) } + +func getCommentRefs(ctx context.Context, q *query.GetCommentRefs) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + q.Result = make([]*entity.CommentRef, 0) + + comments := []*dbCommentRef{} + + // Build the WHERE clause conditions + args := []interface{}{tenant.ID} + whereConditions := []string{"deleted_at IS NULL", "tenant_id = $1"} + argIndex := 2 + + if !q.Since.IsZero() { + whereConditions = append(whereConditions, fmt.Sprintf("COALESCE(edited_at, created_at) >= $%d", argIndex)) + args = append(args, q.Since) + argIndex++ + } + + query := fmt.Sprintf(` + SELECT id, created_at, user_id, post_id, edited_at + FROM comments + WHERE %s + ORDER BY COALESCE(edited_at, created_at) ASC + LIMIT $%d`, strings.Join(whereConditions, " AND "), argIndex) + + args = append(args, q.Limit) + + err := trx.Select(&comments, query, args...) + if err != nil { + return errors.Wrap(err, "failed get comment refs") + } + + q.Result = make([]*entity.CommentRef, len(comments)) + for i, comment := range comments { + q.Result[i] = comment.toModel(ctx) + } + return nil + }) +} diff --git a/app/services/sqlstore/postgres/postgres.go b/app/services/sqlstore/postgres/postgres.go index b8bbf9847..251569299 100644 --- a/app/services/sqlstore/postgres/postgres.go +++ b/app/services/sqlstore/postgres/postgres.go @@ -81,6 +81,7 @@ func (s Service) Init() { bus.AddHandler(deleteComment) bus.AddHandler(getCommentByID) bus.AddHandler(getCommentsByPost) + bus.AddHandler(getCommentRefs) bus.AddHandler(countUsers) bus.AddHandler(blockUser) diff --git a/migrations/202508311750_index_expired_created.sql b/migrations/202508311750_index_expired_created.sql new file mode 100644 index 000000000..629f133ef --- /dev/null +++ b/migrations/202508311750_index_expired_created.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS comments_edited_at +ON comments (tenant_id, COALESCE(edited_at, created_at) ASC) +WHERE deleted_at IS NULL;