Skip to content

Commit

Permalink
Enhanced Comments Ordering for Best Liked Sorting (#21473)
Browse files Browse the repository at this point in the history
ref PLG-220

- Improved `getBestComments` service to paginate correctly since we're
using a custom query to determine the top comments that goes beyond the
scope of what `findPage` is capable of.
- Updated CommentsController and CommentsService to support custom order
parameters.
- Added tests
  • Loading branch information
ronaldlangeveld authored Oct 31, 2024
1 parent fe9b019 commit fd18a39
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 33 deletions.
45 changes: 19 additions & 26 deletions ghost/core/core/server/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
];
}
}
Expand All @@ -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) {
Expand All @@ -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;
},

Expand Down Expand Up @@ -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;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
54 changes: 54 additions & 0 deletions ghost/core/core/server/services/comments/CommentsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 114 additions & 7 deletions ghost/core/test/e2e-api/members-comments/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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 || '<p>This is a comment</p>'
html: data.html || '<p>This is a comment</p>',
created_at: data.created_at
});
},
/**
Expand Down Expand Up @@ -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(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(data2.body.comments[0].id).eql(data.body.comments[1].id);
should(data3.body.comments.length).eql(0);
});

it('does not most liked comment first when order param and keeps normal order', async function () {
Expand Down

0 comments on commit fd18a39

Please sign in to comment.