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:
Simon Backx 2022-08-19 22:39:18 +02:00 committed by GitHub
parent 46870c423f
commit 0943daad72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 460 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -124,7 +124,8 @@ module.exports = function MembersAPI({
}
},
labsService,
stripeService: stripeAPIService
stripeService: stripeAPIService,
memberAttributionService
});
const geolocationService = new GeolocationSerice();

View File

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