🎨 Added 'Changed email address' event to Member Activity (#20493)

fixes https://linear.app/tryghost/issue/ENG-1256

- when a member changes their email address, surface it in Member
Activity
This commit is contained in:
Sag 2024-07-01 17:33:33 +02:00 committed by GitHub
parent fca8941740
commit 7f963e9c2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 264 additions and 7 deletions

View File

@ -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 {

View File

@ -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';
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="#6C747D" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.6 8.364v5.818c0 1.715 2.703 1.962 4.067-.356 1.157-1.963.873-4.955-.571-6.923-2.125-2.9-7.039-3.984-10.608-1.588-3.28 2.202-4.448 6.658-2.635 10.258 1.793 3.564 6.003 5.29 9.813 4.002"/>
<path stroke="#6C747D" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.52 15.23c1.701 0 3.08-1.495 3.08-3.34 0-1.846-1.379-3.341-3.08-3.341-1.702 0-3.081 1.495-3.081 3.34 0 1.846 1.38 3.342 3.08 3.342Z"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M10.736 4.806v5.058c0 1.56 2.371 1.907 3.613-.203 1.052-1.785.794-4.509-.52-6.302C11.894.722 7.422-.265 4.173 1.916 1.19 3.92.127 7.975 1.776 11.252c1.632 3.243 5.463 4.814 8.931 3.641"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M7.882 10.94c1.57 0 2.843-1.362 2.843-3.041 0-1.68-1.273-3.04-2.843-3.04S5.04 6.218 5.04 7.898c0 1.679 1.272 3.04 2.842 3.04Z"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@ -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<Array>,
"last_seen_at": null,
"name": "Jon Snow",
"newsletters": Array [],
"note": null,
"status": "free",
"subscribed": false,
"subscriptions": Any<Array>,
"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<Array>,
"last_seen_at": null,
"name": "Jon Snow",
"newsletters": Array [],
"note": null,
"status": "free",
"subscribed": false,
"subscriptions": Any<Array>,
"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 [

View File

@ -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';

View File

@ -122,7 +122,8 @@ module.exports = function MembersAPI({
EmailSpamComplaintEvent,
Comment,
labsService,
memberAttributionService
memberAttributionService,
MemberEmailChangeEvent
});
const memberBREADService = new MemberBREADService({

View File

@ -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