mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
🐛 Fixed missing attribution in new member and new subscription staff emails (#22252)
ref https://linear.app/ghost/issue/ENG-2030/ We had a couple spots where we could encounter race conditions with the way we emit and handle `DomainEvents` around the various subscriptions (`MemberCreatedEvent` + `SubscriptionActivatedEvent`). Instead of trying to always pull it from the created event in the db, we first try to grab it from memory/the subscription in the checkout session. This gives us more certainty before falling back to the event, in case the order is not as expected.
This commit is contained in:
parent
4b2f5fc358
commit
841fac701f
6 changed files with 310 additions and 9 deletions
|
@ -178,6 +178,19 @@ class MemberAttributionService {
|
|||
return await attribution.fetchResource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the resource for a given attribution without the use of an event model
|
||||
* @param {Object} attribution
|
||||
* @returns {Promise<import('./AttributionBuilder').AttributionResource|null>}
|
||||
*/
|
||||
async fetchResource(attribution) {
|
||||
if (!attribution) {
|
||||
return null;
|
||||
}
|
||||
const _attribution = this.attributionBuilder.build(attribution);
|
||||
return await _attribution.fetchResource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the framework context to source string
|
||||
* @param {Object} context instance of ghost framework context object
|
||||
|
|
|
@ -206,4 +206,211 @@ describe('MemberAttributionService', function () {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttribution', function () {
|
||||
it('returns attribution from builder with history', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
attributionBuilder: {
|
||||
getAttribution: async function (history) {
|
||||
should(history).have.property('length');
|
||||
return {success: true};
|
||||
}
|
||||
},
|
||||
getTrackingEnabled: () => true
|
||||
});
|
||||
const attribution = await service.getAttribution([{path: '/test'}]);
|
||||
should(attribution).deepEqual({success: true});
|
||||
});
|
||||
|
||||
it('returns empty history attribution when tracking disabled', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
attributionBuilder: {
|
||||
getAttribution: async function (history) {
|
||||
should(history).have.property('length', 0);
|
||||
return {success: true};
|
||||
}
|
||||
},
|
||||
getTrackingEnabled: () => false
|
||||
});
|
||||
const attribution = await service.getAttribution([{path: '/test'}]);
|
||||
should(attribution).deepEqual({success: true});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addPostAttributionTracking', function () {
|
||||
it('adds attribution params to url', function () {
|
||||
const service = new MemberAttributionService({});
|
||||
const url = new URL('https://example.com/test');
|
||||
const post = {id: 'post_123'};
|
||||
|
||||
const result = service.addPostAttributionTracking(url, post);
|
||||
|
||||
should(result.searchParams.get('attribution_id')).equal('post_123');
|
||||
should(result.searchParams.get('attribution_type')).equal('post');
|
||||
});
|
||||
|
||||
it('does not overwrite existing attribution params', function () {
|
||||
const service = new MemberAttributionService({});
|
||||
const url = new URL('https://example.com/test?attribution_id=existing&attribution_type=existing');
|
||||
const post = {id: 'post_123'};
|
||||
|
||||
const result = service.addPostAttributionTracking(url, post);
|
||||
|
||||
should(result.searchParams.get('attribution_id')).equal('existing');
|
||||
should(result.searchParams.get('attribution_type')).equal('existing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemberCreatedAttribution', function () {
|
||||
it('returns null when no event exists', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
models: {
|
||||
MemberCreatedEvent: {
|
||||
findOne: () => null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.getMemberCreatedAttribution('member_123');
|
||||
should(result).be.null();
|
||||
});
|
||||
|
||||
it('returns attribution from event', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
models: {
|
||||
MemberCreatedEvent: {
|
||||
findOne: () => ({
|
||||
get: function (key) {
|
||||
const values = {
|
||||
attribution_id: 'attr_123',
|
||||
attribution_url: '/test',
|
||||
attribution_type: 'post',
|
||||
referrer_source: 'source',
|
||||
referrer_medium: 'medium',
|
||||
referrer_url: 'https://referrer.com'
|
||||
};
|
||||
return values[key];
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
attributionBuilder: {
|
||||
build: function (attribution) {
|
||||
return {
|
||||
...attribution,
|
||||
fetchResource: async function () {
|
||||
return {...attribution, title: 'Fetched'};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.getMemberCreatedAttribution('member_123');
|
||||
should(result).deepEqual({
|
||||
id: 'attr_123',
|
||||
url: '/test',
|
||||
type: 'post',
|
||||
referrerSource: 'source',
|
||||
referrerMedium: 'medium',
|
||||
referrerUrl: 'https://referrer.com',
|
||||
title: 'Fetched'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptionCreatedAttribution', function () {
|
||||
it('returns null when no event exists', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
models: {
|
||||
SubscriptionCreatedEvent: {
|
||||
findOne: () => null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.getSubscriptionCreatedAttribution('subscription_123');
|
||||
should(result).be.null();
|
||||
});
|
||||
|
||||
it('returns attribution from event', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
models: {
|
||||
SubscriptionCreatedEvent: {
|
||||
findOne: () => ({
|
||||
get: function (key) {
|
||||
const values = {
|
||||
attribution_id: 'attr_123',
|
||||
attribution_url: '/test',
|
||||
attribution_type: 'post',
|
||||
referrer_source: 'source',
|
||||
referrer_medium: 'medium',
|
||||
referrer_url: 'https://referrer.com'
|
||||
};
|
||||
return values[key];
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
attributionBuilder: {
|
||||
build: function (attribution) {
|
||||
return {
|
||||
...attribution,
|
||||
fetchResource: async function () {
|
||||
return {...attribution, title: 'Fetched'};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.getSubscriptionCreatedAttribution('subscription_123');
|
||||
should(result).deepEqual({
|
||||
id: 'attr_123',
|
||||
url: '/test',
|
||||
type: 'post',
|
||||
referrerSource: 'source',
|
||||
referrerMedium: 'medium',
|
||||
referrerUrl: 'https://referrer.com',
|
||||
title: 'Fetched'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchResource', function () {
|
||||
it('returns null when no attribution provided', async function () {
|
||||
const service = new MemberAttributionService({});
|
||||
const result = await service.fetchResource(null);
|
||||
should(result).be.null();
|
||||
});
|
||||
|
||||
it('fetches resource using attribution builder', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
attributionBuilder: {
|
||||
build: function (attribution) {
|
||||
return {
|
||||
...attribution,
|
||||
fetchResource: async function () {
|
||||
return {...attribution, title: 'Fetched'};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const attribution = {
|
||||
id: 'attr_123',
|
||||
type: 'post',
|
||||
url: '/test'
|
||||
};
|
||||
|
||||
const result = await service.fetchResource(attribution);
|
||||
should(result).deepEqual({
|
||||
id: 'attr_123',
|
||||
type: 'post',
|
||||
url: '/test',
|
||||
title: 'Fetched'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
* @prop {string} batchId
|
||||
* @prop {string} tierId
|
||||
* @prop {string} subscriptionId
|
||||
* @prop {string} attribution
|
||||
* @prop {string} offerId
|
||||
*/
|
||||
|
||||
|
|
|
@ -1213,6 +1213,7 @@ module.exports = class MemberRepository {
|
|||
memberId: member.id,
|
||||
subscriptionId: subscriptionModel.get('id'),
|
||||
offerId: offerId,
|
||||
attribution: attribution,
|
||||
batchId: options.batch_id
|
||||
});
|
||||
this.dispatchEvent(activatedEvent, options);
|
||||
|
|
|
@ -96,10 +96,14 @@ class StaffService {
|
|||
|
||||
if (type === MemberCreatedEvent && member.status === 'free') {
|
||||
let attribution;
|
||||
try {
|
||||
attribution = await this.memberAttributionService.getMemberCreatedAttribution(event.data.memberId);
|
||||
} catch (e) {
|
||||
this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
|
||||
if (event.data?.attribution) {
|
||||
attribution = await this.memberAttributionService.fetchResource(event.data.attribution);
|
||||
} else {
|
||||
try {
|
||||
attribution = await this.memberAttributionService.getMemberCreatedAttribution(event.data.memberId);
|
||||
} catch (e) {
|
||||
this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
|
||||
}
|
||||
}
|
||||
await this.emails.notifyFreeMemberSignup({
|
||||
member,
|
||||
|
@ -107,10 +111,14 @@ class StaffService {
|
|||
});
|
||||
} else if (type === SubscriptionActivatedEvent) {
|
||||
let attribution;
|
||||
try {
|
||||
attribution = await this.memberAttributionService.getSubscriptionCreatedAttribution(event.data.subscriptionId);
|
||||
} catch (e) {
|
||||
this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
|
||||
if (event.data?.attribution) {
|
||||
attribution = await this.memberAttributionService.fetchResource(event.data.attribution);
|
||||
} else {
|
||||
try {
|
||||
attribution = await this.memberAttributionService.getSubscriptionCreatedAttribution(event.data.subscriptionId);
|
||||
} catch (e) {
|
||||
this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
|
||||
}
|
||||
}
|
||||
await this.emails.notifyPaidSubscriptionStarted({
|
||||
member,
|
||||
|
|
|
@ -347,6 +347,18 @@ describe('StaffService', function () {
|
|||
isSet: () => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
memberAttributionService: {
|
||||
getSubscriptionCreatedAttribution: sinon.stub().resolves(),
|
||||
getMemberCreatedAttribution: sinon.stub().resolves(),
|
||||
fetchResource: sinon.stub().callsFake((attribution) => {
|
||||
return Promise.resolve({
|
||||
title: attribution.title,
|
||||
url: attribution.url,
|
||||
type: attribution.type,
|
||||
referrerSource: attribution.referrerSource
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -358,6 +370,30 @@ describe('StaffService', function () {
|
|||
}
|
||||
});
|
||||
|
||||
service.memberAttributionService.getMemberCreatedAttribution.called.should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match({subject: '🥳 Free member signup: Jamie'})
|
||||
).should.be.true();
|
||||
});
|
||||
it('handles free member created event with provided attribution', async function () {
|
||||
await service.handleEvent(MemberCreatedEvent, {
|
||||
data: {
|
||||
source: 'member',
|
||||
memberId: 'member-1',
|
||||
attribution: {
|
||||
title: 'Welcome Post',
|
||||
url: 'https://example.com/welcome',
|
||||
type: 'post',
|
||||
referrerSource: 'Direct'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// provided attribution should be used instead of fetching it
|
||||
service.memberAttributionService.getMemberCreatedAttribution.called.should.be.false();
|
||||
service.memberAttributionService.fetchResource.called.should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match({subject: '🥳 Free member signup: Jamie'})
|
||||
).should.be.true();
|
||||
|
@ -374,11 +410,47 @@ describe('StaffService', function () {
|
|||
}
|
||||
});
|
||||
|
||||
service.memberAttributionService.getSubscriptionCreatedAttribution.called.should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match({subject: '💸 Paid subscription started: Jamie'})
|
||||
).should.be.true();
|
||||
});
|
||||
|
||||
it('handles paid member created event with provided attribution', async function () {
|
||||
await service.handleEvent(SubscriptionActivatedEvent, {
|
||||
data: {
|
||||
source: 'member',
|
||||
memberId: 'member-1',
|
||||
subscriptionId: 'sub-1',
|
||||
offerId: 'offer-1',
|
||||
tierId: 'tier-1',
|
||||
attribution: {
|
||||
title: 'Welcome Post',
|
||||
url: 'https://example.com/welcome',
|
||||
type: 'post',
|
||||
referrerSource: 'Direct'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// provided attribution should be used instead of fetching it
|
||||
service.memberAttributionService.getSubscriptionCreatedAttribution.called.should.be.false();
|
||||
service.memberAttributionService.fetchResource.called.should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match({subject: '💸 Paid subscription started: Jamie'})
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match.has('html', sinon.match('Welcome Post'))
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match.has('html', sinon.match('Direct'))
|
||||
).should.be.true();
|
||||
});
|
||||
|
||||
it('handles paid member cancellation event', async function () {
|
||||
await service.handleEvent(SubscriptionCancelledEvent, {
|
||||
data: {
|
||||
|
@ -523,7 +595,6 @@ describe('StaffService', function () {
|
|||
tier = {
|
||||
name: 'Test Tier'
|
||||
};
|
||||
|
||||
subscription = {
|
||||
amount: 5000,
|
||||
currency: 'USD',
|
||||
|
|
Loading…
Add table
Reference in a new issue