0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-27 22:49:56 -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 Component from '@glimmer/component';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ActivityFeed extends Component { export default class ActivityFeed extends Component {
@service feature;
linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions 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 @action
enterLinkURL(event) { enterLinkURL(event) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1751,6 +1751,25 @@
filter: brightness(0.8); 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 { .gh-post-activity-feed-pagination-link {
display: flex; display: flex;
align-items: center; 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>, "data": Any<Object>,
"type": Any<String>, "type": Any<String>,
}, },
Object {
"data": Any<Object>,
"type": Any<String>,
},
], ],
"meta": Object { "meta": Object {
"pagination": Object { "pagination": Object {
@ -22681,17 +22677,29 @@ Object {
"page": null, "page": null,
"pages": 1, "pages": 1,
"prev": null, "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`] = ` exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "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", "content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding", "vary": "Accept-Version, Origin, Accept-Encoding",
@ -22718,7 +22726,7 @@ Object {
"page": null, "page": null,
"pages": 8, "pages": 8,
"prev": null, "prev": null,
"total": 16, "total": 15,
}, },
}, },
} }
@ -22915,7 +22923,7 @@ Object {
"page": null, "page": null,
"pages": 2, "pages": 2,
"prev": null, "prev": null,
"total": 16, "total": 15,
}, },
}, },
} }
@ -23388,9 +23396,9 @@ Object {
"limit": "36", "limit": "36",
"next": null, "next": null,
"page": null, "page": null,
"pages": 2, "pages": 1,
"prev": null, "prev": null,
"total": 37, "total": 36,
}, },
}, },
} }
@ -23492,7 +23500,7 @@ Object {
"page": null, "page": null,
"pages": 2, "pages": 2,
"prev": null, "prev": null,
"total": 13, "total": 12,
}, },
}, },
} }
@ -23521,10 +23529,6 @@ Object {
"data": Any<Object>, "data": Any<Object>,
"type": Any<String>, "type": Any<String>,
}, },
Object {
"data": Any<Object>,
"type": Any<String>,
},
], ],
"meta": Object { "meta": Object {
"pagination": Object { "pagination": Object {
@ -23533,7 +23537,7 @@ Object {
"page": null, "page": null,
"pages": 1, "pages": 1,
"prev": null, "prev": null,
"total": 3, "total": 2,
}, },
}, },
} }
@ -23894,10 +23898,6 @@ Object {
"data": Any<Object>, "data": Any<Object>,
"type": Any<String>, "type": Any<String>,
}, },
Object {
"data": Any<Object>,
"type": Any<String>,
},
], ],
"meta": Object { "meta": Object {
"pagination": Object { "pagination": Object {
@ -23906,17 +23906,29 @@ Object {
"page": null, "page": null,
"pages": 1, "pages": 1,
"prev": null, "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`] = ` exports[`Activity Feed API Returns email sent events in activity feed 2: [headers] 1`] = `
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "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", "content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding", "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 // 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) // 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']; 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 () { 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) // 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']; 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 () { 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 etag: anyEtag
}) })
.matchBodySnapshot({ .matchBodySnapshot({
events: new Array(5).fill({ events: new Array(4).fill({
type: anyString, type: anyString,
data: anyObject data: anyObject
}) })
@ -396,7 +396,7 @@ describe('Activity Feed API', function () {
etag: anyEtag etag: anyEtag
}) })
.matchBodySnapshot({ .matchBodySnapshot({
events: new Array(16).fill({ events: new Array(15).fill({
type: anyString, type: anyString,
data: anyObject 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(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event');
// Assert total is correct // 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(!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 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) { if (!options.limit) {
options.limit = 10; options.limit = 10;
} }
const [typeFilter, otherFilter] = this.getNQLSubset(options.filter); const [typeFilter, otherFilter] = this.getNQLSubset(options.filter);
// Changing this order might need a change in the query functions // Changing this order might need a change in the query functions
@ -173,10 +173,10 @@ module.exports = class EventRepository {
options = { options = {
...options, ...options,
withRelated: [ withRelated: [
'member', 'member',
'subscriptionCreatedEvent.postAttribution', 'subscriptionCreatedEvent.postAttribution',
'subscriptionCreatedEvent.userAttribution', 'subscriptionCreatedEvent.userAttribution',
'subscriptionCreatedEvent.tagAttribution', 'subscriptionCreatedEvent.tagAttribution',
'subscriptionCreatedEvent.memberCreatedEvent', '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) // 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 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; 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) // Prevent toJSON on stripeSubscription (we don't have everything loaded)
delete model.relations.stripeSubscription; delete model.relations.stripeSubscription;
const d = { const d = {
@ -298,9 +298,9 @@ module.exports = class EventRepository {
options = { options = {
...options, ...options,
withRelated: [ withRelated: [
'member', 'member',
'postAttribution', 'postAttribution',
'userAttribution', 'userAttribution',
'tagAttribution' 'tagAttribution'
], ],
filter: 'subscriptionCreatedEvent.id:null+custom:true', filter: 'subscriptionCreatedEvent.id:null+custom:true',
@ -415,15 +415,15 @@ module.exports = class EventRepository {
*/ */
async getAggregatedClickEvents(options = {}, filter) { async getAggregatedClickEvents(options = {}, filter) {
// This counts all clicks for a member for the same post // This counts all clicks for a member for the same post
const postClickQuery = `SELECT count(distinct A.redirect_id) const postClickQuery = `SELECT count(distinct A.redirect_id)
FROM members_click_events A FROM members_click_events A
LEFT JOIN redirects A_r on A_r.id = A.redirect_id 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 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`; 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) // 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) const postClickQueryPreceding = `SELECT count(distinct A.redirect_id)
FROM members_click_events A FROM members_click_events A
LEFT JOIN redirects A_r on A_r.id = A.redirect_id 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 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))`; 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) { 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 = {
...options, ...options,
withRelated: ['member', 'email'], withRelated: ['member', 'email'],
filter: 'failed_at:null+processed_at:-null+custom:true', filter: filterStr,
mongoTransformer: chainTransformers( mongoTransformer: chainTransformers(
// First set the filter manually // First set the filter manually
replaceCustomFilterTransformer(filter), replaceCustomFilterTransformer(filter),
@ -670,7 +673,7 @@ module.exports = class EventRepository {
* Split the filter in two parts: * Split the filter in two parts:
* - One with 'type' that will be applied to all the pages * - One with 'type' that will be applied to all the pages
* - Other filter that will be applied to each individual page * - Other filter that will be applied to each individual page
* *
* Throws if splitting is not possible (e.g. OR'ing type with other filters) * Throws if splitting is not possible (e.g. OR'ing type with other filters)
*/ */
getNQLSubset(filter) { getNQLSubset(filter) {