Skip to content

Commit

Permalink
Added member attributions to activity feed (#15283)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
SimonBackx authored Aug 24, 2022
1 parent ab8952d commit e986b78
Show file tree
Hide file tree
Showing 23 changed files with 3,244 additions and 1,653 deletions.
3 changes: 1 addition & 2 deletions ghost/admin/app/components/dashboard/charts/recents.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
3 changes: 1 addition & 2 deletions ghost/admin/app/components/member/activity-feed.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}} />
Expand Down
9 changes: 4 additions & 5 deletions ghost/admin/app/components/members-activity/table-row.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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>
Expand Down
108 changes: 65 additions & 43 deletions ghost/admin/app/helpers/parse-member-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +20,7 @@ export default function parseMemberEvent(event, hasMultipleNewsletters) {
icon,
subject,
action,
join,
object,
info,
url,
Expand Down Expand Up @@ -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';
}
Expand All @@ -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 === 'comment_event') {
if (event.data.post) {
return 'on';
}
}

if (event.type.match?.(/^email_/)) {
return 'an email';
}
return '';
}

if (event.type === 'subscription_event') {
return 'their subscription';
/**
* 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.type === 'comment_event') {
if (event.data.post) {
return event.data.post.title;
}
if (event.data.post) {
return event.data.post.title;
}
}

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

Expand All @@ -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;
}
12 changes: 10 additions & 2 deletions ghost/core/core/server/models/member-created-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 10 additions & 2 deletions ghost/core/core/server/models/subscription-created-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions ghost/core/core/server/services/member-attribution/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ class MemberAttributionServiceWrapper {
}
});

const attributionBuilder = new AttributionBuilder({urlTranslator});
this.attributionBuilder = new AttributionBuilder({urlTranslator});

// Expose the service
this.service = new MemberAttributionService({
models: {
MemberCreatedEvent: models.MemberCreatedEvent,
SubscriptionCreatedEvent: models.SubscriptionCreatedEvent
},
attributionBuilder,
attributionBuilder: this.attributionBuilder,
labsService
});

Expand Down
2 changes: 2 additions & 0 deletions ghost/core/core/server/services/members/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit e986b78

Please sign in to comment.