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:
parent
fbf761b0ac
commit
1b784b5ec5
11 changed files with 152 additions and 69 deletions
|
@ -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) {
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
|
|
3
ghost/admin/public/assets/icons/event-sent-email.svg
Normal file
3
ghost/admin/public/assets/icons/event-sent-email.svg
Normal 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 |
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Reference in a new issue