From 0943daad720534e4ca0fc834644cc8b250aa0eef Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Fri, 19 Aug 2022 22:39:18 +0200 Subject: [PATCH] Added member attribution to member details page (#15266) refs https://github.com/TryGhost/Team/issues/1817 Co-authored-by: James Morris --- .../app/components/gh-member-details.hbs | 8 +- .../components/gh-member-settings-form.hbs | 13 ++- ghost/admin/app/models/member.js | 1 + ghost/admin/app/styles/layouts/members.css | 28 +++++- .../utils/serializers/output/members.js | 3 +- .../services/member-attribution/index.js | 3 + ghost/core/test/e2e-api/admin/members.test.js | 6 ++ .../services/member-attribution.test.js | 44 ++++++++- ghost/member-attribution/lib/attribution.js | 96 ++++++++++++++++--- ghost/member-attribution/lib/service.js | 48 +++++++++- .../member-attribution/lib/url-translator.js | 57 ++++++++++- .../test/attribution.test.js | 52 ++++++++-- .../test/url-translator.test.js | 95 ++++++++++++++++++ ghost/members-api/lib/MembersAPI.js | 3 +- .../members-api/lib/services/member-bread.js | 38 +++++++- 15 files changed, 460 insertions(+), 35 deletions(-) diff --git a/ghost/admin/app/components/gh-member-details.hbs b/ghost/admin/app/components/gh-member-details.hbs index 6b80ed5af5..c70d3f2219 100644 --- a/ghost/admin/app/components/gh-member-details.hbs +++ b/ghost/admin/app/components/gh-member-details.hbs @@ -46,6 +46,12 @@ {{svg-jar "member-add"}} Created on {{moment-format (moment-site-tz @member.createdAtUTC) "D MMM YYYY"}}

+ {{#if (and @member.attribution @member.attribution.url @member.attribution.title) }} +

+ {{svg-jar "satellite"}} + {{ @member.attribution.title }} +

+ {{/if}}

{{svg-jar "eye"}} {{#if (not (is-empty @member.lastSeenAtUTC))}} @@ -94,4 +100,4 @@ {{/if}} {{/unless}} - \ No newline at end of file + diff --git a/ghost/admin/app/components/gh-member-settings-form.hbs b/ghost/admin/app/components/gh-member-settings-form.hbs index cf4e301e59..fbb15fb806 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -162,8 +162,8 @@ Expires {{sub.compExpiry}} Active {{else if sub.trialUntil}} - Ends {{sub.trialUntil}} - Active + Ends {{sub.trialUntil}} + Active {{else}} Renews {{sub.validUntil}} Active @@ -192,8 +192,13 @@ {{/if}} {{/if}} -

- Created on {{sub.startDate}} +
+ Created on {{sub.startDate}} + {{#if (and sub.attribution sub.attribution.url sub.attribution.title) }} + · + Attributed to {{ sub.attribution.title }} + {{/if}} +
diff --git a/ghost/admin/app/models/member.js b/ghost/admin/app/models/member.js index 66f164fe7d..41aebbdd94 100644 --- a/ghost/admin/app/models/member.js +++ b/ghost/admin/app/models/member.js @@ -13,6 +13,7 @@ export default Model.extend(ValidationEngine, { createdAtUTC: attr('moment-utc'), lastSeenAtUTC: attr('moment-utc'), subscriptions: attr('member-subscription'), + attribution: attr(), subscribed: attr('boolean', {defaultValue: true}), comped: attr('boolean', {defaultValue: false}), geolocation: attr('json-string'), diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 1aa3a99c12..f0b59f7699 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -707,6 +707,8 @@ label[for="member-description"] + p { .gh-member-details-meta p { display: flex; align-items: center; + white-space: nowrap; + min-width: 0; } .gh-member-details-meta .gh-member-last-seen { @@ -717,6 +719,16 @@ label[for="member-description"] + p { width: 1.6rem; height: 1.6rem; margin-right: .8rem; + flex-shrink: 0; +} + +/* WIP style for attribution link*/ +.gh-member-details-meta p a { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-decoration: underline; + margin-left: 2px; } .gh-member-details-meta svg path { @@ -2143,9 +2155,23 @@ p.gh-members-import-errordetail:first-of-type { border-top: 1px solid var(--whitegrey); } -.gh-membertier-created { +.gh-membertier-created, +.gh-membertier-started { color: var(--midgrey-d1); font-size: 1.25rem; + font-weight: 500; +} + +.gh-membertier-started a { + color: var(--black); + font-weight: 500; +} + +.gh-membertier-separator { + color: var(--midgrey-d1); + font-size: 1.25rem; + font-weight: 700; + margin: 0 0.25em; } .gh-membertier-archived .gh-membertier-name { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js index ae84496174..58651a4c68 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js @@ -128,7 +128,8 @@ function serializeMember(member, options) { email_open_rate: json.email_open_rate, email_recipients: json.email_recipients, status: json.status, - last_seen_at: json.last_seen_at + last_seen_at: json.last_seen_at, + attribution: json.attribution }; if (json.products) { diff --git a/ghost/core/core/server/services/member-attribution/index.js b/ghost/core/core/server/services/member-attribution/index.js index e9bd5a8737..14159af0c0 100644 --- a/ghost/core/core/server/services/member-attribution/index.js +++ b/ghost/core/core/server/services/member-attribution/index.js @@ -13,6 +13,9 @@ class MemberAttributionServiceWrapper { // For now we don't need to expose anything (yet) this.service = new MemberAttributionService({ + Post: models.Post, + User: models.User, + Tag: models.Tag, MemberCreatedEvent: models.MemberCreatedEvent, SubscriptionCreatedEvent: models.SubscriptionCreatedEvent, urlService, diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 61bd25449d..44155ef1f5 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -123,6 +123,9 @@ describe('Members API without Stripe', function () { beforeEach(function () { mockManager.mockMail(); + + // For some reason it is enabled by default? + mockManager.mockLabsDisabled('memberAttribution'); }); afterEach(function () { @@ -164,6 +167,9 @@ describe('Members API', function () { beforeEach(function () { mockManager.mockStripe(); mockManager.mockMail(); + + // For some reason it is enabled by default? + mockManager.mockLabsDisabled('memberAttribution'); }); afterEach(function () { diff --git a/ghost/core/test/e2e-server/services/member-attribution.test.js b/ghost/core/test/e2e-server/services/member-attribution.test.js index e8f2f4c7c7..4297ec54ac 100644 --- a/ghost/core/test/e2e-server/services/member-attribution.test.js +++ b/ghost/core/test/e2e-server/services/member-attribution.test.js @@ -30,11 +30,20 @@ describe('Member Attribution Service', function () { time: 123 } ]); - attribution.should.eql(({ + attribution.should.match(({ id: post.id, url, type: 'post' })); + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + (await attribution.getResource()).should.match(({ + id: post.id, + url: absoluteUrl, + type: 'post', + title: post.get('title') + })); }); it('resolves pages', async function () { @@ -50,11 +59,20 @@ describe('Member Attribution Service', function () { time: 123 } ]); - attribution.should.eql(({ + attribution.should.match(({ id: post.id, url, type: 'page' })); + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + (await attribution.getResource()).should.match(({ + id: post.id, + url: absoluteUrl, + type: 'page', + title: post.get('title') + })); }); it('resolves tags', async function () { @@ -68,11 +86,20 @@ describe('Member Attribution Service', function () { time: 123 } ]); - attribution.should.eql(({ + attribution.should.match(({ id: tag.id, url, type: 'tag' })); + + const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true}); + + (await attribution.getResource()).should.match(({ + id: tag.id, + url: absoluteUrl, + type: 'tag', + title: tag.get('name') + })); }); it('resolves authors', async function () { @@ -86,11 +113,20 @@ describe('Member Attribution Service', function () { time: 123 } ]); - attribution.should.eql(({ + attribution.should.match(({ id: author.id, url, type: 'author' })); + + const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true}); + + (await attribution.getResource()).should.match(({ + id: author.id, + url: absoluteUrl, + type: 'author', + title: author.get('name') + })); }); }); }); diff --git a/ghost/member-attribution/lib/attribution.js b/ghost/member-attribution/lib/attribution.js index ea1b8703d3..10958a6d48 100644 --- a/ghost/member-attribution/lib/attribution.js +++ b/ghost/member-attribution/lib/attribution.js @@ -1,10 +1,71 @@ /** - * @typedef {object} Attribution - * @prop {string|null} [id] - * @prop {string|null} [url] - * @prop {string} [type] + * @typedef {object} AttributionResource + * @prop {string|null} id + * @prop {string|null} url + * @prop {'page'|'post'|'author'|'tag'|'url'} type + * @prop {string|null} title */ +class Attribution { + #urlTranslator; + + /** + * @param {object} data + * @param {string|null} [data.id] + * @param {string|null} [data.url] + * @param {'page'|'post'|'author'|'tag'|'url'} [data.type] + */ + constructor({id, url, type}, {urlTranslator}) { + /** @type {string|null} */ + this.id = id; + + /** @type {string|null} */ + this.url = url; + + /** @type {'page'|'post'|'author'|'tag'|'url'} */ + this.type = type; + + /** + * @private + */ + this.#urlTranslator = urlTranslator; + } + + /** + * Convert the instance to a parsed instance with more information about the resource included. + * It does: + * - Fetch the resource and add some information about it to the attribution + * - If the resource exists and have a new url, it updates the url if possible + * - Returns an absolute URL instead of a relative one + * @returns {Promise} + */ + async getResource() { + if (!this.id || this.type === 'url' || !this.type) { + return { + id: null, + type: 'url', + // TODO: make url absolute + url: this.url, + title: this.url + }; + } + + const resource = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true}); + + if (resource) { + return resource; + } + + return { + id: null, + type: 'url', + // TODO: make url absolute + url: this.url, + title: this.url + }; + } +} + /** * Convert a UrlHistory to an attribution object */ @@ -15,6 +76,17 @@ class AttributionBuilder { this.urlTranslator = urlTranslator; } + /** + * Creates an Attribution object with the dependencies injected + */ + build({id, url, type}) { + return new Attribution({ + id, + url, + type + }, {urlTranslator: this.urlTranslator}); + } + /** * Last Post Algorithm™️ * @param {UrlHistory} history @@ -22,11 +94,11 @@ class AttributionBuilder { */ getAttribution(history) { if (history.length === 0) { - return { + return this.build({ id: null, url: null, type: null - }; + }); } // TODO: if something is wrong with the attribution script, and it isn't loading @@ -38,10 +110,10 @@ class AttributionBuilder { const typeId = this.urlTranslator.getTypeAndId(item.path); if (typeId && typeId.type === 'post') { - return { + return this.build({ url: item.path, ...typeId - }; + }); } } @@ -51,20 +123,20 @@ class AttributionBuilder { const typeId = this.urlTranslator.getTypeAndId(item.path); if (typeId) { - return { + return this.build({ url: item.path, ...typeId - }; + }); } } // Default to last URL // In the future we might decide to exclude certain URLs, that can happen here - return { + return this.build({ id: null, url: history.last.path, type: 'url' - }; + }); } } diff --git a/ghost/member-attribution/lib/service.js b/ghost/member-attribution/lib/service.js index 9878d778e0..9be2496dc4 100644 --- a/ghost/member-attribution/lib/service.js +++ b/ghost/member-attribution/lib/service.js @@ -5,11 +5,19 @@ const AttributionBuilder = require('./attribution'); const UrlHistory = require('./history'); class MemberAttributionService { - constructor({MemberCreatedEvent, SubscriptionCreatedEvent, urlService, labsService}) { + constructor({Post, User, Tag, MemberCreatedEvent, SubscriptionCreatedEvent, urlService, labsService}) { const eventHandler = new MemberAttributionEventHandler({MemberCreatedEvent, SubscriptionCreatedEvent, DomainEvents, labsService}); eventHandler.subscribe(); - const urlTranslator = new UrlTranslator({urlService}); + this.urlService = urlService; + this.models = {MemberCreatedEvent, SubscriptionCreatedEvent}; + + const urlTranslator = new UrlTranslator({ + urlService, + models: { + Post, User, Tag + } + }); this.attributionBuilder = new AttributionBuilder({urlTranslator}); } @@ -22,6 +30,42 @@ class MemberAttributionService { const history = new UrlHistory(historyArray); return this.attributionBuilder.getAttribution(history); } + + /** + * Returns the parsed attribution for a member creation event + * @param {string} memberId + * @returns {Promise} + */ + async getMemberCreatedAttribution(memberId) { + const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false}); + if (!memberCreatedEvent || !memberCreatedEvent.get('attribution_type')) { + return null; + } + const attribution = this.attributionBuilder.build({ + id: memberCreatedEvent.get('attribution_id'), + url: memberCreatedEvent.get('attribution_url'), + type: memberCreatedEvent.get('attribution_type') + }); + return await attribution.getResource(); + } + + /** + * Returns the last attribution for a given subscription ID + * @param {string} subscriptionId + * @returns {Promise} + */ + async getSubscriptionCreatedAttribution(subscriptionId) { + const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false}); + if (!subscriptionCreatedEvent || !subscriptionCreatedEvent.get('attribution_type')) { + return null; + } + const attribution = this.attributionBuilder.build({ + id: subscriptionCreatedEvent.get('attribution_id'), + url: subscriptionCreatedEvent.get('attribution_url'), + type: subscriptionCreatedEvent.get('attribution_type') + }); + return await attribution.getResource(); + } } module.exports = MemberAttributionService; diff --git a/ghost/member-attribution/lib/url-translator.js b/ghost/member-attribution/lib/url-translator.js index 1ccde85de7..ba6bcfcda5 100644 --- a/ghost/member-attribution/lib/url-translator.js +++ b/ghost/member-attribution/lib/url-translator.js @@ -1,20 +1,27 @@ /** * @typedef {Object} UrlService * @prop {(resourceId: string) => Object} getResource + * @prop {(resourceId: string, options) => string} getUrlByResourceId * */ /** * Translate a url into a type and id + * And also in reverse */ class UrlTranslator { /** * * @param {Object} deps * @param {UrlService} deps.urlService + * @param {Object} deps.models + * @param {Object} deps.models.Post + * @param {Object} deps.models.Tag + * @param {Object} deps.models.User */ - constructor({urlService}) { + constructor({urlService, models}) { this.urlService = urlService; + this.models = models; } getTypeAndId(url) { @@ -51,6 +58,54 @@ class UrlTranslator { }; } } + + async getResourceById(id, type, options = {absolute: true}) { + const url = this.urlService.getUrlByResourceId(id, options); + + switch (type) { + case 'post': + case 'page': { + const post = await this.models.Post.findOne({id}, {require: false}); + if (!post) { + return null; + } + + return { + id: post.id, + type, + url, + title: post.get('title') + }; + } + case 'author': { + const user = await this.models.User.findOne({id}, {require: false}); + if (!user) { + return null; + } + + return { + id: user.id, + type, + url, + title: user.get('name') + }; + } + case 'tag': { + const tag = await this.models.Tag.findOne({id}, {require: false}); + if (!tag) { + return null; + } + + return { + id: tag.id, + type, + url, + title: tag.get('name') + }; + } + } + return null; + } } module.exports = UrlTranslator; diff --git a/ghost/member-attribution/test/attribution.test.js b/ghost/member-attribution/test/attribution.test.js index 74ce2fc5ad..ef74c32ddc 100644 --- a/ghost/member-attribution/test/attribution.test.js +++ b/ghost/member-attribution/test/attribution.test.js @@ -24,6 +24,17 @@ describe('AttributionBuilder', function () { }; } return; + }, + getResourceById(id, type) { + if (id === 'invalid') { + return null; + } + return { + id, + type, + url: '/path', + title: 'Title' + }; } } }); @@ -31,12 +42,12 @@ describe('AttributionBuilder', function () { it('Returns empty if empty history', function () { const history = new UrlHistory([]); - should(attributionBuilder.getAttribution(history)).eql({id: null, type: null, url: null}); + should(attributionBuilder.getAttribution(history)).match({id: null, type: null, url: null}); }); it('Returns last url', function () { const history = new UrlHistory([{path: '/not-last', time: 123}, {path: '/test', time: 123}]); - should(attributionBuilder.getAttribution(history)).eql({type: 'url', id: null, url: '/test'}); + should(attributionBuilder.getAttribution(history)).match({type: 'url', id: null, url: '/test'}); }); it('Returns last post', function () { @@ -45,7 +56,7 @@ describe('AttributionBuilder', function () { {path: '/test', time: 124}, {path: '/unknown-page', time: 125} ]); - should(attributionBuilder.getAttribution(history)).eql({type: 'post', id: 123, url: '/my-post'}); + should(attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'}); }); it('Returns last post even when it found pages', function () { @@ -54,7 +65,7 @@ describe('AttributionBuilder', function () { {path: '/my-page', time: 124}, {path: '/unknown-page', time: 125} ]); - should(attributionBuilder.getAttribution(history)).eql({type: 'post', id: 123, url: '/my-post'}); + should(attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'}); }); it('Returns last page if no posts', function () { @@ -63,12 +74,12 @@ describe('AttributionBuilder', function () { {path: '/my-page', time: 124}, {path: '/unknown-page', time: 125} ]); - should(attributionBuilder.getAttribution(history)).eql({type: 'page', id: 845, url: '/my-page'}); + should(attributionBuilder.getAttribution(history)).match({type: 'page', id: 845, url: '/my-page'}); }); it('Returns all null for invalid histories', function () { const history = new UrlHistory('invalid'); - should(attributionBuilder.getAttribution(history)).eql({ + should(attributionBuilder.getAttribution(history)).match({ type: null, id: null, url: null @@ -77,10 +88,37 @@ describe('AttributionBuilder', function () { it('Returns all null for empty histories', function () { const history = new UrlHistory([]); - should(attributionBuilder.getAttribution(history)).eql({ + should(attributionBuilder.getAttribution(history)).match({ type: null, id: null, url: null }); }); + + it('Returns post resource', async function () { + should(await attributionBuilder.build({type: 'post', id: '123', url: '/post'}).getResource()).match({ + type: 'post', + id: '123', + url: '/path', + title: 'Title' + }); + }); + + it('Returns url resource', async function () { + should(await attributionBuilder.build({type: 'url', id: null, url: '/url'}).getResource()).match({ + type: 'url', + id: null, + url: '/url', + title: '/url' + }); + }); + + it('Returns url resource if not found', async function () { + should(await attributionBuilder.build({type: 'post', id: 'invalid', url: '/post'}).getResource()).match({ + type: 'url', + id: null, + url: '/post', + title: '/post' + }); + }); }); diff --git a/ghost/member-attribution/test/url-translator.test.js b/ghost/member-attribution/test/url-translator.test.js index 836da4899e..d2c439861f 100644 --- a/ghost/member-attribution/test/url-translator.test.js +++ b/ghost/member-attribution/test/url-translator.test.js @@ -71,4 +71,99 @@ describe('UrlTranslator', function () { should(translator.getTypeAndId('/other')).eql(undefined); }); }); + + describe('getResourceById', function () { + let translator; + before(function () { + translator = new UrlTranslator({ + urlService: { + getUrlByResourceId: () => { + return '/path'; + } + }, + models: { + Post: { + findOne({id}) { + if (id === 'invalid') { + return null; + } + return {id: 'post_id', get: () => 'Title'}; + } + }, + User: { + findOne({id}) { + if (id === 'invalid') { + return null; + } + return {id: 'user_id', get: () => 'Title'}; + } + }, + Tag: { + findOne({id}) { + if (id === 'invalid') { + return null; + } + return {id: 'tag_id', get: () => 'Title'}; + } + } + } + }); + }); + + it('returns for post', async function () { + should(await translator.getResourceById('id', 'post')).eql({ + type: 'post', + id: 'post_id', + title: 'Title', + url: '/path' + }); + }); + + it('returns for page', async function () { + should(await translator.getResourceById('id', 'page')).eql({ + type: 'page', + id: 'post_id', + title: 'Title', + url: '/path' + }); + }); + + it('returns for tag', async function () { + should(await translator.getResourceById('id', 'tag')).eql({ + type: 'tag', + id: 'tag_id', + title: 'Title', + url: '/path' + }); + }); + + it('returns for user', async function () { + should(await translator.getResourceById('id', 'author')).eql({ + type: 'author', + id: 'user_id', + title: 'Title', + url: '/path' + }); + }); + + it('returns for invalid', async function () { + should(await translator.getResourceById('id', 'invalid')).eql(null); + }); + + it('returns null for not found post', async function () { + should(await translator.getResourceById('invalid', 'post')).eql(null); + }); + + it('returns null for not found page', async function () { + should(await translator.getResourceById('invalid', 'page')).eql(null); + }); + + it('returns null for not found author', async function () { + should(await translator.getResourceById('invalid', 'author')).eql(null); + }); + + it('returns null for not found tag', async function () { + should(await translator.getResourceById('invalid', 'tag')).eql(null); + }); + }); }); diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index 4ebd8548f7..b6d4c93fbe 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -124,7 +124,8 @@ module.exports = function MembersAPI({ } }, labsService, - stripeService: stripeAPIService + stripeService: stripeAPIService, + memberAttributionService }); const geolocationService = new GeolocationSerice(); diff --git a/ghost/members-api/lib/services/member-bread.js b/ghost/members-api/lib/services/member-bread.js index ccb9a75c92..860c3acea1 100644 --- a/ghost/members-api/lib/services/member-bread.js +++ b/ghost/members-api/lib/services/member-bread.js @@ -35,8 +35,9 @@ module.exports = class MemberBREADService { * @param {ILabsService} deps.labsService * @param {IEmailService} deps.emailService * @param {IStripeService} deps.stripeService + * @param {import('@tryghost/member-attribution/lib/service')} deps.memberAttributionService */ - constructor({memberRepository, labsService, emailService, stripeService, offersAPI}) { + constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService}) { this.offersAPI = offersAPI; /** @private */ this.memberRepository = memberRepository; @@ -46,6 +47,8 @@ module.exports = class MemberBREADService { this.emailService = emailService; /** @private */ this.stripeService = stripeService; + /** @private */ + this.memberAttributionService = memberAttributionService; } /** @@ -164,6 +167,29 @@ module.exports = class MemberBREADService { }); } + /** + * @private + * Adds missing complimentary subscriptions to a member and makes sure the tier of all subscriptions is set correctly. + */ + async attachAttributionsToMember(member, subscriptionIdMap) { + // Created attribution + member.attribution = await this.memberAttributionService.getMemberCreatedAttribution(member.id); + + // Subscriptions attributions + for (const subscription of member.subscriptions) { + if (!subscription.id) { + continue; + } + + // Convert stripe ID to database id + const id = subscriptionIdMap.get(subscription.id); + if (!id) { + continue; + } + subscription.attribution = await this.memberAttributionService.getSubscriptionCreatedAttribution(id); + } + } + async read(data, options = {}) { const defaultWithRelated = [ 'labels', @@ -195,12 +221,22 @@ module.exports = class MemberBREADService { return null; } + // We need to know the real IDs for each subscription to fetch the member attribution + const subscriptionIdMap = new Map(); + for (const subscription of model.related('stripeSubscriptions')) { + subscriptionIdMap.set(subscription.get('subscription_id'), subscription.id); + } + const member = model.toJSON(options); member.subscriptions = member.subscriptions.filter(sub => !!sub.price); this.attachSubscriptionsToMember(member); this.attachOffersToSubscriptions(member, await this.fetchSubscriptionOffers(model.related('stripeSubscriptions'))); + if (this.labsService.isSet('memberAttribution')) { + await this.attachAttributionsToMember(member, subscriptionIdMap); + } + return member; }