0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added events to Post Analytics page (#15886)

closes TryGhost/Team#2313
- Added Sent event to Post analytics and Members feed. Now post can be
Sent or Received or Bounced.
- Excluded Delivered event from Sent filter on backend.
This commit is contained in:
Elena Baidakova 2022-11-28 17:43:35 +04:00 committed by GitHub
parent fbf761b0ac
commit 1b784b5ec5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 152 additions and 69 deletions

View file

@ -1,9 +1,14 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ActivityFeed extends Component {
@service feature;
linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions
excludedEventTypes = ['email_sent_event', 'aggregated_click_event'];
excludedEventTypes = this.feature.get('suppressionList')
? ['aggregated_click_event']
: ['email_sent_event', 'aggregated_click_event'];
@action
enterLinkURL(event) {

View file

@ -53,6 +53,7 @@
@postId={{this.post.id}}
@eventType="sent"
@linkQuery={{hash filterParam=(concat "emails.post_id:" this.post.id) }}
@linkText="Received"
/>
</tabs.tabPanel>
@ -67,6 +68,7 @@
@postId={{this.post.id}}
@eventType="opened"
@linkQuery={{hash filterParam=(concat "opened_emails.post_id:" this.post.id) }}
@linkText="Opened"
/>
</tabs.tabPanel>
{{/if}}
@ -82,6 +84,7 @@
@postId={{this.post.id}}
@eventType="clicked"
@linkQuery={{hash filterParam=(concat "clicked_links.post_id:" this.post.id) }}
@linkText="Clicked"
/>
</tabs.tabPanel>
{{/if}}

View file

@ -85,16 +85,28 @@
<div class="gh-post-activity-feed-footer">
{{#if @linkQuery}}
<LinkTo
class="gh-post-activity-feed-pagination-link"
@route="members"
@query={{@linkQuery}}
>
{{svg-jar "filter"}}
See members
</LinkTo>
{{else}}
<div></div>
{{#if (feature "suppressionList")}}
<div class="gh-post-activity-feed-pagination-link-wrapper">
{{svg-jar "filter"}}
See members for
<LinkTo
class="gh-post-activity-feed-pagination-link"
@route="members"
@query={{@linkQuery}}
>
{{@linkText}}
</LinkTo>
</div>
{{else}}
<LinkTo
class="gh-post-activity-feed-pagination-link"
@route="members"
@query={{@linkQuery}}
>
{{svg-jar "filter"}}
See members
</LinkTo>
{{/if}}
{{/if}}
<div class="gh-post-activity-feed-pagination">

View file

@ -1,19 +1,23 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
const eventTypes = {
sent: ['email_sent_event'],
opened: ['email_opened_event'],
clicked: ['aggregated_click_event'],
feedback: ['feedback_event'],
conversion: ['subscription_event', 'signup_event']
};
import {inject as service} from '@ember/service';
export default class PostActivityFeed extends Component {
@service feature;
_pageSize = 5;
_eventTypes = {
sent: this.feature.get('suppressionList')
? ['email_sent_event', 'email_delivered_event', 'email_failed_event']
: ['email_sent_event'],
opened: ['email_opened_event'],
clicked: ['aggregated_click_event'],
feedback: ['feedback_event'],
conversion: ['subscription_event', 'signup_event']
};
get getEventTypes() {
return eventTypes[this.args.eventType];
return this._eventTypes[this.args.eventType];
}
get pageSize() {

View file

@ -10,6 +10,7 @@ export default class MembersActivityController extends Controller {
@service router;
@service settings;
@service store;
@service feature;
queryParams = ['excludedEvents', 'member'];
@ -27,8 +28,9 @@ export default class MembersActivityController extends Controller {
if (!this.member) {
hiddenEvents.push(...EMAIL_EVENTS);
} else {
// Always hide sent event
hiddenEvents.push('email_sent_event');
if (!this.feature.get('suppressionList')) {
hiddenEvents.push('email_sent_event');
}
}
hiddenEvents.push('aggregated_click_event');

View file

@ -76,8 +76,18 @@ export default class ParseMemberEventHelper extends Helper {
icon = 'opened-email';
}
if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') {
icon = 'received-email';
if (this.feature.get('suppressionList')) {
if (event.type === 'email_sent_event') {
icon = 'sent-email';
}
if (event.type === 'email_delivered_event') {
icon = 'received-email';
}
} else {
if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') {
icon = 'received-email';
}
}
if (event.type === 'email_failed_event') {
@ -157,8 +167,18 @@ export default class ParseMemberEventHelper extends Helper {
return 'opened email';
}
if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') {
return 'received email';
if (this.feature.get('suppressionList')) {
if (event.type === 'email_sent_event') {
return 'sent email';
}
if (event.type === 'email_delivered_event') {
return 'received email';
}
} else {
if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') {
return 'received email';
}
}
if (event.type === 'email_failed_event') {

View file

@ -1751,6 +1751,25 @@
filter: brightness(0.8);
}
.gh-post-activity-feed-pagination-link-wrapper {
display: flex;
align-items: center;
gap: 4px;
font-size: 1.3rem;
font-weight: 500;
line-height: 1.3;
color: #959595;
}
.gh-post-activity-feed-pagination-link-wrapper svg {
width: 15px;
height: 15px;
}
.gh-post-activity-feed-pagination-link-wrapper path {
stroke: currentColor;
}
.gh-post-activity-feed-pagination-link {
display: flex;
align-items: center;

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path d="m10.835 16.242 2.414 2.404a1.22 1.22 0 0 0 1.163.332 1.24 1.24 0 0 0 .898-.82l3.965-11.885a1.24 1.24 0 0 0-1.595-1.595L5.795 8.643a1.24 1.24 0 0 0-.82.953 1.218 1.218 0 0 0 .332 1.108l3.035 3.035-.1 3.843 2.593-1.34ZM18.917 4.926 8.339 13.743" stroke="#6C747D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View file

@ -22669,10 +22669,6 @@ Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
"meta": Object {
"pagination": Object {
@ -22681,17 +22677,29 @@ Object {
"page": null,
"pages": 1,
"prev": null,
"total": 16,
"total": 15,
},
},
}
`;
exports[`Activity Feed API Can filter events by post id 1: [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": "20329",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Activity Feed API Can filter events by post id 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": "21559",
"content-length": "20329",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -22718,7 +22726,7 @@ Object {
"page": null,
"pages": 8,
"prev": null,
"total": 16,
"total": 15,
},
},
}
@ -22915,7 +22923,7 @@ Object {
"page": null,
"pages": 2,
"prev": null,
"total": 16,
"total": 15,
},
},
}
@ -23388,9 +23396,9 @@ Object {
"limit": "36",
"next": null,
"page": null,
"pages": 2,
"pages": 1,
"prev": null,
"total": 37,
"total": 36,
},
},
}
@ -23492,7 +23500,7 @@ Object {
"page": null,
"pages": 2,
"prev": null,
"total": 13,
"total": 12,
},
},
}
@ -23521,10 +23529,6 @@ Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
"meta": Object {
"pagination": Object {
@ -23533,7 +23537,7 @@ Object {
"page": null,
"pages": 1,
"prev": null,
"total": 3,
"total": 2,
},
},
}
@ -23894,10 +23898,6 @@ Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
"meta": Object {
"pagination": Object {
@ -23906,17 +23906,29 @@ Object {
"page": null,
"pages": 1,
"prev": null,
"total": 5,
"total": 4,
},
},
}
`;
exports[`Activity Feed API Returns email sent events in activity feed 1: [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": "5004",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Activity Feed API Returns email sent events 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": "6234",
"content-length": "5004",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",

View file

@ -191,7 +191,7 @@ describe('Activity Feed API', function () {
// If that is ever fixed (it is difficult) we can update this test to not use a filter
// Same for click_event and aggregated_click_event (use same id)
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_delivered_event', 'aggregated_click_event'];
await testPagination(skippedTypes, null, 37, 36);
await testPagination(skippedTypes, null, 36, 36);
});
it('Can do filter based pagination for one post', async function () {
@ -202,7 +202,7 @@ describe('Activity Feed API', function () {
// Same for click_event and aggregated_click_event (use same id)
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_delivered_event', 'aggregated_click_event'];
await testPagination(skippedTypes, postId, 13, 10);
await testPagination(skippedTypes, postId, 12, 10);
});
it('Can do filter based pagination for aggregated clicks for one post', async function () {
@ -335,7 +335,7 @@ describe('Activity Feed API', function () {
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(5).fill({
events: new Array(4).fill({
type: anyString,
data: anyObject
})
@ -396,7 +396,7 @@ describe('Activity Feed API', function () {
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(16).fill({
events: new Array(15).fill({
type: anyString,
data: anyObject
})
@ -416,7 +416,7 @@ describe('Activity Feed API', function () {
assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event');
// Assert total is correct
assert.equal(body.meta.pagination.total, 16);
assert.equal(body.meta.pagination.total, 15);
});
});
@ -439,7 +439,7 @@ describe('Activity Feed API', function () {
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post');
// Assert total is correct
assert.equal(body.meta.pagination.total, 16);
assert.equal(body.meta.pagination.total, 15);
});
});
});

View file

@ -50,7 +50,7 @@ module.exports = class EventRepository {
if (!options.limit) {
options.limit = 10;
}
const [typeFilter, otherFilter] = this.getNQLSubset(options.filter);
// Changing this order might need a change in the query functions
@ -173,10 +173,10 @@ module.exports = class EventRepository {
options = {
...options,
withRelated: [
'member',
'subscriptionCreatedEvent.postAttribution',
'subscriptionCreatedEvent.userAttribution',
'subscriptionCreatedEvent.tagAttribution',
'member',
'subscriptionCreatedEvent.postAttribution',
'subscriptionCreatedEvent.userAttribution',
'subscriptionCreatedEvent.tagAttribution',
'subscriptionCreatedEvent.memberCreatedEvent',
// This is rediculous, but we need the tier name (we'll be able to shorten this later when we switch to the subscriptions table)
@ -208,7 +208,7 @@ module.exports = class EventRepository {
const data = models.map((model) => {
const tierName = model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null;
// Prevent toJSON on stripeSubscription (we don't have everything loaded)
delete model.relations.stripeSubscription;
const d = {
@ -298,9 +298,9 @@ module.exports = class EventRepository {
options = {
...options,
withRelated: [
'member',
'postAttribution',
'userAttribution',
'member',
'postAttribution',
'userAttribution',
'tagAttribution'
],
filter: 'subscriptionCreatedEvent.id:null+custom:true',
@ -415,15 +415,15 @@ module.exports = class EventRepository {
*/
async getAggregatedClickEvents(options = {}, filter) {
// This counts all clicks for a member for the same post
const postClickQuery = `SELECT count(distinct A.redirect_id)
FROM members_click_events A
const postClickQuery = `SELECT count(distinct A.redirect_id)
FROM members_click_events A
LEFT JOIN redirects A_r on A_r.id = A.redirect_id
LEFT JOIN redirects B_r on B_r.id = members_click_events.redirect_id
WHERE A.member_id = members_click_events.member_id AND A_r.post_id = B_r.post_id`;
// Counts all clicks for the same member, for the same post, but only preceding events. This should be zero to include the event (so we only include the first events)
const postClickQueryPreceding = `SELECT count(distinct A.redirect_id)
FROM members_click_events A
const postClickQueryPreceding = `SELECT count(distinct A.redirect_id)
FROM members_click_events A
LEFT JOIN redirects A_r on A_r.id = A.redirect_id
LEFT JOIN redirects B_r on B_r.id = members_click_events.redirect_id
WHERE A.member_id = members_click_events.member_id AND A_r.post_id = B_r.post_id AND (A.created_at < members_click_events.created_at OR (A.created_at = members_click_events.created_at AND A.id < members_click_events.id))`;
@ -499,10 +499,13 @@ module.exports = class EventRepository {
}
async getEmailSentEvents(options = {}, filter) {
const filterStr = this._labsService.isSet('suppressionList')
? 'failed_at:null+processed_at:-null+delivered_at:null+custom:true'
: 'failed_at:null+processed_at:-null+custom:true';
options = {
...options,
withRelated: ['member', 'email'],
filter: 'failed_at:null+processed_at:-null+custom:true',
filter: filterStr,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -670,7 +673,7 @@ module.exports = class EventRepository {
* Split the filter in two parts:
* - One with 'type' that will be applied to all the pages
* - Other filter that will be applied to each individual page
*
*
* Throws if splitting is not possible (e.g. OR'ing type with other filters)
*/
getNQLSubset(filter) {