diff --git a/ghost/core/core/server/models/comment.js b/ghost/core/core/server/models/comment.js index 2c09c32a74..f836b402bf 100644 --- a/ghost/core/core/server/models/comment.js +++ b/ghost/core/core/server/models/comment.js @@ -193,7 +193,7 @@ const Comment = ghostBookshelf.Model.extend({ // Relations 'member', 'count.replies', 'count.likes', 'count.liked', // Replies (limited to 3) - 'replies', 'replies.member' , 'replies.count.likes', 'replies.count.liked' + 'replies', 'replies.member', 'replies.count.likes', 'replies.count.liked' ]; } } @@ -202,19 +202,24 @@ const Comment = ghostBookshelf.Model.extend({ return options; }, - async findMostLikedComment(options = {}) { - let query = ghostBookshelf.knex('comments') - .select('comments.*') - .count('comment_likes.id as count__likes') // Counting likes for sorting - .leftJoin('comment_likes', 'comments.id', 'comment_likes.comment_id') - .groupBy('comments.id') // Group by comment ID to aggregate likes count - .orderBy('count__likes', 'desc') // Order by likes in descending order (most likes first) - .limit(1); // Limit to just 1 result - // Execute the query and get the result - const result = await query.first(); // Fetch the single top comment - const id = result && result.id; - // Fetch the comment model by ID - return this.findOne({id}, options); + async commentCount(options) { + const query = this.forge().query(); + + if (options.postId) { + query.where('post_id', options.postId); + } + + if (options.status) { + query.where('status', options.status); + } + + return query.count('id as count').then((result) => { + return Number(result[0].count) || 0; + }).catch((err) => { + throw new errors.InternalServerError({ + err: err + }); + }); }, async findPage(options) { @@ -233,16 +238,6 @@ const Comment = ghostBookshelf.Model.extend({ await model.load(relationsToLoadIndividually, _.omit(options, 'withRelated')); } - // if options.order === 'best', we findMostLikedComment - // then we remove it from the result set and add it as the first element - if (options.order === 'best' && options.page === '1') { - const mostLikedComment = await this.findMostLikedComment(options); - if (mostLikedComment) { - result.data = result.data.filter(comment => comment.id !== mostLikedComment.id); - result.data.unshift(mostLikedComment); - } - } - return result; }, @@ -289,10 +284,8 @@ const Comment = ghostBookshelf.Model.extend({ */ permittedOptions: function permittedOptions(methodName) { let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); - // The comment model additionally supports having a parentId option options.push('parentId'); - return options; } }); diff --git a/ghost/core/core/server/services/comments/CommentsController.js b/ghost/core/core/server/services/comments/CommentsController.js index 4a5a4828f0..a2da700a8d 100644 --- a/ghost/core/core/server/services/comments/CommentsController.js +++ b/ghost/core/core/server/services/comments/CommentsController.js @@ -51,6 +51,11 @@ module.exports = class CommentsController { frame.options.filter = `post_id:${frame.options.post_id}`; } } + + if (frame.options.order === 'best') { + return this.service.getBestComments(frame.options); + } + return this.service.getComments(frame.options); } diff --git a/ghost/core/core/server/services/comments/CommentsService.js b/ghost/core/core/server/services/comments/CommentsService.js index b9049fb6d1..bc6c515bdf 100644 --- a/ghost/core/core/server/services/comments/CommentsService.js +++ b/ghost/core/core/server/services/comments/CommentsService.js @@ -173,6 +173,60 @@ class CommentsService { return page; } + async getBestComments(options) { + this.checkEnabled(); + const postId = options.post_id; + + const allOrderedComments = await this.models.Comment.query() + .where('comments.post_id', postId) // Filter by postId + .select('comments.id') + .count('comment_likes.id as count__likes') + .leftJoin('comment_likes', 'comments.id', 'comment_likes.comment_id') + .groupBy('comments.id') + .orderByRaw(` + count__likes DESC, + comments.created_at DESC + `); + + const totalComments = allOrderedComments.length; + + if (totalComments === 0) { + const page = await this.models.Comment.findPage({...options, parentId: null}); + + return page; + } + + const limit = Number(options.limit) || 15; + const currentPage = Number(options.page) || 1; + + const orderedIds = allOrderedComments + .slice((options.page - 1) * limit, currentPage * limit) + .map(comment => comment.id); + + const findPageOptions = { + ...options, + filter: `id:[${orderedIds.join(',')}]`, + withRelated: options.withRelated + }; + + const page = await this.models.Comment.findPage(findPageOptions); + + page.data = orderedIds + .map(id => page.data.find(comment => comment && comment.id === id)) + .filter(comment => comment !== undefined); + + page.meta.pagination = { + page: currentPage, + limit: limit, + pages: Math.ceil(totalComments / limit), + total: totalComments, + next: currentPage < Math.ceil(totalComments / limit) ? currentPage + 1 : null, + prev: currentPage > 1 ? currentPage - 1 : null + }; + + return page; + } + /** * @param {string} id - The ID of the Comment to get replies from * @param {any} options diff --git a/ghost/core/test/e2e-api/members-comments/comments.test.js b/ghost/core/test/e2e-api/members-comments/comments.test.js index 4c3c54707b..dac54520f1 100644 --- a/ghost/core/test/e2e-api/members-comments/comments.test.js +++ b/ghost/core/test/e2e-api/members-comments/comments.test.js @@ -7,6 +7,7 @@ const moment = require('moment-timezone'); const settingsCache = require('../../../core/shared/settings-cache'); const sinon = require('sinon'); const DomainEvents = require('@tryghost/domain-events'); +const {forEach} = require('lodash'); let membersAgent, membersAgent2, postId, postAuthorEmail, postTitle; @@ -26,6 +27,7 @@ const dbFns = { * @typedef {Object} AddCommentReplyData * @property {string} member_id * @property {string} [html='This is a reply'] + * @property {date} [created_at] */ /** * @typedef {AddCommentData & {replies: AddCommentReplyData[]}} AddCommentWithRepliesData @@ -40,7 +42,8 @@ const dbFns = { post_id: data.post_id || postId, member_id: data.member_id, parent_id: data.parent_id, - html: data.html || '
This is a comment
' + html: data.html || 'This is a comment
', + created_at: data.created_at }); }, /** @@ -511,20 +514,124 @@ describe('Comments API', function () { }); it('can show most liked comment first when order param = best', async function () { - await setupBrowseCommentsData(); - const data = await membersAgent - .get(`/api/comments/post/${postId}`); + // await setupBrowseCommentsData(); + // add another comment + await dbFns.addComment({ + html: 'This is the newest comment', + member_id: fixtureManager.get('members', 2).id, + created_at: new Date('2024-08-18') + }); + + const secondBest = await dbFns.addComment({ + member_id: fixtureManager.get('members', 0).id, + html: 'This will be the second best comment', + created_at: new Date('2022-01-01') + }); + + await dbFns.addComment({ + member_id: fixtureManager.get('members', 1).id, + created_at: new Date('2023-01-01') + }); + + const bestComment = await dbFns.addComment({ + member_id: fixtureManager.get('members', 2).id, + html: 'This will be the best comment', + created_at: new Date('2021-01-01') + }); + + const oldestComment = await dbFns.addComment({ + member_id: fixtureManager.get('members', 1).id, + html: 'ancient comment', + created_at: new Date('2019-01-01') + }); await dbFns.addLike({ - comment_id: data.body.comments[1].id, + comment_id: secondBest.id, member_id: loggedInMember.id }); + await dbFns.addLike({ + comment_id: bestComment.id, + member_id: loggedInMember.id + }); + + await dbFns.addLike({ + comment_id: bestComment.id, + member_id: fixtureManager.get('members', 0).id + }); + + await dbFns.addLike({ + comment_id: bestComment.id, + member_id: fixtureManager.get('members', 1).id + }); + const data2 = await membersAgent - .get(`/api/comments/post/${postId}/?order=best`) + .get(`/api/comments/post/${postId}/?page=1&order=best`) .expectStatus(200); - should(data2.body.comments[0].id).eql(data.body.comments[1].id); + should(data2.body.comments[0].id).eql(bestComment.id); + + // check oldest comment + should(data2.body.comments[4].id).eql(oldestComment.id); + }); + + it('checks that pagination is working when order param = best', async function () { + // create 20 comments + const postId2 = fixtureManager.get('posts', 1).id; + forEach(new Array(12).fill(0), async (item, index) => { + await dbFns.addComment({ + member_id: fixtureManager.get('members', 1).id, + html: `This is comment ${index}`, + created_at: new Date(`2021-01-${index + 1}`) + }); + }); + + const comment = await dbFns.addComment({ + member_id: fixtureManager.get('members', 0).id, + html: 'This is the best comment', + created_at: new Date('2021-01-10') + }); + + await dbFns.addLike({ + comment_id: comment.id, + member_id: loggedInMember.id + }); + + await dbFns.addLike({ + comment_id: comment.id, + member_id: fixtureManager.get('members', 1).id + }); + + const data = await membersAgent + .get(`/api/comments/post/${postId}/?limit=5&page=1&order=best`) + .expectStatus(200); + + should(data.body.comments.length).eql(5); + should(data.body.meta.pagination.total).eql(13); + should(data.body.meta.pagination.pages).eql(3); + should(data.body.meta.pagination.next).eql(2); + should(data.body.meta.pagination.prev).eql(null); + should(data.body.meta.pagination.limit).eql(5); + should(data.body.comments[0].id).eql(comment.id); + + const data2 = await membersAgent + .get(`/api/comments/post/${postId}/?limit=5&page=2&order=best`) + .expectStatus(200); + + should(data2.body.meta.pagination.next).eql(3); + should(data2.body.meta.pagination.prev).eql(1); + + // ensure data2 does not contain any of the comments from data + const ids = data.body.comments.map(com => com.id); + data2.body.comments.forEach((com) => { + should(ids.includes(com.id)).eql(false); + }); + + const data3 = await membersAgent + .get(`/api/comments/post/${postId2}/?limit=5&page=1&order=best`) + .expectStatus(200); + + should(data3.body.comments.length).eql(0); }); it('does not most liked comment first when order param and keeps normal order', async function () {