Skip to content

Commit ae8fbf2

Browse files
authored
✨ Added Admin API endpoint for browsing all comments (#25700)
ref https://linear.app/ghost/issue/FEA-485 Adds GET /api/admin/comments/ endpoint for browsing all comments across posts, supporting filtering, pagination, and include_nested parameter.
1 parent 577f150 commit ae8fbf2

File tree

10 files changed

+1990
-111
lines changed

10 files changed

+1990
-111
lines changed

ghost/core/core/server/api/endpoints/comments.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ const controller = {
9898
return result;
9999
}
100100
},
101+
browseAll: {
102+
headers: {
103+
cacheInvalidate: false
104+
},
105+
options: [
106+
'page',
107+
'limit',
108+
'filter',
109+
'order',
110+
'include_nested'
111+
],
112+
validation: {},
113+
permissions: {
114+
method: 'browse'
115+
},
116+
async query(frame) {
117+
const result = await commentsService.controller.adminBrowseAll(frame);
118+
return result;
119+
}
120+
},
101121
add: {
102122
statusCode: 201,
103123
headers: {

ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext');
55

66
const commentFields = [
77
'id',
8+
'parent_id',
89
'in_reply_to_id',
910
'in_reply_to_snippet',
1011
'status',
@@ -47,6 +48,25 @@ const commentMapper = (model, frame) => {
4748

4849
const isPublicRequest = utils.isMembersAPI(frame);
4950

51+
// Deleted comments are tombstones - just enough info to show "Reply to deleted comment"
52+
if (jsonModel.status === 'deleted') {
53+
const tombstone = {
54+
id: jsonModel.id,
55+
parent_id: jsonModel.parent_id || null,
56+
status: 'deleted'
57+
};
58+
// Include post relation if loaded
59+
if (jsonModel.post) {
60+
url.forPost(jsonModel.post.id, jsonModel.post, frame);
61+
tombstone.post = _.pick(jsonModel.post, postFields);
62+
}
63+
// Include replies if present (for tree structure), recursively mapped
64+
if (jsonModel.replies) {
65+
tombstone.replies = jsonModel.replies.map(reply => commentMapper(reply, frame));
66+
}
67+
return tombstone;
68+
}
69+
5070
if (jsonModel.inReplyTo && (jsonModel.inReplyTo.status === 'published' || (!isPublicRequest && jsonModel.inReplyTo.status === 'hidden'))) {
5171
jsonModel.in_reply_to_snippet = htmlToPlaintext.commentSnippet(jsonModel.inReplyTo.html);
5272
} else if (jsonModel.inReplyTo && jsonModel.inReplyTo.status !== 'published') {

ghost/core/core/server/services/comments/CommentsController.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ module.exports = class CommentsController {
9292
return await this.service.getAdminComments(frame.options);
9393
}
9494

95+
/**
96+
* Browse all comments across the site (admin only, no post_id required)
97+
* Used for admin moderation page showing comments from all posts
98+
*
99+
* Controller responsibility: Parse frame into clean domain parameters.
100+
* The frame should not pass beyond this layer.
101+
*
102+
* @param {Frame} frame
103+
*/
104+
async adminBrowseAll(frame) {
105+
// Query params can be strings or booleans depending on how the request is made
106+
const includeNestedParam = frame.options.include_nested;
107+
const includeNested = includeNestedParam !== 'false' && includeNestedParam !== false;
108+
109+
return await this.service.getAdminAllComments({
110+
includeNested,
111+
filter: frame.options.filter,
112+
order: frame.options.order || 'created_at desc',
113+
page: frame.options.page,
114+
limit: frame.options.limit
115+
});
116+
}
117+
95118
/**
96119
* @param {Frame} frame
97120
*/

ghost/core/core/server/services/comments/CommentsService.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,39 @@ class CommentsService {
176176
return page;
177177
}
178178

179+
/**
180+
* @typedef {Object} AdminBrowseAllOptions
181+
* @property {boolean} includeNested - If true, include replies in flat list; if false, only top-level comments
182+
* @property {string[]} [withRelated] - Relations to include (e.g. ['member', 'post'])
183+
* @property {string} [filter] - NQL filter string
184+
* @property {string} order - Order string (e.g. 'created_at desc')
185+
* @property {number} [page] - Page number
186+
* @property {number} [limit] - Results per page
187+
*/
188+
189+
/**
190+
* Browse all comments across the site for admin moderation.
191+
* Does not check if comments are enabled - admins can moderate existing comments.
192+
*
193+
* Service responsibility: Business logic and data access.
194+
* Receives clean, typed parameters - no frame/HTTP knowledge.
195+
*
196+
* @param {AdminBrowseAllOptions} options
197+
*/
198+
async getAdminAllComments({includeNested, filter, order, page, limit}) {
199+
return await this.models.Comment.findPage({
200+
withRelated: ['member', 'post'],
201+
filter,
202+
order,
203+
page,
204+
limit,
205+
// If includeNested is false, only return top-level comments
206+
parentId: includeNested ? undefined : null,
207+
// Admin context: see hidden comments with full content, and all statuses including deleted
208+
isAdmin: true
209+
});
210+
}
211+
179212
async getAdminComments(options) {
180213
this.checkEnabled();
181214
const page = await this.models.Comment.findPage({...options, parentId: null});

ghost/core/core/server/web/api/endpoints/admin/routes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ module.exports = function apiRoutes() {
4141

4242
router.get('/mentions', mw.authAdminApi, http(api.mentions.browse));
4343

44+
// Comments - browseAll must come before :id routes
45+
router.get('/comments', mw.authAdminApi, http(api.comments.browseAll));
4446
router.get('/comments/:id', mw.authAdminApi, http(api.commentReplies.read));
4547
router.get('/comments/:id/replies', mw.authAdminApi, http(api.commentReplies.browse));
4648
router.get('/comments/post/:post_id', mw.authAdminApi, http(api.comments.browse));

ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
2269922699
Object {
2270022700
"access-control-allow-origin": "http://127.0.0.1:2369",
2270122701
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
22702-
"content-length": "18117",
22702+
"content-length": "18190",
2270322703
"content-type": "application/json; charset=utf-8",
2270422704
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
2270522705
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -22871,7 +22871,7 @@ exports[`Activity Feed API Filter splitting Can use NQL OR for type only 2: [hea
2287122871
Object {
2287222872
"access-control-allow-origin": "http://127.0.0.1:2369",
2287322873
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
22874-
"content-length": "5279",
22874+
"content-length": "5352",
2287522875
"content-type": "application/json; charset=utf-8",
2287622876
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
2287722877
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -23987,7 +23987,7 @@ exports[`Activity Feed API Returns comments in activity feed 2: [headers] 1`] =
2398723987
Object {
2398823988
"access-control-allow-origin": "http://127.0.0.1:2369",
2398923989
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
23990-
"content-length": "1385",
23990+
"content-length": "1458",
2399123991
"content-type": "application/json; charset=utf-8",
2399223992
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
2399323993
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

0 commit comments

Comments
 (0)