diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js index 957eafeec0..50029841c6 100644 --- a/ghost/admin/app/components/members-activity/event-type-filter.js +++ b/ghost/admin/app/components/members-activity/event-type-filter.js @@ -11,7 +11,8 @@ const ALL_EVENT_TYPES = [ {event: 'email_opened_event', icon: 'filter-dropdown-email-opened', name: 'Email opened', group: 'emails'}, {event: 'email_delivered_event', icon: 'filter-dropdown-email-received', name: 'Email received', group: 'emails'}, {event: 'email_complaint_event', icon: 'filter-dropdown-email-flagged-as-spam', name: 'Email flagged as spam', group: 'emails'}, - {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'} + {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}, + {event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'} ]; export default class MembersActivityEventTypeFilter extends Component { diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 0a723477d8..8a3a045633 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -114,6 +114,10 @@ export default class ParseMemberEventHelper extends Helper { icon = 'subscriptions'; } + if (event.type === 'email_change_event') { + icon = 'email-changed'; + } + return 'event-' + icon; } @@ -208,8 +212,15 @@ export default class ParseMemberEventHelper extends Helper { return 'less like this'; } + if (event.type === 'email_change_event') { + if (event.data.from_email && event.data.to_email) { + return `Email address changed from ${event.data.from_email} to ${event.data.to_email}`; + } + return 'Email address changed'; + } + if (event.type === 'donation_event') { - return `Made a one-time payment`; + return 'Made a one-time payment'; } } diff --git a/ghost/admin/public/assets/icons/event-email-changed.svg b/ghost/admin/public/assets/icons/event-email-changed.svg new file mode 100644 index 0000000000..2ed1e12e8f --- /dev/null +++ b/ghost/admin/public/assets/icons/event-email-changed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg b/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg new file mode 100644 index 0000000000..8b2fd02f2e --- /dev/null +++ b/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 447dca53c7..23a80d62ff 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -2910,12 +2910,12 @@ Object { Object { "comped": 4, "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, - "free": 4, + "free": 5, "paid": 2, }, ], "resource": "members", - "total": 10, + "total": 11, } `; @@ -6323,6 +6323,130 @@ Object { } `; +exports[`Members API can change the email address 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": "Ghost Admin", + "referrer_source": "Created manually", + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jon.snow@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "email_suppression": Object { + "info": null, + "suppressed": false, + }, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": "Jon Snow", + "newsletters": Array [], + "note": null, + "status": "free", + "subscribed": false, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API can change the email address 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": "665", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API can change the email address 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": "Ghost Admin", + "referrer_source": "Created manually", + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "aegon.targaryen@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "email_suppression": Object { + "info": null, + "suppressed": false, + }, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": "Jon Snow", + "newsletters": Array [], + "note": null, + "status": "free", + "subscribed": false, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API can change the email address 4: [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": "672", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API can change the email address 5: [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": "1118", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Members API without Stripe Add should fail when comped flag is passed in but Stripe is not enabled 1: [body] 1`] = ` Object { "errors": Array [ diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index ce1e7f7356..12f9d891cf 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -2131,6 +2131,83 @@ describe('Members API', function () { }); }); + it('can change the email address', async function () { + const memberToChange = { + name: 'Jon Snow', + email: 'jon.snow@test.com', + newsletters: [] + }; + + const memberChanged = { + email: 'aegon.targaryen@test.com' + }; + + // Create member + const {body} = await agent + .post(`/members/`) + .body({members: [memberToChange]}) + .expectStatus(201) + .matchBodySnapshot({ + members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 0)) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('members') + }); + + // Update email address + const newMember = body.members[0]; + await agent + .put(`/members/${newMember.id}/`) + .body({members: [memberChanged]}) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 0)) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Check member events + await assertMemberEvents({ + eventType: 'MemberEmailChangeEvent', + memberId: newMember.id, + asserts: [ + { + from_email: 'jon.snow@test.com', + to_email: 'aegon.targaryen@test.com' + } + ] + }); + + // Check activity feed + const {body: eventsBody} = await agent + .get(`/members/events?filter=data.member_id:'${newMember.id}'`) + .body({members: [memberChanged]}) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + const events = eventsBody.events; + + matchArrayWithoutOrder(events, [ + { + type: 'email_change_event', + data: { + from_email: 'jon.snow@test.com', + to_email: 'aegon.targaryen@test.com' + } + }, + { + type: 'signup_event' + } + ]); + }); + describe('email_disabled', function () { const testMemberId = '6543c13c13575e086a06b222'; const suppressedEmail = 'suppressed@email.com'; diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 5b4b328789..00068ed53c 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -122,7 +122,8 @@ module.exports = function MembersAPI({ EmailSpamComplaintEvent, Comment, labsService, - memberAttributionService + memberAttributionService, + MemberEmailChangeEvent }); const memberBREADService = new MemberBREADService({ diff --git a/ghost/members-api/lib/repositories/EventRepository.js b/ghost/members-api/lib/repositories/EventRepository.js index 98484299bc..eb3574e391 100644 --- a/ghost/members-api/lib/repositories/EventRepository.js +++ b/ghost/members-api/lib/repositories/EventRepository.js @@ -31,7 +31,8 @@ module.exports = class EventRepository { EmailSpamComplaintEvent, Comment, labsService, - memberAttributionService + memberAttributionService, + MemberEmailChangeEvent }) { this._DonationPaymentEvent = DonationPaymentEvent; this._MemberSubscribeEvent = MemberSubscribeEvent; @@ -48,6 +49,7 @@ module.exports = class EventRepository { this._MemberFeedback = MemberFeedback; this._EmailSpamComplaintEvent = EmailSpamComplaintEvent; this._memberAttributionService = memberAttributionService; + this._MemberEmailChangeEvent = MemberEmailChangeEvent; } async getEventTimeline(options = {}) { @@ -76,7 +78,8 @@ module.exports = class EventRepository { pageActions.push( {type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'}, {type: 'login_event', action: 'getLoginEvents'}, - {type: 'payment_event', action: 'getPaymentEvents'} + {type: 'payment_event', action: 'getPaymentEvents'}, + {type: 'email_change_event', action: 'getEmailChangeEvent'} ); } @@ -764,6 +767,38 @@ module.exports = class EventRepository { }; } + async getEmailChangeEvent(options = {}, filter) { + options = { + ...options, + withRelated: ['member'], + filter: 'custom:true', + mongoTransformer: chainTransformers( + // First set the filter manually + replaceCustomFilterTransformer(filter), + + // Map the used keys in that filter + ...mapKeys({ + 'data.created_at': 'created_at', + 'data.member_id': 'member_id' + }) + ) + }; + + const {data: models, meta} = await this._MemberEmailChangeEvent.findPage(options); + + const data = models.map((model) => { + return { + type: 'email_change_event', + data: model.toJSON(options) + }; + }); + + return { + data, + meta + }; + } + /** * Split the filter in two parts: * - One with 'type' that will be applied to all the pages