Added comment events to activity feed (#15064)

refs https://github.com/TryGhost/Team/issues/1709

- New event type `comment_event` (comments and replies of a member in the activity feed)
- Includes member, post and parent relation by default
- Added new output mapper for ActivityFeed events

**Changes to `Comment` model:**
* **Only limit comment fetched to root comments when not authenticated as a user:** 
`enforcedFilters` is applied to all queries, which is a problem because for the activity feed we also need to fetch comments which have a parent_id that is not null (`Member x replied to a comment`). The current filter in the model is specifically for the members API, not the admin API (so checking the user should fix that, not sure if that is a good pattern but couldn’t find a better alternative).
* **Only set default relations for comments when withRelated is empty or not set:**
`defaultRelations`: Right now, for every fetch it would force all these relations. But we don’t need all those relations for the activity feed; So I updated the pattern to only set the default relations when it is empty (which we also do on a couple of other places and seems like a good pattern). I also updated the comments-ui frontend to not send ?include
This commit is contained in:
Simon Backx 2022-07-25 17:48:23 +02:00 committed by GitHub
parent 31a4135fec
commit 5235d67fed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 156 additions and 8 deletions

View File

@ -0,0 +1,17 @@
const mapComment = require('./comments');
const commentEventMapper = (json, frame) => {
return {
...json,
data: mapComment(json.data, frame)
};
};
const activityFeedMapper = (event, frame) => {
if (event.type === 'comment_event') {
return commentEventMapper(event, frame);
}
return event;
};
module.exports = activityFeedMapper;

View File

@ -1,4 +1,5 @@
const _ = require('lodash');
const url = require('../utils/url');
const commentFields = [
'id',
@ -16,6 +17,13 @@ const memberFields = [
'avatar_image'
];
const postFields = [
'id',
'uuid',
'title',
'url'
];
const commentMapper = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
@ -37,6 +45,16 @@ const commentMapper = (model, frame) => {
response.replies = jsonModel.replies.map(reply => commentMapper(reply, frame));
}
if (jsonModel.parent) {
response.parent = commentMapper(jsonModel.parent, frame);
}
if (jsonModel.post) {
// We could use the post mapper here, but we need less field + don't need al the async behaviour support
url.forPost(jsonModel.post.id, jsonModel.post, frame);
response.post = _.pick(jsonModel.post, postFields);
}
// todo
response.liked = false;
if (jsonModel.likes && frame.original.context.member && frame.original.context.member.id) {

View File

@ -1,5 +1,6 @@
module.exports = {
actions: require('./actions'),
activityFeedEvents: require('./activity-feed-events'),
authors: require('./authors'),
comments: require('./comments'),
emails: require('./emails'),

View File

@ -1,6 +1,7 @@
//@ts-check
const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:members');
const {unparse} = require('@tryghost/members-csv');
const mappers = require('./mappers');
module.exports = {
browse: createSerializer('browse', paginatedMembers),
@ -18,7 +19,7 @@ module.exports = {
importCSV: createSerializer('importCSV', passthrough),
memberStats: createSerializer('memberStats', passthrough),
mrrStats: createSerializer('mrrStats', passthrough),
activityFeed: createSerializer('activityFeed', passthrough)
activityFeed: createSerializer('activityFeed', activityFeed)
};
/**
@ -73,6 +74,16 @@ function bulkAction(bulkActionResult, _apiConfig, frame) {
};
}
/**
*
* @returns {{events: any[]}}
*/
function activityFeed(data, _apiConfig, frame) {
return {
events: data.events.map(e => mappers.activityFeedEvents(e, frame))
};
}
/**
* @template PageMeta
*

View File

@ -72,7 +72,11 @@ const Comment = ghostBookshelf.Model.extend({
model.emitChange('added', options);
},
enforcedFilters: function enforcedFilters() {
enforcedFilters: function enforcedFilters(options) {
if (options.context && options.context.user) {
return null;
}
return 'parent_id:null';
}
@ -146,7 +150,9 @@ const Comment = ghostBookshelf.Model.extend({
defaultRelations: function defaultRelations(methodName, options) {
// @todo: the default relations are not working for 'add' when we add it below
if (['findAll', 'findPage', 'edit', 'findOne'].indexOf(methodName) !== -1) {
options.withRelated = _.union(['member', 'likes', 'replies', 'replies.member', 'replies.likes'], options.withRelated || []);
if (!options.withRelated || options.withRelated.length === 0) {
options.withRelated = ['member', 'likes', 'replies', 'replies.member', 'replies.likes'];
}
}
return options;

View File

@ -189,7 +189,8 @@ function createApiInstance(config) {
StripeProduct: models.StripeProduct,
StripePrice: models.StripePrice,
Product: models.Product,
Settings: models.Settings
Settings: models.Settings,
Comment: models.Comment
},
stripeAPIService: stripeService.api,
offersAPI: offersService.api,

View File

@ -3278,6 +3278,45 @@ Object {
}
`;
exports[`Members API Returns comments in activity feed 1: [body] 1`] = `
Object {
"events": Array [
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
}
`;
exports[`Members API Returns comments in activity feed 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3484",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Search by case-insensitive email MEMBER2 receives member with email member2@test.com 1: [body] 1`] = `
Object {
"members": Array [

View File

@ -1,5 +1,5 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, anyISODate, anyString, anyArray, anyLocationFor, anyErrorId} = matchers;
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, anyISODate, anyString, anyArray, anyLocationFor, anyErrorId, anyObject} = matchers;
const ObjectId = require('bson-objectid');
const assert = require('assert');
@ -155,7 +155,7 @@ describe('Members API', function () {
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters', 'members:newsletters');
await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments');
await agent.loginAsOwner();
newsletters = await getNewsletters();
@ -170,6 +170,26 @@ describe('Members API', function () {
mockManager.restore();
});
// Activity feed
it('Returns comments in activity feed', async function () {
// Check activity feed
await agent
.get(`/members/events`)
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(5).fill({
type: anyString,
data: anyObject
})
})
.expect(({body}) => {
should(body.events.find(e => e.type === 'comment_event')).not.be.undefined();
});
});
// List Members
it('Can browse', async function () {

View File

@ -316,6 +316,7 @@ module.exports = {
anyBoolean: any(Boolean),
anyString: any(String),
anyArray: any(Array),
anyObject: any(Object),
anyNumber: any(Number),
anyStringNumber: stringMatching(/\d+/),
anyISODateTime: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z/),

View File

@ -53,7 +53,8 @@ module.exports = function MembersAPI({
StripeProduct,
StripePrice,
Product,
Settings
Settings,
Comment
},
stripeAPIService,
offersAPI,
@ -103,6 +104,7 @@ module.exports = function MembersAPI({
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
Comment,
labsService
});

View File

@ -9,6 +9,7 @@ module.exports = class EventRepository {
MemberStatusEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
Comment,
labsService
}) {
this._MemberSubscribeEvent = MemberSubscribeEvent;
@ -17,6 +18,7 @@ module.exports = class EventRepository {
this._MemberStatusEvent = MemberStatusEvent;
this._MemberLoginEvent = MemberLoginEvent;
this._EmailRecipient = EmailRecipient;
this._Comment = Comment;
this._labsService = labsService;
}
@ -175,6 +177,35 @@ module.exports = class EventRepository {
};
}
async getCommentEvents(options = {}, filters = {}) {
options = {
...options,
withRelated: ['member', 'post', 'parent'],
filter: ['member_id:-null']
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._Comment.findPage(options);
const data = models.map((model) => {
return {
type: 'comment_event',
data: model.toJSON(options)
};
});
return {
data,
meta
};
}
async getEmailDeliveredEvents(options = {}, filters = {}) {
options = {
...options,
@ -358,7 +389,8 @@ module.exports = class EventRepository {
{type: 'subscription_event', action: 'getSubscriptionEvents'},
{type: 'login_event', action: 'getLoginEvents'},
{type: 'payment_event', action: 'getPaymentEvents'},
{type: 'signup_event', action: 'getSignupEvents'}
{type: 'signup_event', action: 'getSignupEvents'},
{type: 'comment_event', action: 'getCommentEvents'}
];
if (this._EmailRecipient) {
pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'});