0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Added clicks to activity feed (#15439)

closes https://github.com/TryGhost/Team/issues/1933

- Added click_events to activity feed
- Added support for parsing click_events in the frontend
- Moved url parsing (transform ready) to model layer of LinkRedirect
- Moved `getEventTimeline` method to the top of the event repository
- Added description field to parsed events in the frontend (because we need a second line)
- Fixed: member email not returned in comment_event
This commit is contained in:
Simon Backx 2022-09-21 10:25:51 +02:00 committed by GitHub
parent 9f0bf7e40c
commit b8041f0a60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 216 additions and 54 deletions

View file

@ -37,6 +37,11 @@
<GhEmailPreviewLink @data={{event.email}} />
{{/if}}
</span>
{{#if event.description}}
<div>
{{event.description}}
</div>
{{/if}}
</span>
</div>
<div class="gh-member-feed-time">

View file

@ -32,6 +32,11 @@
<span class="{{if (feature "memberAttribution") 'hidden'}}"><GhEmailPreviewLink @data={{event.email}} /></span>
{{/if}}
</span>
{{#if event.description}}
<div>
{{event.description}}
</div>
{{/if}}
</div>
</div>
</div>

View file

@ -11,6 +11,7 @@ export default class ParseMemberEventHelper extends Helper {
const icon = this.getIcon(event);
const action = this.getAction(event, hasMultipleNewsletters);
const info = this.getInfo(event);
const description = this.getDescription(event);
const join = this.getJoin(event);
const object = this.getObject(event);
@ -28,6 +29,7 @@ export default class ParseMemberEventHelper extends Helper {
join,
object,
info,
description,
url,
timestamp
};
@ -81,6 +83,10 @@ export default class ParseMemberEventHelper extends Helper {
icon = 'comment';
}
if (event.type === 'click_event') {
icon = 'click';
}
return 'event-' + icon + (this.feature.get('memberAttribution') ? '--feature-attribution' : '');
}
@ -148,6 +154,10 @@ export default class ParseMemberEventHelper extends Helper {
}
return 'commented';
}
if (event.type === 'click_event') {
return 'clicked email';
}
}
/**
@ -191,6 +201,12 @@ export default class ParseMemberEventHelper extends Helper {
}
}
if (event.type === 'click_event') {
if (event.data.post) {
return event.data.post.title;
}
}
return '';
}
@ -207,6 +223,23 @@ export default class ParseMemberEventHelper extends Helper {
return;
}
getDescription(event) {
if (event.type === 'click_event') {
// Clean URL
try {
const parsedURL = new URL(event.data.link.to);
// Remove protocol, querystring and hash
// + strip trailing /
return 'URL: ' + parsedURL.host + (parsedURL.pathname === '/' ? '' : parsedURL.pathname);
} catch (e) {
// Invalid URL
}
return 'URL: ' + event.data.link.to;
}
return;
}
/**
* Make the object clickable
*/
@ -217,6 +250,12 @@ export default class ParseMemberEventHelper extends Helper {
}
}
if (event.type === 'click_event') {
if (event.data.post) {
return event.data.post.url;
}
}
if (event.type === 'signup_event' || event.type === 'subscription_event') {
if (event.data.attribution && event.data.attribution.url) {
return event.data.attribution.url;

View file

@ -1,4 +1,6 @@
const mapComment = require('./comments');
const url = require('../utils/url');
const _ = require('lodash');
const commentEventMapper = (json, frame) => {
return {
@ -7,10 +9,59 @@ const commentEventMapper = (json, frame) => {
};
};
const clickEventMapper = (json, frame) => {
const memberFields = [
'id',
'uuid',
'name',
'email',
'avatar_image'
];
const linkFields = [
'from',
'to'
];
const postFields = [
'id',
'uuid',
'title',
'url'
];
const data = json.data;
const response = {};
if (data.link && data.link.post) {
// We could use the post mapper here, but we need less field + don't need al the async behavior support
url.forPost(data.link.post.id, data.link.post, frame);
response.post = _.pick(data.link.post, postFields);
}
if (data.link) {
response.link = _.pick(data.link, linkFields);
}
if (data.member) {
response.member = _.pick(data.member, memberFields);
} else {
response.member = null;
}
return {
...json,
data: response
};
};
const activityFeedMapper = (event, frame) => {
if (event.type === 'comment_event') {
return commentEventMapper(event, frame);
}
if (event.type === 'click_event') {
return clickEventMapper(event, frame);
}
return event;
};

View file

@ -18,6 +18,15 @@ const memberFields = [
'avatar_image'
];
const memberFieldsAdmin = [
'id',
'uuid',
'name',
'email',
'expertise',
'avatar_image'
];
const postFields = [
'id',
'uuid',
@ -36,7 +45,7 @@ const commentMapper = (model, frame) => {
const response = _.pick(jsonModel, commentFields);
if (jsonModel.member) {
response.member = _.pick(jsonModel.member, memberFields);
response.member = _.pick(jsonModel.member, utils.isMembersAPI(frame) ? memberFields : memberFieldsAdmin);
} else {
response.member = null;
}

View file

@ -1,10 +1,29 @@
const ghostBookshelf = require('./base');
const urlUtils = require('../../shared/url-utils');
const LinkRedirect = ghostBookshelf.Model.extend({
tableName: 'link_redirects',
post() {
return this.belongsTo('Post', 'post_id');
},
formatOnWrite(attrs) {
if (attrs.to) {
attrs.to = urlUtils.absoluteToTransformReady(attrs.to);
}
return attrs;
},
parse() {
const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments);
if (attrs.to) {
attrs.to = urlUtils.transformReadyToAbsolute(attrs.to);
}
return attrs;
}
}, {
});

View file

@ -4,17 +4,13 @@ const ObjectID = require('bson-objectid').default;
module.exports = class LinkRedirectRepository {
/** @type {Object} */
#LinkRedirect;
/** @type {Object} */
#urlUtils;
/**
* @param {object} deps
* @param {object} deps.LinkRedirect Bookshelf Model
* @param {object} deps.urlUtils
*/
constructor(deps) {
this.#LinkRedirect = deps.LinkRedirect;
this.#urlUtils = deps.urlUtils;
}
/**
@ -25,7 +21,7 @@ module.exports = class LinkRedirectRepository {
const model = await this.#LinkRedirect.add({
// Only store the parthname (no support for variable query strings)
from: linkRedirect.from.pathname,
to: this.#urlUtils.absoluteToTransformReady(linkRedirect.to.href)
to: linkRedirect.to.href
}, {});
linkRedirect.link_id = ObjectID.createFromHexString(model.id);
@ -47,7 +43,7 @@ module.exports = class LinkRedirectRepository {
return new LinkRedirect({
id: linkRedirect.id,
from: url,
to: new URL(this.#urlUtils.transformReadyToAbsolute(linkRedirect.get('to')))
to: new URL(linkRedirect.get('to'))
});
}
}

View file

@ -14,8 +14,7 @@ class LinkRedirectsServiceWrapper {
const {LinkRedirectsService} = require('@tryghost/link-redirects');
const linkRedirectRepository = new LinkRedirectRepository({
LinkRedirect: models.LinkRedirect,
urlUtils
LinkRedirect: models.LinkRedirect
});
// Expose the service

View file

@ -187,6 +187,7 @@ function createApiInstance(config) {
MemberAnalyticEvent: models.MemberAnalyticEvent,
MemberCreatedEvent: models.MemberCreatedEvent,
SubscriptionCreatedEvent: models.SubscriptionCreatedEvent,
MemberLinkClickEvent: models.MemberLinkClickEvent,
OfferRedemption: models.OfferRedemption,
Offer: models.Offer,
StripeProduct: models.StripeProduct,

View file

@ -3805,7 +3805,7 @@ 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": "1093",
"content-length": "1147",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",

View file

@ -51,6 +51,7 @@ module.exports = function MembersAPI({
MemberAnalyticEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
Offer,
OfferRedemption,
StripeProduct,
@ -110,6 +111,7 @@ module.exports = function MembersAPI({
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
Comment,
labsService,
memberAttributionService

View file

@ -11,6 +11,7 @@ module.exports = class EventRepository {
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberPaidSubscriptionEvent,
MemberLinkClickEvent,
Comment,
labsService,
memberAttributionService
@ -25,9 +26,59 @@ module.exports = class EventRepository {
this._labsService = labsService;
this._MemberCreatedEvent = MemberCreatedEvent;
this._SubscriptionCreatedEvent = SubscriptionCreatedEvent;
this._MemberLinkClickEvent = MemberLinkClickEvent;
this._memberAttributionService = memberAttributionService;
}
async getEventTimeline(options = {}) {
if (!options.limit) {
options.limit = 10;
}
// Changing this order might need a change in the query functions
// because of the different underlying models.
options.order = 'created_at desc';
// Create a list of all events that can be queried
const pageActions = [
{type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
{type: 'subscription_event', action: 'getSubscriptionEvents'},
{type: 'login_event', action: 'getLoginEvents'},
{type: 'payment_event', action: 'getPaymentEvents'},
{type: 'signup_event', action: 'getSignupEvents'},
{type: 'comment_event', action: 'getCommentEvents'}
];
if (this._labsService.isSet('emailClicks')) {
pageActions.push({type: 'click_event', action: 'getClickEvents'});
}
if (this._EmailRecipient) {
pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'});
pageActions.push({type: 'email_opened_event', action: 'getEmailOpenedEvents'});
pageActions.push({type: 'email_failed_event', action: 'getEmailFailedEvents'});
}
let filters = this.getNQLSubset(options.filter);
//Filter events to query
const filteredPages = filters.type ? pageActions.filter(page => nql(filters.type).queryJSON(page)) : pageActions;
//Start the promises
const pages = filteredPages.map(page => this[page.action](options, filters));
const allEventPages = await Promise.all(pages);
const allEvents = allEventPages.reduce((accumulator, page) => accumulator.concat(page.data), []);
return allEvents.sort((a, b) => {
return new Date(b.data.created_at) - new Date(a.data.created_at);
}).reduce((memo, event) => {
//disable the event filtering
return memo.concat(event);
}, []).slice(0, options.limit);
}
async registerPayment(data) {
await this._MemberPaymentEvent.add({
...data,
@ -276,6 +327,35 @@ module.exports = class EventRepository {
};
}
async getClickEvents(options = {}, filters = {}) {
options = {
...options,
withRelated: ['member', 'link', 'link.post'],
filter: []
};
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._MemberLinkClickEvent.findPage(options);
const data = models.map((model) => {
return {
type: 'click_event',
data: model.toJSON(options)
};
});
return {
data,
meta
};
}
async getEmailDeliveredEvents(options = {}, filters = {}) {
options = {
...options,
@ -444,50 +524,6 @@ module.exports = class EventRepository {
return result;
}
async getEventTimeline(options = {}) {
if (!options.limit) {
options.limit = 10;
}
// Changing this order might need a change in the query functions
// because of the different underlying models.
options.order = 'created_at desc';
// Create a list of all events that can be queried
const pageActions = [
{type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
{type: 'subscription_event', action: 'getSubscriptionEvents'},
{type: 'login_event', action: 'getLoginEvents'},
{type: 'payment_event', action: 'getPaymentEvents'},
{type: 'signup_event', action: 'getSignupEvents'},
{type: 'comment_event', action: 'getCommentEvents'}
];
if (this._EmailRecipient) {
pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'});
pageActions.push({type: 'email_opened_event', action: 'getEmailOpenedEvents'});
pageActions.push({type: 'email_failed_event', action: 'getEmailFailedEvents'});
}
let filters = this.getNQLSubset(options.filter);
//Filter events to query
const filteredPages = filters.type ? pageActions.filter(page => nql(filters.type).queryJSON(page)) : pageActions;
//Start the promises
const pages = filteredPages.map(page => this[page.action](options, filters));
const allEventPages = await Promise.all(pages);
const allEvents = allEventPages.reduce((accumulator, page) => accumulator.concat(page.data), []);
return allEvents.sort((a, b) => {
return new Date(b.data.created_at) - new Date(a.data.created_at);
}).reduce((memo, event) => {
//disable the event filtering
return memo.concat(event);
}, []).slice(0, options.limit);
}
async getSubscriptions() {
const results = await this._MemberSubscribeEvent.findAll({
aggregateSubscriptionDeltas: true