Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin-x-framework/src/api/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type Comment = {
count?: {
replies?: number;
likes?: number;
reports?: number;
};
};

Expand Down
8 changes: 8 additions & 0 deletions apps/posts/src/views/comments/comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ function buildCommentsFilter(filters: Filter[]): string | undefined {
parts.push(`member_id:${filter.values[0]}`);
}
break;

case 'reported':
if (filter.values[0] === 'true') {
parts.push('count.reports:0');
} else if (filter.values[0] === 'false') {
parts.push('count.reports:>0');
Comment on lines +71 to +74
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter logic is inverted. When the user selects 'reported: Yes' (value='true'), the code filters for count.reports:0 which means zero reports (not reported). Similarly, 'reported: No' (value='false') filters for count.reports:>0 (has reports). The conditions should be swapped: 'true' should map to count.reports:>0 and 'false' should map to count.reports:0.

Copilot uses AI. Check for mistakes.
}
break;
}
}

Expand Down
10 changes: 10 additions & 0 deletions apps/posts/src/views/comments/components/comments-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ const CommentsHeader: React.FC<CommentsHeaderProps> = ({filters, onFiltersChange
{value: 'hidden', label: 'Hidden'}
]
},
{
key: 'reported',
label: 'Reported',
type: 'select',
icon: <LucideIcon.Flag className="size-4" />,
options: [
{value: 'true', label: 'Yes'},
{value: 'false', label: 'No'}
]
},
{
key: 'created_at',
label: 'Date',
Expand Down
6 changes: 6 additions & 0 deletions apps/posts/src/views/comments/components/comments-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ function CommentsList({
) : (
<div dangerouslySetInnerHTML={{__html: item.html || ''}} className="prose block text-base" />
)}
{(item.count?.reports && item.count.reports > 0) ? (
<div className="text-amber-600 dark:text-amber-500 mt-1 flex items-center gap-1 text-sm">
<LucideIcon.Flag className="size-3.5" />
<span>{item.count.reports} {item.count.reports === 1 ? 'report' : 'reports'}</span>
</div>
) : null}
</TableCell>
<TableCell className="col-start-1 col-end-1 row-start-2 row-end-2 flex p-0 lg:table-cell lg:p-4">
{item.member?.id ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const countFields = [
'likes'
];

const countFieldsAdmin = [
'replies',
'likes',
'reports'
];

const commentMapper = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;

Expand Down Expand Up @@ -87,7 +93,7 @@ const commentMapper = (model, frame) => {
}

if (jsonModel.count) {
response.count = _.pick(jsonModel.count, countFields);
response.count = _.pick(jsonModel.count, isPublicRequest ? countFieldsAdmin : countFields);
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ternary logic is inverted. When isPublicRequest is true (members API), it uses countFieldsAdmin which includes reports. It should use countFields (without reports) for public requests and countFieldsAdmin for admin requests. Change to !isPublicRequest ? countFieldsAdmin : countFields.

Copilot uses AI. Check for mistakes.
}

if (isPublicRequest) {
Expand Down
30 changes: 29 additions & 1 deletion ghost/core/core/server/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ const Comment = ghostBookshelf.Model.extend({
});
});
}

// Filter by report count (extracted from filter in controller)
if (options.reportCount !== undefined) {
const subquery = '(SELECT COUNT(*) FROM comment_reports WHERE comment_reports.comment_id = comments.id)';
qb.whereRaw(`${subquery} ${options.reportCount.op} ?`, [options.reportCount.value]);
}
});
},

Expand Down Expand Up @@ -124,6 +130,7 @@ const Comment = ghostBookshelf.Model.extend({
orderAttributes: function orderAttributes() {
let keys = ghostBookshelf.Model.prototype.orderAttributes.call(this, arguments);
keys.push('count__likes');
keys.push('count__reports');
return keys;
},

Expand Down Expand Up @@ -241,13 +248,24 @@ const Comment = ghostBookshelf.Model.extend({
// Relations
'inReplyTo', 'member', 'count.likes', 'count.liked'
];

// Add count.reports for admin requests only
if (options.isAdmin) {
options.withRelated.push('count.reports');
}
} else {
options.withRelated = [
// Relations
'member', 'inReplyTo', 'count.replies', 'count.likes', 'count.liked',
// Replies (limited to 3)
'replies', 'replies.member', 'replies.inReplyTo', 'replies.count.likes', 'replies.count.liked'
];

// Add count.reports for admin requests only
if (options.isAdmin) {
options.withRelated.push('count.reports');
options.withRelated.push('replies.count.reports');
}
}
}

Expand All @@ -265,7 +283,8 @@ const Comment = ghostBookshelf.Model.extend({
'replies.member',
'replies.inReplyTo',
'replies.count.likes',
'replies.count.liked'
'replies.count.liked',
'replies.count.reports'
].filter(relation => (withRelated.includes(relation) || withRelated.some(r => typeof r === 'object' && r[relation])));

this.applyRepliesWithRelatedOption(relationsToLoadIndividually, options.isAdmin);
Expand Down Expand Up @@ -312,6 +331,14 @@ const Comment = ghostBookshelf.Model.extend({
// Return zero
qb.select(ghostBookshelf.knex.raw('0')).as('count__liked');
});
},
reports(modelOrCollection) {
modelOrCollection.query('columns', 'comments.*', (qb) => {
qb.count('comment_reports.id')
.from('comment_reports')
.whereRaw('comment_reports.comment_id = comments.id')
.as('count__reports');
});
}
};
},
Expand All @@ -326,6 +353,7 @@ const Comment = ghostBookshelf.Model.extend({
options.push('parentId');
options.push('isAdmin');
options.push('browseAll');
options.push('reportCount');

return options;
}
Expand Down
56 changes: 55 additions & 1 deletion ghost/core/core/server/services/comments/CommentsController.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const _ = require('lodash');
const errors = require('@tryghost/errors');
const nql = require('@tryghost/nql');
const {splitFilter} = require('@tryghost/mongo-utils');

/**
* @typedef {import('@tryghost/api-framework').Frame} Frame
Expand Down Expand Up @@ -39,6 +41,53 @@ module.exports = class CommentsController {
}
}

/**
* Extract count.reports from filter string.
* Returns the remaining filter, a reportCount option, and a mongoTransformer.
*
* @param {string} [filterString]
* @returns {{filter: string|undefined, reportCount: {op: string, value: number}|undefined, mongoTransformer: Function|undefined}}
*/
#extractReportCountFilter(filterString) {
if (!filterString || !filterString.includes('count.reports')) {
return {filter: filterString, reportCount: undefined, mongoTransformer: undefined};
}

const parsed = nql(filterString).parse();
const [countReportsFilter, remainingFilter] = splitFilter(parsed, ['count.reports']);

// Convert count.reports to reportCount option
let reportCount;
if (countReportsFilter?.['count.reports'] !== undefined) {
const val = countReportsFilter['count.reports'];
if (typeof val === 'number') {
reportCount = {op: '=', value: val};
} else if (val.$gt !== undefined) {
reportCount = {op: '>', value: val.$gt};
} else if (val.$gte !== undefined) {
reportCount = {op: '>=', value: val.$gte};
} else if (val.$lt !== undefined) {
reportCount = {op: '<', value: val.$lt};
} else if (val.$lte !== undefined) {
reportCount = {op: '<=', value: val.$lte};
} else if (val.$ne !== undefined) {
reportCount = {op: '!=', value: val.$ne};
}
}

// If there's remaining filter, use transformer to replace parsed result
// Otherwise, no filter needed
if (remainingFilter && Object.keys(remainingFilter).length > 0) {
return {
filter: filterString, // Keep original string for NQL to parse
reportCount,
mongoTransformer: () => remainingFilter // Replace with our pre-split result
};
}

return {filter: undefined, reportCount, mongoTransformer: undefined};
}

/**
* @param {Frame} frame
*/
Expand Down Expand Up @@ -106,9 +155,14 @@ module.exports = class CommentsController {
const includeNestedParam = frame.options.include_nested;
const includeNested = includeNestedParam !== 'false' && includeNestedParam !== false;

// Extract count.reports from filter (it needs special handling as raw SQL)
const {filter, reportCount, mongoTransformer} = this.#extractReportCountFilter(frame.options.filter);

return await this.service.getAdminAllComments({
includeNested,
filter: frame.options.filter,
filter,
mongoTransformer,
reportCount,
order: frame.options.order || 'created_at desc',
page: frame.options.page,
limit: frame.options.limit
Expand Down
8 changes: 6 additions & 2 deletions ghost/core/core/server/services/comments/CommentsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ class CommentsService {
* @property {boolean} includeNested - If true, include replies in flat list; if false, only top-level comments
* @property {string[]} [withRelated] - Relations to include (e.g. ['member', 'post'])
* @property {string} [filter] - NQL filter string
* @property {Function} [mongoTransformer] - Function to transform parsed NQL filter
* @property {{op: string, value: number}} [reportCount] - Filter by report count (op: '=', '>', '>=', '<', '<=', '!=')
* @property {string} order - Order string (e.g. 'created_at desc')
* @property {number} [page] - Page number
* @property {number} [limit] - Results per page
Expand All @@ -195,10 +197,12 @@ class CommentsService {
*
* @param {AdminBrowseAllOptions} options
*/
async getAdminAllComments({includeNested, filter, order, page, limit}) {
async getAdminAllComments({includeNested, filter, mongoTransformer, reportCount, order, page, limit}) {
return await this.models.Comment.findPage({
withRelated: ['member', 'post'],
withRelated: ['member', 'post', 'count.replies', 'count.likes'],
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The count.reports relation is missing from the withRelated array. The tests expect reports count to be included in admin responses, but it's not being explicitly loaded here. This should include 'count.reports' as well.

Copilot uses AI. Check for mistakes.
filter,
mongoTransformer,
reportCount,
order,
page,
limit,
Expand Down
Loading
Loading