Added member attributions to activity feed (#15283)

refs https://github.com/TryGhost/Team/issues/1833
refs https://github.com/TryGhost/Team/issues/1834

We've added the attribution property to subscription and signup events when the
flag is enabled. The attributions resource is fetched by creating multiple relations
on the model, rather than polymorphic as we ran into issues with that as they can't
be nullable/optional.

The parse-member-event structure has been updated to make it easier to work with,
specifically `getObject` is only used when the event is clickable, and there is now a 
join property which makes it easier to join the action and the object.
This commit is contained in:
Simon Backx 2022-08-24 16:11:25 +02:00 committed by GitHub
parent ab8952dd46
commit e986b78458
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 3378 additions and 1787 deletions

View File

@ -102,9 +102,8 @@
<span class="gh-dashboard-list-subtext">
{{capitalize-first-letter parsedEvent.action}}
{{#if parsedEvent.url}}
{{parsedEvent.join}}
<a class="ghost-members-activity-object-link {{if (feature "memberAttribution") 'hidden'}}" href="{{parsedEvent.url}}" target="_blank" rel="noopener noreferrer">{{parsedEvent.object}}</a>
{{else}}
{{parsedEvent.object}}
{{/if}}
{{#if parsedEvent.info}}
<span class="highlight">{{parsedEvent.info}}</span>

View File

@ -27,9 +27,8 @@
<span class="gh-members-activity-description">
{{capitalize-first-letter event.action}}
{{#if event.url}}
{{event.join}}
<a class="ghost-members-activity-object-link" href="{{event.url}}" target="_blank" rel="noopener noreferrer">{{event.object}}</a>
{{else}}
{{event.object}}
{{/if}}
{{#if event.email}}
<GhEmailPreviewLink @data={{event.email}} />

View File

@ -6,7 +6,7 @@
<div class="flex items-center">
<GhMemberAvatar @member={{event.member}} @containerClass="w9 h9 mr3 flex-shrink-0" />
<div class="w-80">
<h3 class="ma0 pa0 gh-members-list-name {{unless event.member.name "gh-members-name-noname"}}">{{or event.member.name event.member.email}}</h3>
<h3 class="ma0 pa0 gh-members-list-name {{unless event.member.name "gh-members-name-noname"}}">{{event.subject}}</h3>
{{#if event.member.name}}
<p class="ma0 pa0 middarkgrey f8 gh-members-list-email">{{event.member.email}}</p>
{{/if}}
@ -21,10 +21,9 @@
<div class="gh-members-activity-event">
<span class="gh-members-activity-description">
{{capitalize-first-letter event.action}}
{{#if event.url}}
<a class="ghost-members-activity-object-link {{if (feature "memberAttribution") 'hidden'}}" href="{{event.url}}" target="_blank" rel="noopener noreferrer">{{event.object}}</a>
{{else}}
{{event.object}}
{{#if (and event.url (not (feature "memberAttribution")))}}
{{event.join}}
<a class="ghost-members-activity-object-link" href="{{event.url}}" target="_blank" rel="noopener noreferrer">{{event.object}}</a>
{{/if}}
{{#if event.email}}
<span class="{{if (feature "memberAttribution") 'hidden'}}"><GhEmailPreviewLink @data={{event.email}} /></span>

View File

@ -2,13 +2,15 @@ import moment from 'moment';
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
export default function parseMemberEvent(event, hasMultipleNewsletters) {
let subject = event.data.member.name || event.data.member.email;
let icon = getIcon(event);
let action = getAction(event);
let object = getObject(event, hasMultipleNewsletters);
let info = getInfo(event);
const subject = event.data.member.name || event.data.member.email;
const icon = getIcon(event);
const action = getAction(event, hasMultipleNewsletters);
const info = getInfo(event);
const join = getJoin(event);
const object = getObject(event);
const url = getURL(event);
let timestamp = moment(event.data.created_at);
const timestamp = moment(event.data.created_at);
return {
memberId: event.data.member_id ?? event.data.member?.id,
@ -18,6 +20,7 @@ export default function parseMemberEvent(event, hasMultipleNewsletters) {
icon,
subject,
action,
join,
object,
info,
url,
@ -77,7 +80,7 @@ function getIcon(event) {
return 'event-' + icon;
}
function getAction(event) {
function getAction(event, hasMultipleNewsletters) {
if (event.type === 'signup_event') {
return 'signed up';
}
@ -91,78 +94,98 @@ function getAction(event) {
}
if (event.type === 'newsletter_event') {
let newsletter = 'newsletter';
if (hasMultipleNewsletters && event.data.newsletter && event.data.newsletter.name) {
newsletter = 'newsletter ' + event.data.newsletter.name;
}
if (event.data.subscribed) {
return 'subscribed to';
return 'subscribed to ' + newsletter;
} else {
return 'unsubscribed from';
return 'unsubscribed from ' + newsletter;
}
}
if (event.type === 'subscription_event') {
if (event.data.type === 'created') {
return 'started';
return 'started their subscription';
}
if (event.data.type === 'updated') {
return 'changed';
return 'changed their subscription';
}
if (event.data.type === 'canceled') {
return 'canceled';
return 'canceled their subscription';
}
if (event.data.type === 'reactivated') {
return 'reactivated';
return 'reactivated their subscription';
}
if (event.data.type === 'expired') {
return 'ended';
return 'ended their subscription';
}
return 'changed';
return 'changed their subscription';
}
if (event.type === 'email_opened_event') {
return 'opened';
return 'opened an email';
}
if (event.type === 'email_delivered_event') {
return 'received';
return 'received an email';
}
if (event.type === 'email_failed_event') {
return 'failed to receive';
return 'failed to receive an email';
}
if (event.type === 'comment_event') {
if (event.data.parent) {
return 'replied to a comment on';
return 'replied to a comment';
}
return 'commented on';
return 'commented';
}
}
function getObject(event, hasMultipleNewsletters) {
if (event.type === 'newsletter_event') {
if (hasMultipleNewsletters && event.data.newsletter && event.data.newsletter.name) {
return 'newsletter ' + event.data.newsletter.name;
/**
* When we need to append the action and object in one sentence, you can add extra words here.
* E.g.,
* action: 'Signed up'.
* object: 'My blog post'
* When both words need to get appended, we'll add 'on'
* -> do this by returning 'on' in getJoin()
* This string is not added when action and object are in a separete table column, or when the getObject/getURL is empty
*/
function getJoin(event) {
if (event.type === 'signup_event' || event.type === 'subscription_event') {
if (event.data.attribution?.title) {
// Add 'Attributed to ' for now, until this is incorporated in the design
return 'on';
}
return 'newsletter';
}
if (event.type === 'subscription_event') {
return 'their subscription';
}
if (event.type.match?.(/^email_/)) {
return 'an email';
}
if (event.type === 'subscription_event') {
return 'their subscription';
}
if (event.type === 'comment_event') {
if (event.type === 'comment_event') {
if (event.data.post) {
return event.data.post.title;
}
if (event.data.post) {
return 'on';
}
}
return '';
}
/**
* Clickable object, shown between action and info, or in a separate column in some views
*/
function getObject(event) {
if (event.type === 'signup_event' || event.type === 'subscription_event') {
if (event.data.attribution?.title) {
// Add 'Attributed to ' for now, until this is incorporated in the design
return event.data.attribution.title;
}
}
if (event.type === 'comment_event') {
if (event.data.post) {
return event.data.post.title;
}
}
@ -179,13 +202,6 @@ function getInfo(event) {
let symbol = getSymbol(event.data.currency);
return `(MRR ${sign}${symbol}${Math.abs(mrrDelta)})`;
}
// TODO: we can include the post title
/*if (event.type === 'comment_event') {
if (event.data.post) {
return event.data.post.title;
}
}*/
return;
}
@ -198,5 +214,11 @@ function getURL(event) {
return event.data.post.url;
}
}
if (event.type === 'signup_event' || event.type === 'subscription_event') {
if (event.data.attribution && event.data.attribution.url) {
return event.data.attribution.url;
}
}
return;
}

View File

@ -8,8 +8,16 @@ const MemberCreatedEvent = ghostBookshelf.Model.extend({
return this.belongsTo('Member', 'member_id', 'id');
},
attribution() {
return this.belongsTo('Post', 'attribution_id', 'id');
postAttribution() {
return this.belongsTo('Post', 'attribution_id', 'id');
},
userAttribution() {
return this.belongsTo('User', 'attribution_id', 'id');
},
tagAttribution() {
return this.belongsTo('Tag', 'attribution_id', 'id');
}
}, {
async edit() {

View File

@ -8,6 +8,10 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({
return this.belongsTo('Member', 'member_id', 'id');
},
subscriptionCreatedEvent() {
return this.belongsTo('SubscriptionCreatedEvent', 'subscription_id', 'subscription_id');
},
customQuery(qb, options) {
if (options.aggregateMRRDeltas) {
if (options.limit || options.filter) {

View File

@ -12,8 +12,16 @@ const SubscriptionCreatedEvent = ghostBookshelf.Model.extend({
return this.belongsTo('StripeCustomerSubscription', 'subscription_id', 'id');
},
attribution() {
return this.belongsTo('Post', 'attribution_id', 'id');
postAttribution() {
return this.belongsTo('Post', 'attribution_id', 'id');
},
userAttribution() {
return this.belongsTo('User', 'attribution_id', 'id');
},
tagAttribution() {
return this.belongsTo('Tag', 'attribution_id', 'id');
}
}, {
async edit() {

View File

@ -24,7 +24,7 @@ class MemberAttributionServiceWrapper {
}
});
const attributionBuilder = new AttributionBuilder({urlTranslator});
this.attributionBuilder = new AttributionBuilder({urlTranslator});
// Expose the service
this.service = new MemberAttributionService({
@ -32,7 +32,7 @@ class MemberAttributionServiceWrapper {
MemberCreatedEvent: models.MemberCreatedEvent,
SubscriptionCreatedEvent: models.SubscriptionCreatedEvent
},
attributionBuilder,
attributionBuilder: this.attributionBuilder,
labsService
});

View File

@ -185,6 +185,8 @@ function createApiInstance(config) {
MemberStatusEvent: models.MemberStatusEvent,
MemberProductEvent: models.MemberProductEvent,
MemberAnalyticEvent: models.MemberAnalyticEvent,
MemberCreatedEvent: models.MemberCreatedEvent,
SubscriptionCreatedEvent: models.SubscriptionCreatedEvent,
OfferRedemption: models.OfferRedemption,
Offer: models.Offer,
StripeProduct: models.StripeProduct,

View File

@ -1,5 +1,279 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Members API - member attribution Can read member attributed to a page 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6e9",
"title": "This is a static page",
"type": "page",
"url": "http://127.0.0.1:2369/static-page-test/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-page@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API - member attribution Can read member attributed to a page 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1955",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API - member attribution Can read member attributed to a post 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6df",
"title": "HTML Ipsum",
"type": "post",
"url": "http://127.0.0.1:2369/html-ipsum/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-post@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API - member attribution Can read member attributed to a post 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1938",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API - member attribution Can read member attributed to a tag 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1febe2896088840a6db",
"title": "kitchen sink",
"type": "tag",
"url": "http://127.0.0.1:2369/tag/kitchen-sink/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-tag@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API - member attribution Can read member attributed to a tag 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1944",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API - member attribution Can read member attributed to an author 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "1",
"title": "Joe Bloggs",
"type": "author",
"url": "http://127.0.0.1:2369/author/joe-bloggs/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-author@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API - member attribution Can read member attributed to an author 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1926",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API - member attribution Can read member attributed to an url 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"title": "/a-static-page/",
"type": "url",
"url": "http://127.0.0.1:2369/a-static-page/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-url@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API - member attribution Can read member attributed to an url 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1922",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API - member attribution Returns sign up attributions in activity feed 1: [body] 1`] = `
Object {
"events": Array [
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
}
`;
exports[`Members API - member attribution Returns sign up attributions in activity feed 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "8381",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Add should fail when passing incorrect email_type query parameter 1: [body] 1`] = `
Object {
"errors": Array [
@ -3748,3 +4022,280 @@ Object {
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": null,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member1@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": "Mr Egg",
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1321",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read member attributed to a page 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6e9",
"title": "This is a static page",
"type": "page",
"url": "http://127.0.0.1:2369/static-page-test/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-page@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read member attributed to a page 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1955",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read member attributed to a post 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6df",
"title": "HTML Ipsum",
"type": "post",
"url": "http://127.0.0.1:2369/html-ipsum/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-post@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read member attributed to a post 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1938",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read member attributed to a tag 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1febe2896088840a6db",
"title": "kitchen sink",
"type": "tag",
"url": "http://127.0.0.1:2369/tag/kitchen-sink/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-tag@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read member attributed to a tag 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1944",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read member attributed to an author 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "1",
"title": "Joe Bloggs",
"type": "author",
"url": "http://127.0.0.1:2369/author/joe-bloggs/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-author@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read member attributed to an author 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1926",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members APi - member attribution Can read member attributed to an url 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"title": "/a-static-page/",
"type": "url",
"url": "http://127.0.0.1:2369/a-static-page/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "member-attributed-to-url@test.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members APi - member attribution Can read member attributed to an url 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1922",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

View File

@ -10,6 +10,10 @@ const testUtils = require('../../utils');
const Papa = require('papaparse');
const models = require('../../../core/server/models');
const membersService = require('../../../core/server/services/members');
const memberAttributionService = require('../../../core/server/services/member-attribution');
const urlService = require('../../../core/server/services/url');
const urlUtils = require('../../../core/shared/url-utils');
async function assertMemberEvents({eventType, memberId, asserts}) {
const events = await models[eventType].where('member_id', memberId).fetchAll();
@ -153,6 +157,227 @@ describe('Members API without Stripe', function () {
});
});
// Tests specific for member attribution
describe('Members API - member attribution', function () {
const signupAttributions = [];
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments');
await agent.loginAsOwner();
});
beforeEach(function () {
mockManager.mockStripe();
mockManager.mockMail();
// For some reason it is enabled by default?
mockManager.mockLabsEnabled('memberAttribution');
});
afterEach(function () {
mockManager.restore();
});
it('Can read member attributed to a post', async function () {
const id = fixtureManager.get('posts', 0).id;
const post = await models.Post.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-post@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'post'
})
});
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: post.id,
url: absoluteUrl,
type: 'post',
title: post.get('title')
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to a page', async function () {
const id = fixtureManager.get('posts', 5).id;
const post = await models.Post.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-page@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'page'
})
});
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: post.id,
url: absoluteUrl,
type: 'page',
title: post.get('title')
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to a tag', async function () {
const id = fixtureManager.get('tags', 0).id;
const tag = await models.Tag.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-tag@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'tag'
})
});
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: tag.id,
url: absoluteUrl,
type: 'tag',
title: tag.get('name')
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to an author', async function () {
const id = fixtureManager.get('users', 0).id;
const author = await models.User.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-author@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'author'
})
});
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: author.id,
url: absoluteUrl,
type: 'author',
title: author.get('name')
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to an url', async function () {
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-url@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id: null,
url: '/a-static-page/',
type: 'url'
})
});
const absoluteUrl = urlUtils.createUrl('/a-static-page/', true);
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: null,
url: absoluteUrl,
type: 'url',
title: '/a-static-page/'
});
signupAttributions.push(body.members[0].attribution);
});
});
// Activity feed
it('Returns sign up attributions in activity feed', async function () {
// Check activity feed
await agent
.get(`/members/events/?filter=type:signup_event`)
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(signupAttributions.length).fill({
type: anyString,
data: anyObject
})
})
.expect(({body}) => {
should(body.events.find(e => e.type !== 'signup_event')).be.undefined();
should(body.events.map(e => e.data.attribution)).containDeep(signupAttributions);
});
});
});
describe('Members API', function () {
let newsletters;

View File

@ -0,0 +1,448 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "1",
"title": "Joe Bloggs",
"type": "author",
"url": "http://127.0.0.1:2369/author/joe-bloggs/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2795",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"title": "/removed-blog-post/",
"type": "url",
"url": "http://127.0.0.1:2369/removed-blog-post/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2809",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": null,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2611",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6e9",
"title": "This is a static page",
"type": "page",
"url": "http://127.0.0.1:2369/static-page-test/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2857",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1ffbe2896088840a6df",
"title": "HTML Ipsum",
"type": "post",
"url": "http://127.0.0.1:2369/html-ipsum/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2823",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": "618ba1febe2896088840a6db",
"title": "kitchen sink",
"type": "tag",
"url": "http://127.0.0.1:2369/tag/kitchen-sink/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2837",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"title": "/",
"type": "url",
"url": "http://127.0.0.1:2369/",
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2737",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": null,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2611",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [body] 1`] = `
Object {
"events": Array [
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
Object {
"data": Any<Object>,
"type": Any<String>,
},
],
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "7784",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "13390",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution empty initial activity feed 1: [body] 1`] = `
Object {
"events": Array [],
}
`;
exports[`Members API Member attribution empty initial activity feed 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "13",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ describe('Member Attribution Service', function () {
type: 'url'
}));
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: null,
url: absoluteUrl,
type: 'url',
@ -60,7 +60,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: post.id,
url: absoluteUrl,
type: 'post',
@ -92,7 +92,7 @@ describe('Member Attribution Service', function () {
// Unpublish this post
await models.Post.edit({status: 'draft'}, {id});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: null,
url: absoluteUrl,
type: 'url',
@ -123,7 +123,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: post.id,
url: absoluteUrl,
type: 'page',
@ -150,7 +150,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: tag.id,
url: absoluteUrl,
type: 'tag',
@ -177,7 +177,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: author.id,
url: absoluteUrl,
type: 'author',
@ -212,7 +212,7 @@ describe('Member Attribution Service', function () {
type: 'url'
}));
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: null,
url: absoluteUrl,
type: 'url',
@ -245,7 +245,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: post.id,
url: absoluteUrl,
type: 'post',
@ -280,7 +280,7 @@ describe('Member Attribution Service', function () {
// Unpublish this post
await models.Post.edit({status: 'draft'}, {id});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: null,
url: absoluteUrl,
type: 'url',
@ -310,7 +310,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: post.id,
url: absoluteUrl,
type: 'page',
@ -338,7 +338,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: tag.id,
url: absoluteUrl,
type: 'tag',
@ -366,7 +366,7 @@ describe('Member Attribution Service', function () {
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true});
(await attribution.getResource()).should.match(({
(await attribution.fetchResource()).should.match(({
id: author.id,
url: absoluteUrl,
type: 'author',

View File

@ -478,6 +478,27 @@ const fixtures = {
return models.Product.add(archivedProduct, context.internal);
},
insertProducts: async function insertProducts() {
let coreProductFixtures = fixtureManager.findModelFixtures('Product').entries;
await Promise.map(coreProductFixtures, async (product) => {
const found = await models.Product.findOne(product, context.internal);
if (!found) {
await models.Product.add(product, context.internal);
}
});
const product = await models.Product.findOne({type: 'paid'}, context.internal);
await Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_products), function (stripeProduct) {
stripeProduct.product_id = product.id;
return models.StripeProduct.add(stripeProduct, context.internal);
});
await Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_prices), function (stripePrice) {
return models.StripePrice.add(stripePrice, context.internal);
});
},
insertMembersAndLabelsAndProducts: function insertMembersAndLabelsAndProducts(newsletters = false) {
return Promise.map(DataGenerator.forKnex.labels, function (label) {
return models.Label.add(label, context.internal);
@ -684,6 +705,9 @@ const toDoList = {
members: function insertMembersAndLabelsAndProducts() {
return fixtures.insertMembersAndLabelsAndProducts(false);
},
products: function insertProducts() {
return fixtures.insertProducts();
},
newsletters: function insertNewsletters() {
return fixtures.insertNewsletters();
},

View File

@ -27,15 +27,16 @@ class Attribution {
}
/**
* Convert the instance to a parsed instance with more information about the resource included.
* Converts 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
* - Uses the passed model and adds a title to the attribution
* - If the resource exists and has a new url, it updates the url if possible
* - Returns an absolute URL instead of a relative one
* @returns {Promise<AttributionResource>}
* @param {Object|null} [model] The Post/User/Tag model of the resource associated with this attribution
* @returns {AttributionResource}
*/
async getResource() {
if (!this.id || this.type === 'url' || !this.type) {
getResource(model) {
if (!this.id || this.type === 'url' || !this.type || !model) {
return {
id: null,
type: 'url',
@ -44,19 +45,29 @@ class Attribution {
};
}
const resource = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true});
if (resource) {
return resource;
}
const updatedUrl = this.#urlTranslator.getUrlByResourceId(this.id, {absolute: true});
return {
id: null,
type: 'url',
url: this.#urlTranslator.relativeToAbsolute(this.url),
title: this.url
id: model.id,
type: this.type,
url: updatedUrl,
title: model.get('title') ?? model.get('name') ?? this.url
};
}
/**
* Same as getResource, but fetches the model by ID instead of passing it as a parameter
*/
async fetchResource() {
if (!this.id || this.type === 'url' || !this.type) {
// No fetch required
return this.getResource();
}
// Fetch model
const model = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true});
return this.getResource(model);
}
}
/**

View File

@ -24,13 +24,47 @@ class MemberAttributionService {
return this.attributionBuilder.getAttribution(history);
}
/**
* Returns the attribution resource for a given event model (MemberCreatedEvent / SubscriptionCreatedEvent), where the model has the required relations already loaded
* You need to already load the 'postAttribution', 'userAttribution', and 'tagAttribution' relations
* @param {Object} eventModel MemberCreatedEvent or SubscriptionCreatedEvent
* @returns {import('./attribution').AttributionResource|null}
*/
getEventAttribution(eventModel) {
if (eventModel.get('attribution_type') === null) {
return null;
}
const _attribution = this.attributionBuilder.build({
id: eventModel.get('attribution_id'),
url: eventModel.get('attribution_url'),
type: eventModel.get('attribution_type')
});
if (_attribution.type !== 'url') {
// Find the right relation to use to fetch the resource
const tryRelations = [
eventModel.related('postAttribution'),
eventModel.related('userAttribution'),
eventModel.related('tagAttribution')
];
for (const relation of tryRelations) {
if (relation && relation.id) {
// We need to check the ID, because .related() always returs a model when eager loaded, even when the relation didn't exist
return _attribution.getResource(relation);
}
}
}
return _attribution.getResource(null);
}
/**
* 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});
const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false, withRelated: []});
if (!memberCreatedEvent || !memberCreatedEvent.get('attribution_type')) {
return null;
}
@ -39,7 +73,7 @@ class MemberAttributionService {
url: memberCreatedEvent.get('attribution_url'),
type: memberCreatedEvent.get('attribution_type')
});
return await attribution.getResource();
return await attribution.fetchResource();
}
/**
@ -48,7 +82,7 @@ class MemberAttributionService {
* @returns {Promise<import('./attribution').AttributionResource|null>}
*/
async getSubscriptionCreatedAttribution(subscriptionId) {
const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false});
const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false, withRelated: []});
if (!subscriptionCreatedEvent || !subscriptionCreatedEvent.get('attribution_type')) {
return null;
}
@ -57,7 +91,7 @@ class MemberAttributionService {
url: subscriptionCreatedEvent.get('attribution_url'),
type: subscriptionCreatedEvent.get('attribution_type')
});
return await attribution.getResource();
return await attribution.fetchResource();
}
}

View File

@ -6,8 +6,7 @@
*/
/**
* Translate a url into a type and id
* And also in reverse
* Translate a url into, (id+type), or a resource, and vice versa
*/
class UrlTranslator {
/**
@ -81,9 +80,11 @@ class UrlTranslator {
}
}
async getResourceById(id, type, options = {absolute: true}) {
const url = this.urlService.getUrlByResourceId(id, options);
getUrlByResourceId(id, options = {absolute: true}) {
return this.urlService.getUrlByResourceId(id, options);
}
async getResourceById(id, type) {
switch (type) {
case 'post':
case 'page': {
@ -92,12 +93,7 @@ class UrlTranslator {
return null;
}
return {
id: post.id,
type,
url,
title: post.get('title')
};
return post;
}
case 'author': {
const user = await this.models.User.findOne({id}, {require: false});
@ -105,12 +101,7 @@ class UrlTranslator {
return null;
}
return {
id: user.id,
type,
url,
title: user.get('name')
};
return user;
}
case 'tag': {
const tag = await this.models.Tag.findOne({id}, {require: false});
@ -118,12 +109,7 @@ class UrlTranslator {
return null;
}
return {
id: tag.id,
type,
url,
title: tag.get('name')
};
return tag;
}
}
return null;

View File

@ -25,17 +25,20 @@ describe('AttributionBuilder', function () {
}
return;
},
getResourceById(id, type) {
getResourceById(id) {
if (id === 'invalid') {
return null;
}
return {
id,
type,
url: 'https://absolute/dir/path',
title: 'Title'
get() {
return 'Title';
}
};
},
getUrlByResourceId() {
return 'https://absolute/dir/path';
},
relativeToAbsolute(path) {
return 'https://absolute/dir' + path;
},
@ -105,7 +108,7 @@ describe('AttributionBuilder', function () {
});
it('Returns post resource', async function () {
should(await attributionBuilder.build({type: 'post', id: '123', url: '/post'}).getResource()).match({
should(await attributionBuilder.build({type: 'post', id: '123', url: '/post'}).fetchResource()).match({
type: 'post',
id: '123',
url: 'https://absolute/dir/path',
@ -114,7 +117,7 @@ describe('AttributionBuilder', function () {
});
it('Returns url resource', async function () {
should(await attributionBuilder.build({type: 'url', id: null, url: '/url'}).getResource()).match({
should(await attributionBuilder.build({type: 'url', id: null, url: '/url'}).fetchResource()).match({
type: 'url',
id: null,
url: 'https://absolute/dir/url',
@ -123,7 +126,7 @@ describe('AttributionBuilder', function () {
});
it('Returns url resource if not found', async function () {
should(await attributionBuilder.build({type: 'post', id: 'invalid', url: '/post'}).getResource()).match({
should(await attributionBuilder.build({type: 'post', id: 'invalid', url: '/post'}).fetchResource()).match({
type: 'url',
id: null,
url: 'https://absolute/dir/post',

View File

@ -9,4 +9,97 @@ describe('MemberAttributionService', function () {
new MemberAttributionService({});
});
});
describe('getEventAttribution', function () {
it('returns null if attribution_type is null', function () {
const service = new MemberAttributionService({});
const model = {
id: 'event_id',
get() {
return null;
}
};
should(service.getEventAttribution(model)).eql(null);
});
it('returns url attribution types', function () {
const service = new MemberAttributionService({
attributionBuilder: {
build(attribution) {
return {
...attribution,
getResource() {
return {
...attribution,
title: 'added'
};
}
};
}
}
});
const model = {
id: 'event_id',
get(name) {
if (name === 'attribution_type') {
return 'url';
}
if (name === 'attribution_url') {
return '/my/url/';
}
return null;
}
};
should(service.getEventAttribution(model)).eql({
id: null,
type: 'url',
url: '/my/url/',
title: 'added'
});
});
it('returns first loaded relation', function () {
const service = new MemberAttributionService({
attributionBuilder: {
build(attribution) {
return {
...attribution,
getResource() {
return {
...attribution,
title: 'added'
};
}
};
}
}
});
const model = {
id: 'event_id',
get(name) {
if (name === 'attribution_type') {
return 'user';
}
if (name === 'attribution_url') {
return '/my/url/';
}
return 'test_user_id';
},
related(name) {
if (name === 'userAttribution') {
return {
id: 'test_user_id'
};
}
return {};
}
};
should(service.getEventAttribution(model)).eql({
id: 'test_user_id',
type: 'user',
url: '/my/url/',
title: 'added'
});
});
});
});

View File

@ -111,38 +111,26 @@ describe('UrlTranslator', function () {
});
it('returns for post', async function () {
should(await translator.getResourceById('id', 'post')).eql({
type: 'post',
id: 'post_id',
title: 'Title',
url: '/path'
should(await translator.getResourceById('id', 'post')).match({
id: 'post_id'
});
});
it('returns for page', async function () {
should(await translator.getResourceById('id', 'page')).eql({
type: 'page',
id: 'post_id',
title: 'Title',
url: '/path'
should(await translator.getResourceById('id', 'page')).match({
id: 'post_id'
});
});
it('returns for tag', async function () {
should(await translator.getResourceById('id', 'tag')).eql({
type: 'tag',
id: 'tag_id',
title: 'Title',
url: '/path'
should(await translator.getResourceById('id', 'tag')).match({
id: 'tag_id'
});
});
it('returns for user', async function () {
should(await translator.getResourceById('id', 'author')).eql({
type: 'author',
id: 'user_id',
title: 'Title',
url: '/path'
should(await translator.getResourceById('id', 'author')).match({
id: 'user_id'
});
});

View File

@ -48,6 +48,8 @@ module.exports = function MembersAPI({
MemberProductEvent,
MemberEmailChangeEvent,
MemberAnalyticEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
Offer,
OfferRedemption,
StripeProduct,
@ -105,8 +107,11 @@ module.exports = function MembersAPI({
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
Comment,
labsService
labsService,
memberAttributionService
});
const memberBREADService = new MemberBREADService({

View File

@ -8,9 +8,12 @@ module.exports = class EventRepository {
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberPaidSubscriptionEvent,
Comment,
labsService
labsService,
memberAttributionService
}) {
this._MemberSubscribeEvent = MemberSubscribeEvent;
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
@ -20,6 +23,9 @@ module.exports = class EventRepository {
this._EmailRecipient = EmailRecipient;
this._Comment = Comment;
this._labsService = labsService;
this._MemberCreatedEvent = MemberCreatedEvent;
this._SubscriptionCreatedEvent = SubscriptionCreatedEvent;
this._memberAttributionService = memberAttributionService;
}
async registerPayment(data) {
@ -62,9 +68,38 @@ module.exports = class EventRepository {
}
async getSubscriptionEvents(options = {}, filters = {}) {
if (!this._labsService.isSet('memberAttribution')){
options = {
...options,
withRelated: ['member'],
filter: []
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options);
const data = models.map((model) => {
return {
type: 'subscription_event',
data: model.toJSON(options)
};
});
return {
data,
meta
};
}
options = {
...options,
withRelated: ['member'],
withRelated: ['member', 'subscriptionCreatedEvent.postAttribution', 'subscriptionCreatedEvent.userAttribution', 'subscriptionCreatedEvent.tagAttribution'],
filter: []
};
if (filters['data.created_at']) {
@ -80,7 +115,10 @@ module.exports = class EventRepository {
const data = models.map((model) => {
return {
type: 'subscription_event',
data: model.toJSON(options)
data: {
...model.toJSON(options),
attribution: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id ? this._memberAttributionService.getEventAttribution(model.related('subscriptionCreatedEvent')) : null
}
};
});
@ -149,10 +187,39 @@ module.exports = class EventRepository {
}
async getSignupEvents(options = {}, filters = {}) {
if (!this._labsService.isSet('memberAttribution')){
options = {
...options,
withRelated: ['member'],
filter: ['from_status:null']
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberStatusEvent.findPage(options);
const data = models.map((model) => {
return {
type: 'signup_event',
data: model.toJSON(options)
};
});
return {
data,
meta
};
}
options = {
...options,
withRelated: ['member'],
filter: ['from_status:null']
withRelated: ['member', 'postAttribution', 'userAttribution', 'tagAttribution'],
filter: []
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:'));
@ -162,12 +229,15 @@ module.exports = class EventRepository {
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberStatusEvent.findPage(options);
const {data: models, meta} = await this._MemberCreatedEvent.findPage(options);
const data = models.map((model) => {
return {
type: 'signup_event',
data: model.toJSON(options)
data: {
...model.toJSON(options),
attribution: this._memberAttributionService.getEventAttribution(model)
}
};
});