mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 19:02:29 +03:00
Added member attribution to member details page (#15266)
refs https://github.com/TryGhost/Team/issues/1817 Co-authored-by: James Morris <moreofmorris@users.noreply.github.com>
This commit is contained in:
parent
46870c423f
commit
0943daad72
@ -46,6 +46,12 @@
|
||||
{{svg-jar "member-add"}}
|
||||
Created on {{moment-format (moment-site-tz @member.createdAtUTC) "D MMM YYYY"}}
|
||||
</p>
|
||||
{{#if (and @member.attribution @member.attribution.url @member.attribution.title) }}
|
||||
<p>
|
||||
{{svg-jar "satellite"}}
|
||||
<a href="{{@member.attribution.url}}" target="_blank" rel="noopener noreferrer">{{ @member.attribution.title }}</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
<p class="gh-member-last-seen">
|
||||
{{svg-jar "eye"}}
|
||||
{{#if (not (is-empty @member.lastSeenAtUTC))}}
|
||||
@ -94,4 +100,4 @@
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
@ -162,8 +162,8 @@
|
||||
<span class="gh-cp-membertier-renewal">Expires {{sub.compExpiry}}</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else if sub.trialUntil}}
|
||||
<span class="gh-cp-membertier-renewal">Ends {{sub.trialUntil}}</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
<span class="gh-cp-membertier-renewal">Ends {{sub.trialUntil}}</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else}}
|
||||
<span class="gh-cp-membertier-renewal">Renews {{sub.validUntil}}</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
@ -192,8 +192,13 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<div class="gh-membertier-created">
|
||||
Created on {{sub.startDate}}
|
||||
<div>
|
||||
<span class="gh-membertier-created">Created on {{sub.startDate}}</span>
|
||||
{{#if (and sub.attribution sub.attribution.url sub.attribution.title) }}
|
||||
<span class="gh-membertier-separator">·</span>
|
||||
<span class="gh-membertier-started">Attributed to <a href="{{sub.attribution.url}}" target="_blank" rel="noopener noreferrer">{{ sub.attribution.title }}</a></span>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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 () {
|
||||
|
@ -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')
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<AttributionResource>}
|
||||
*/
|
||||
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'
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<import('./attribution').AttributionResource|null>}
|
||||
*/
|
||||
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<import('./attribution').AttributionResource|null>}
|
||||
*/
|
||||
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;
|
||||
|
@ -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;
|
||||
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -124,7 +124,8 @@ module.exports = function MembersAPI({
|
||||
}
|
||||
},
|
||||
labsService,
|
||||
stripeService: stripeAPIService
|
||||
stripeService: stripeAPIService,
|
||||
memberAttributionService
|
||||
});
|
||||
|
||||
const geolocationService = new GeolocationSerice();
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user