0
Fork 0
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:
Steve Larson 2025-02-20 14:53:37 -06:00 committed by GitHub
parent 4b2f5fc358
commit 841fac701f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 310 additions and 9 deletions

View file

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

View file

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

View file

@ -7,6 +7,7 @@
* @prop {string} batchId
* @prop {string} tierId
* @prop {string} subscriptionId
* @prop {string} attribution
* @prop {string} offerId
*/

View file

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

View file

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

View file

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