From fd91f7eebbe2360846c858aa81a752e9db6e479e Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Mon, 24 Oct 2022 11:11:44 +0200 Subject: [PATCH] Added email sent events (#15682) fixes https://github.com/TryGhost/Team/issues/2137 For the analytics page, we need the sent events to show up immediately after sending an email. Otherwise we need to wait for emails to be marked as received (which takes too long) before being able to show them on the analytics page. This adds the email_sent_event, which is hidden by default everywhere and used on the analytics page. --- .../app/components/member/activity-feed.hbs | 2 +- .../app/components/member/activity-feed.js | 3 +- .../components/posts/post-activity-feed.js | 3 +- .../admin/app/controllers/members-activity.js | 3 + .../admin/app/helpers/members-event-filter.js | 2 +- ghost/admin/app/helpers/parse-member-event.js | 4 +- .../__snapshots__/activity-feed.test.js.snap | 79 +++++++++++++++++-- .../test/e2e-api/admin/activity-feed.test.js | 29 ++++++- ghost/members-api/lib/repositories/event.js | 41 ++++++++++ 9 files changed, 151 insertions(+), 15 deletions(-) diff --git a/ghost/admin/app/components/member/activity-feed.hbs b/ghost/admin/app/components/member/activity-feed.hbs index 8c22e50d032..95d7d54747a 100644 --- a/ghost/admin/app/components/member/activity-feed.hbs +++ b/ghost/admin/app/components/member/activity-feed.hbs @@ -6,7 +6,7 @@ {{else}} - {{#let (members-event-fetcher filter=(members-event-filter member=@member.id) pageSize=5) as |eventsFetcher|}} + {{#let (members-event-fetcher filter=(members-event-filter member=@member.id excludedEvents=this.excludedEventTypes) pageSize=5) as |eventsFetcher|}}
diff --git a/ghost/admin/app/components/member/activity-feed.js b/ghost/admin/app/components/member/activity-feed.js index d9f5654a5a2..ec9e34e1483 100644 --- a/ghost/admin/app/components/member/activity-feed.js +++ b/ghost/admin/app/components/member/activity-feed.js @@ -3,6 +3,7 @@ import {action} from '@ember/object'; export default class ActivityFeed extends Component { linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions + excludedEventTypes = ['email_sent_event']; @action enterLinkURL(event) { @@ -29,4 +30,4 @@ export default class ActivityFeed extends Component { child.style.transform = 'translateX(0)'; parent.classList.remove('scroller'); } -} \ No newline at end of file +} diff --git a/ghost/admin/app/components/posts/post-activity-feed.js b/ghost/admin/app/components/posts/post-activity-feed.js index a42551abfb8..0f44846cf87 100644 --- a/ghost/admin/app/components/posts/post-activity-feed.js +++ b/ghost/admin/app/components/posts/post-activity-feed.js @@ -6,6 +6,7 @@ const allEvents = [ 'click_event', 'signup_event', 'subscription_event', + 'email_sent_event', 'email_delivered_event', 'email_opened_event', 'email_failed_event', @@ -13,7 +14,7 @@ const allEvents = [ ]; const eventTypes = { - sent: ['email_delivered_event'], + sent: ['email_sent_event'], opened: ['email_opened_event'], clicked: ['click_event'], feedback: ['feedback_event'], diff --git a/ghost/admin/app/controllers/members-activity.js b/ghost/admin/app/controllers/members-activity.js index ed22f6671af..07b9004a9a4 100644 --- a/ghost/admin/app/controllers/members-activity.js +++ b/ghost/admin/app/controllers/members-activity.js @@ -26,6 +26,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.settings.editorDefaultEmailRecipients === 'disabled') { diff --git a/ghost/admin/app/helpers/members-event-filter.js b/ghost/admin/app/helpers/members-event-filter.js index 489468a50c7..a19f8b36559 100644 --- a/ghost/admin/app/helpers/members-event-filter.js +++ b/ghost/admin/app/helpers/members-event-filter.js @@ -3,7 +3,7 @@ import classic from 'ember-classic-decorator'; import {isBlank} from '@ember/utils'; import {inject as service} from '@ember/service'; -export const EMAIL_EVENTS = ['email_delivered_event','email_opened_event','email_failed_event']; +export const EMAIL_EVENTS = ['email_sent_event', 'email_delivered_event', 'email_opened_event','email_failed_event']; export const NEWSLETTER_EVENTS = ['newsletter_event']; @classic diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 164230a8c0a..a0a904b54a3 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -72,7 +72,7 @@ export default class ParseMemberEventHelper extends Helper { icon = 'opened-email'; } - if (event.type === 'email_delivered_event') { + if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') { icon = 'received-email'; } @@ -149,7 +149,7 @@ export default class ParseMemberEventHelper extends Helper { return 'opened email'; } - if (event.type === 'email_delivered_event') { + if (event.type === 'email_delivered_event' || event.type === 'email_sent_event') { return 'received email'; } diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index e0e4be36181..7d1bf8e8cab 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -43,15 +43,35 @@ Object { "data": Any, "type": Any, }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, ], "meta": Object { "pagination": Object { - "limit": 10, + "limit": "20", "next": null, "page": null, "pages": 1, "prev": null, - "total": 10, + "total": 15, }, }, } @@ -61,7 +81,7 @@ 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": "17358", + "content-length": "23031", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -86,9 +106,9 @@ Object { "limit": "2", "next": null, "page": null, - "pages": 5, + "pages": 8, "prev": null, - "total": 10, + "total": 15, }, }, } @@ -422,6 +442,55 @@ Object { } `; +exports[`Activity Feed API Returns email sent events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 5, + }, + }, +} +`; + +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": "5774", + "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 feedback events in activity feed 1: [body] 1`] = ` Object { "events": Array [ diff --git a/ghost/core/test/e2e-api/admin/activity-feed.test.js b/ghost/core/test/e2e-api/admin/activity-feed.test.js index 1074806c545..282b97686a2 100644 --- a/ghost/core/test/e2e-api/admin/activity-feed.test.js +++ b/ghost/core/test/e2e-api/admin/activity-feed.test.js @@ -126,6 +126,26 @@ describe('Activity Feed API', function () { }); }); + it('Returns email sent events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_sent_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(5).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); + assert(!body.events.find(e => e.type !== 'email_sent_event'), 'Expected only email sent events'); + }); + }); + it('Returns email delivered events in activity feed', async function () { // Check activity feed await agent @@ -170,13 +190,13 @@ describe('Activity Feed API', function () { const postId = fixtureManager.get('posts', 0).id; await agent - .get(`/members/events?filter=data.post_id:${postId}`) + .get(`/members/events?filter=data.post_id:${postId}&limit=20`) .expectStatus(200) .matchHeaderSnapshot({ etag: anyEtag }) .matchBodySnapshot({ - events: new Array(10).fill({ + events: new Array(15).fill({ type: anyString, data: anyObject }) @@ -191,10 +211,11 @@ describe('Activity Feed API', function () { assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event'); assert(body.events.find(e => e.type === 'subscription_event'), 'Expected a subscription event'); assert(body.events.find(e => e.type === 'email_delivered_event'), 'Expected an email delivered event'); + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); 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, 10); + assert.equal(body.meta.pagination.total, 15); }); }); @@ -216,7 +237,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, 10); + assert.equal(body.meta.pagination.total, 15); }); }); }); diff --git a/ghost/members-api/lib/repositories/event.js b/ghost/members-api/lib/repositories/event.js index 52c9c28bfcf..d9ba3d4bbff 100644 --- a/ghost/members-api/lib/repositories/event.js +++ b/ghost/members-api/lib/repositories/event.js @@ -60,6 +60,7 @@ module.exports = class EventRepository { } if (this._EmailRecipient) { + pageActions.push({type: 'email_sent_event', action: 'getEmailSentEvents'}); pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'}); pageActions.push({type: 'email_opened_event', action: 'getEmailOpenedEvents'}); pageActions.push({type: 'email_failed_event', action: 'getEmailFailedEvents'}); @@ -431,6 +432,46 @@ module.exports = class EventRepository { }; } + async getEmailSentEvents(options = {}, filters = {}) { + options = { + ...options, + withRelated: ['member', 'email'], + filter: ['failed_at:null', 'processed_at:-null'] + }; + if (filters['data.created_at']) { + options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'processed_at:')); + } + if (filters['data.member_id']) { + options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:')); + } + if (filters['data.post_id']) { + options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'email.post_id:')); + } + options.filter = options.filter.join('+'); + options.order = options.order.replace(/created_at/g, 'processed_at'); + + const {data: models, meta} = await this._EmailRecipient.findPage( + options + ); + + const data = models.map((model) => { + return { + type: 'email_sent_event', + data: { + member_id: model.get('member_id'), + created_at: model.get('processed_at'), + member: model.related('member').toJSON(), + email: model.related('email').toJSON() + } + }; + }); + + return { + data, + meta + }; + } + async getEmailDeliveredEvents(options = {}, filters = {}) { options = { ...options,