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