mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-10-05 01:07:26 +03:00
Added aggregated click events (#15713)
fixes https://github.com/TryGhost/Team/issues/2175 - New event type `aggregated_click_event` that is disabled by default in all the existing activity feeds - This returns click events, but only the first click events for each member/post combination. - It includes the total count of unique link clicks for that member on that post combination - Had to resort to some custom knex queries to make this work easily - Requires `@tryghost/bookshelf-pagination@0.1.31`, included in `@tryghost/bookshelf-plugins@0.6.1` (this fixes an issue with custom selects breaking the total count query of pages) - Went a bit overboard with the pagination tests to cover as much unknown edge cases as possible
This commit is contained in:
parent
08427809ec
commit
b916300ceb
@ -3,7 +3,7 @@ import {action} from '@ember/object';
|
||||
|
||||
export default class ActivityFeed extends Component {
|
||||
linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions
|
||||
excludedEventTypes = ['email_sent_event'];
|
||||
excludedEventTypes = ['email_sent_event', 'aggregated_click_event'];
|
||||
|
||||
@action
|
||||
enterLinkURL(event) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="gh-post-activity-feed">
|
||||
{{#let (activity-feed-fetcher filter=(members-event-filter post=@postId excludedEvents=this.getEventTypes) pageSize=this.pageSize) as |eventsFetcher|}}
|
||||
{{#let (activity-feed-fetcher filter=(members-event-filter post=@postId includeEvents=this.getEventTypes) pageSize=this.pageSize) as |eventsFetcher|}}
|
||||
{{#if eventsFetcher.isError}}
|
||||
<div class="gh-dashboard-list-body">
|
||||
<div class="gh-dashboard-list-error">
|
||||
@ -40,7 +40,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="gh-dashboard-list-body {{if (eq this.eventType "clicked") "gh-dashboard-list-larger-cols" }} {{if (eq this.eventType "conversion") "gh-dashboard-list-four-cols" }}">
|
||||
<div class="gh-dashboard-list-body {{if (eq this.eventType "conversion") "gh-dashboard-list-four-cols" }}">
|
||||
{{#each eventsFetcher.data as |event|}}
|
||||
{{#let (parse-member-event event) as |parsedEvent|}}
|
||||
<div class="gh-dashboard-list-item">
|
||||
@ -56,12 +56,6 @@
|
||||
{{#if parsedEvent.info}}
|
||||
<span class="highlight">{{parsedEvent.info}}</span>
|
||||
{{/if}}
|
||||
{{#if (eq this.eventType "clicked")}}
|
||||
{{#if (and parsedEvent.description parsedEvent.url) }}
|
||||
<span class="gh-members-activity-event-dash">–</span>
|
||||
<a class="ghost-members-activity-object-link" href="{{parsedEvent.url}}" target="_blank" rel="noopener noreferrer">{{parsedEvent.description}}</a>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -1,22 +1,10 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
|
||||
const allEvents = [
|
||||
'comment_event',
|
||||
'click_event',
|
||||
'signup_event',
|
||||
'subscription_event',
|
||||
'email_sent_event',
|
||||
'email_delivered_event',
|
||||
'email_opened_event',
|
||||
'email_failed_event',
|
||||
'feedback_event'
|
||||
];
|
||||
|
||||
const eventTypes = {
|
||||
sent: ['email_sent_event'],
|
||||
opened: ['email_opened_event'],
|
||||
clicked: ['click_event'],
|
||||
clicked: ['aggregated_click_event'],
|
||||
feedback: ['feedback_event'],
|
||||
conversion: ['subscription_event', 'signup_event']
|
||||
};
|
||||
@ -26,8 +14,7 @@ export default class PostActivityFeed extends Component {
|
||||
_pageSize = 5;
|
||||
|
||||
get getEventTypes() {
|
||||
const filteredEvents = eventTypes[this.args.eventType];
|
||||
return allEvents.filter(event => !filteredEvents.includes(event));
|
||||
return eventTypes[this.args.eventType];
|
||||
}
|
||||
|
||||
get pageSize() {
|
||||
|
@ -30,6 +30,7 @@ export default class MembersActivityController extends Controller {
|
||||
// Always hide sent event
|
||||
hiddenEvents.push('email_sent_event');
|
||||
}
|
||||
hiddenEvents.push('aggregated_click_event');
|
||||
|
||||
if (this.settings.editorDefaultEmailRecipients === 'disabled') {
|
||||
hiddenEvents.push(...EMAIL_EVENTS, ...NEWSLETTER_EVENTS);
|
||||
|
@ -13,7 +13,7 @@ export default class MembersEventFilter extends Helper {
|
||||
|
||||
compute(
|
||||
positionalParams,
|
||||
{excludedEvents = [], member = '', post = '', excludeEmailEvents = false}
|
||||
{excludedEvents = [], includeEvents = null, member = '', post = '', excludeEmailEvents = false}
|
||||
) {
|
||||
const excludedEventsSet = new Set();
|
||||
|
||||
@ -35,9 +35,14 @@ export default class MembersEventFilter extends Helper {
|
||||
let filterParts = [];
|
||||
|
||||
const excludedEventsArray = Array.from(excludedEventsSet).reject(isBlank);
|
||||
|
||||
if (includeEvents !== null) {
|
||||
filterParts.push(`type:[${includeEvents.join(',')}]`);
|
||||
} else {
|
||||
if (excludedEventsArray.length > 0) {
|
||||
filterParts.push(`type:-[${excludedEventsArray.join(',')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (member) {
|
||||
filterParts.push(`data.member_id:${member}`);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Helper from '@ember/component/helper';
|
||||
import moment from 'moment-timezone';
|
||||
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
|
||||
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ParseMemberEventHelper extends Helper {
|
||||
@ -87,7 +88,7 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
icon = 'comment';
|
||||
}
|
||||
|
||||
if (event.type === 'click_event') {
|
||||
if (event.type === 'click_event' || event.type === 'aggregated_click_event') {
|
||||
icon = 'click';
|
||||
}
|
||||
|
||||
@ -171,6 +172,13 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
return 'clicked link in email';
|
||||
}
|
||||
|
||||
if (event.type === 'aggregated_click_event') {
|
||||
if (event.data.count.clicks <= 1) {
|
||||
return 'clicked link in email';
|
||||
}
|
||||
return `clicked ${ghPluralize(event.data.count.clicks, 'link')} in email`;
|
||||
}
|
||||
|
||||
if (event.type === 'feedback_event') {
|
||||
if (event.data.score === 1) {
|
||||
return 'more like this';
|
||||
@ -253,7 +261,7 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
|
||||
if (event.data.type === 'created') {
|
||||
const sign = mrrDelta > 0 ? '' : '-';
|
||||
const tierName = this.membersUtils.hasMultipleTiers ? (event.data.tierName ?? 'MRR') : 'paid';
|
||||
const tierName = this.membersUtils.hasMultipleTiers ? (event.data.tierName ?? 'paid') : 'paid';
|
||||
return `(${tierName} - ${sign}${symbol}${Math.abs(mrrDelta)}/month)`;
|
||||
}
|
||||
const sign = mrrDelta > 0 ? '+' : '-';
|
||||
|
@ -63,6 +63,34 @@ const clickEventMapper = (json, frame) => {
|
||||
};
|
||||
};
|
||||
|
||||
const aggregatedClickEventMapper = (json) => {
|
||||
const data = json.data;
|
||||
const response = {};
|
||||
|
||||
if (data.member) {
|
||||
response.member = _.pick(data.member, memberFields);
|
||||
} else {
|
||||
response.member = null;
|
||||
}
|
||||
|
||||
if (data.created_at) {
|
||||
response.created_at = data.created_at;
|
||||
}
|
||||
|
||||
if (data.id) {
|
||||
response.id = data.id;
|
||||
}
|
||||
|
||||
response.count = {
|
||||
clicks: data.count?.clicks ?? 0
|
||||
};
|
||||
|
||||
return {
|
||||
...json,
|
||||
data: response
|
||||
};
|
||||
};
|
||||
|
||||
const feedbackEventMapper = (json, frame) => {
|
||||
const feedbackFields = [
|
||||
'id',
|
||||
@ -115,6 +143,9 @@ const activityFeedMapper = (event, frame) => {
|
||||
if (event.type === 'click_event') {
|
||||
return clickEventMapper(event, frame);
|
||||
}
|
||||
if (event.type === 'aggregated_click_event') {
|
||||
return aggregatedClickEventMapper(event, frame);
|
||||
}
|
||||
if (event.type === 'feedback_event') {
|
||||
return feedbackEventMapper(event, frame);
|
||||
}
|
||||
|
@ -108,6 +108,18 @@ module.exports = function (Bookshelf) {
|
||||
options.order = this.orderDefaultOptions();
|
||||
}
|
||||
|
||||
if (options.selectRaw) {
|
||||
itemCollection.query((qb) => {
|
||||
qb.select(qb.client.raw(options.selectRaw));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.whereRaw) {
|
||||
itemCollection.query((qb) => {
|
||||
qb.whereRaw(options.whereRaw);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await itemCollection.fetchPage(options);
|
||||
// Attributes are being filtered here, so they are not leaked into calling layer
|
||||
// where models are serialized to json and do not do more filtering.
|
||||
|
@ -42,6 +42,19 @@ const MemberClickEvent = ghostBookshelf.Model.extend({
|
||||
|
||||
async destroy() {
|
||||
throw new errors.IncorrectUsageError({message: 'Cannot destroy MemberClickEvent'});
|
||||
},
|
||||
|
||||
permittedOptions(methodName) {
|
||||
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
|
||||
const validOptions = {
|
||||
findPage: ['selectRaw', 'whereRaw']
|
||||
};
|
||||
|
||||
if (validOptions[methodName]) {
|
||||
options = options.concat(validOptions[methodName]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -59,7 +59,7 @@
|
||||
"@tryghost/api-framework": "0.0.0",
|
||||
"@tryghost/api-version-compatibility-service": "0.0.0",
|
||||
"@tryghost/audience-feedback": "0.0.0",
|
||||
"@tryghost/bookshelf-plugins": "0.6.0",
|
||||
"@tryghost/bookshelf-plugins": "0.6.1",
|
||||
"@tryghost/bootstrap-socket": "0.0.0",
|
||||
"@tryghost/color-utils": "0.1.21",
|
||||
"@tryghost/config-url-helpers": "1.0.3",
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,78 @@ const assert = require('assert');
|
||||
const moment = require('moment');
|
||||
|
||||
let agent;
|
||||
|
||||
async function testPagination(skippedTypes, postId, totalExpected) {
|
||||
const postFilter = postId ? `+data.post_id:${postId}` : '';
|
||||
|
||||
// To make the test cover more edge cases, we test different limit configurations
|
||||
for (let limit = 1; limit <= totalExpected; limit++) {
|
||||
const {body: firstPage} = await agent
|
||||
.get(`/members/events?filter=${encodeURIComponent(`type:-[${skippedTypes.join(',')}]${postFilter}`)}&limit=${limit}`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
events: new Array(limit).fill({
|
||||
type: anyString,
|
||||
data: anyObject
|
||||
})
|
||||
})
|
||||
.expect(({body}) => {
|
||||
if (postId) {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId && e.type !== 'aggregated_click_event'), 'Should only return events for the post');
|
||||
}
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, totalExpected, 'Expected total of ' + totalExpected + ' at limit ' + limit);
|
||||
});
|
||||
let previousPage = firstPage;
|
||||
let page = 1;
|
||||
|
||||
const allEvents = previousPage.events;
|
||||
|
||||
while (allEvents.length < totalExpected && page < 50) {
|
||||
page += 1;
|
||||
|
||||
// Calculate next page
|
||||
let lastId = previousPage.events[previousPage.events.length - 1].data.id;
|
||||
let lastCreatedAt = moment(previousPage.events[previousPage.events.length - 1].data.created_at).format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const remaining = totalExpected - (page - 1) * limit;
|
||||
|
||||
const {body: secondPage} = await agent
|
||||
.get(`/members/events?filter=${encodeURIComponent(`type:-[${skippedTypes.join(',')}]${postFilter}+(data.created_at:<'${lastCreatedAt}',(data.created_at:'${lastCreatedAt}'+id:<${lastId}))`)}&limit=${limit}`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
events: new Array(Math.min(remaining, limit)).fill({
|
||||
type: anyString,
|
||||
data: anyObject
|
||||
})
|
||||
})
|
||||
.expect(({body}) => {
|
||||
if (postId) {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId && e.type !== 'aggregated_click_event'), 'Should only return events for the post');
|
||||
}
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, remaining, 'Expected total to be correct for page ' + page + ' with limit ' + limit);
|
||||
});
|
||||
allEvents.push(...secondPage.events);
|
||||
}
|
||||
|
||||
// Check if the ordering is correct and we didn't receive duplicate events
|
||||
assert.equal(allEvents.length, totalExpected, 'Total actually received should match the total');
|
||||
for (const event of allEvents) {
|
||||
// Check no other events have the same id
|
||||
assert.equal(allEvents.filter(e => e.data.id === event.data.id).length, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Activity Feed API', function () {
|
||||
before(async function () {
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
@ -289,17 +361,18 @@ describe('Activity Feed API', function () {
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
events: new Array(15).fill({
|
||||
events: new Array(16).fill({
|
||||
type: anyString,
|
||||
data: anyObject
|
||||
})
|
||||
})
|
||||
.expect(({body}) => {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post');
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId && e.type !== 'aggregated_click_event'), 'Should only return events for the post');
|
||||
|
||||
// Check all post_id event types are covered by this test
|
||||
assert(body.events.find(e => e.type === 'click_event'), 'Expected a click event');
|
||||
assert(body.events.find(e => e.type === 'comment_event'), 'Expected a comment event');
|
||||
assert(body.events.find(e => e.type === 'aggregated_click_event'), 'Expected an aggregated click event');
|
||||
assert(body.events.find(e => e.type === 'feedback_event'), 'Expected a feedback event');
|
||||
assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event');
|
||||
assert(body.events.find(e => e.type === 'subscription_event'), 'Expected a subscription event');
|
||||
@ -308,80 +381,41 @@ describe('Activity Feed API', function () {
|
||||
assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event');
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, 15);
|
||||
assert.equal(body.meta.pagination.total, 16);
|
||||
});
|
||||
});
|
||||
|
||||
it('Can do filter based pagination', async function () {
|
||||
const totalExpected = 13;
|
||||
it('Can do filter based pagination for all posts', async function () {
|
||||
// There is an annoying restriction in the pagination. It doesn't work for mutliple email events at the same time because they have the same id (causes issues as we use id to deduplicate the created_at timestamp)
|
||||
// If that is ever fixed (it is difficult) we can update this test to not use a filter
|
||||
// Same for click_event and aggregated_click_event (use same id)
|
||||
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_delivered_event', 'aggregated_click_event'];
|
||||
await testPagination(skippedTypes, null, 37);
|
||||
});
|
||||
|
||||
it('Can do filter based pagination for one post', async function () {
|
||||
const postId = fixtureManager.get('posts', 0).id;
|
||||
|
||||
// There is an annoying restriction in the pagination. It doesn't work for mutliple email events at the same time because they have the same id (causes issues as we use id to deduplicate the created_at timestamp)
|
||||
// If that is ever fixed (it is difficult) we can update this test to not use a filter
|
||||
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_delivered_event'];
|
||||
// Same for click_event and aggregated_click_event (use same id)
|
||||
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_delivered_event', 'aggregated_click_event'];
|
||||
|
||||
// To make the test cover more edge cases, we test different limit configurations
|
||||
for (let limit = 1; limit <= totalExpected; limit++) {
|
||||
const {body: firstPage} = await agent
|
||||
.get(`/members/events?filter=${encodeURIComponent(`type:-[${skippedTypes.join(',')}]+data.post_id:${postId}`)}&limit=${limit}`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
events: new Array(limit).fill({
|
||||
type: anyString,
|
||||
data: anyObject
|
||||
})
|
||||
})
|
||||
.expect(({body}) => {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post');
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, totalExpected);
|
||||
await testPagination(skippedTypes, postId, 13);
|
||||
});
|
||||
let previousPage = firstPage;
|
||||
let page = 1;
|
||||
|
||||
const allEvents = previousPage.events;
|
||||
it('Can do filter based pagination for aggregated clicks for one post', async function () {
|
||||
// Same as previous but with aggregated clicks instead of normal click events + email_delivered_events instead of sent events
|
||||
const postId = fixtureManager.get('posts', 0).id;
|
||||
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_sent_event', 'click_event'];
|
||||
|
||||
while (allEvents.length < totalExpected && page < 20) {
|
||||
page += 1;
|
||||
|
||||
// Calculate next page
|
||||
let lastId = previousPage.events[previousPage.events.length - 1].data.id;
|
||||
let lastCreatedAt = moment(previousPage.events[previousPage.events.length - 1].data.created_at).format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const remaining = totalExpected - (page - 1) * limit;
|
||||
|
||||
const {body: secondPage} = await agent
|
||||
.get(`/members/events?filter=${encodeURIComponent(`type:-[${skippedTypes.join(',')}]+data.post_id:${postId}+(data.created_at:<'${lastCreatedAt}',(data.created_at:'${lastCreatedAt}'+id:<${lastId}))`)}&limit=${limit}`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
events: new Array(Math.min(remaining, limit)).fill({
|
||||
type: anyString,
|
||||
data: anyObject
|
||||
})
|
||||
})
|
||||
.expect(({body}) => {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post');
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, remaining, 'Expected total to be correct for page ' + page);
|
||||
await testPagination(skippedTypes, postId, 9);
|
||||
});
|
||||
allEvents.push(...secondPage.events);
|
||||
}
|
||||
|
||||
// Check if the ordering is correct and we didn't receive duplicate events
|
||||
assert.equal(allEvents.length, totalExpected);
|
||||
for (const event of allEvents) {
|
||||
// Check no other events have the same id
|
||||
assert.equal(allEvents.filter(e => e.data.id === event.data.id).length, 1);
|
||||
}
|
||||
}
|
||||
it('Can do filter based pagination for aggregated clicks for all posts', async function () {
|
||||
// Same as previous but with aggregated clicks instead of normal click events + email_delivered_events instead of sent events
|
||||
const skippedTypes = ['email_opened_event', 'email_failed_event', 'email_sent_event', 'click_event'];
|
||||
await testPagination(skippedTypes, null, 33);
|
||||
});
|
||||
|
||||
it('Can limit events', async function () {
|
||||
@ -402,7 +436,7 @@ describe('Activity Feed API', function () {
|
||||
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post');
|
||||
|
||||
// Assert total is correct
|
||||
assert.equal(body.meta.pagination.total, 15);
|
||||
assert.equal(body.meta.pagination.total, 16);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -61,6 +61,7 @@ module.exports = class EventRepository {
|
||||
const pageActions = [
|
||||
{type: 'comment_event', action: 'getCommentEvents'},
|
||||
{type: 'click_event', action: 'getClickEvents'},
|
||||
{type: 'aggregated_click_event', action: 'getAggregatedClickEvents'},
|
||||
{type: 'signup_event', action: 'getSignupEvents'},
|
||||
{type: 'subscription_event', action: 'getSubscriptionEvents'}
|
||||
];
|
||||
@ -238,11 +239,15 @@ module.exports = class EventRepository {
|
||||
const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options);
|
||||
|
||||
const data = models.map((model) => {
|
||||
const tierName = model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null;
|
||||
|
||||
// Prevent toJSON on stripeSubscription (we don't have everything loaded)
|
||||
delete model.relations.stripeSubscription;
|
||||
const d = {
|
||||
...model.toJSON(options),
|
||||
attribution: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id ? this._memberAttributionService.getEventAttribution(model.related('subscriptionCreatedEvent')) : null,
|
||||
signup: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id && model.related('subscriptionCreatedEvent').related('memberCreatedEvent') && model.related('subscriptionCreatedEvent').related('memberCreatedEvent').id ? true : false,
|
||||
tierName: model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null
|
||||
tierName
|
||||
};
|
||||
delete d.stripeSubscription;
|
||||
return {
|
||||
@ -473,6 +478,61 @@ module.exports = class EventRepository {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This groups click events per member for the same post, and only returns the first actual event, and includes the total clicks per event (for the same member and post)
|
||||
*/
|
||||
async getAggregatedClickEvents(options = {}, filter) {
|
||||
// This counts all clicks for a member for the same post
|
||||
const postClickQuery = `SELECT count(distinct A.redirect_id)
|
||||
FROM members_click_events A
|
||||
LEFT JOIN redirects A_r on A_r.id = A.redirect_id
|
||||
LEFT JOIN redirects B_r on B_r.id = members_click_events.redirect_id
|
||||
WHERE A.member_id = members_click_events.member_id AND A_r.post_id = B_r.post_id`;
|
||||
|
||||
// Counts all clicks for the same member, for the same post, but only preceding events. This should be zero to include the event (so we only include the first events)
|
||||
const postClickQueryPreceding = `SELECT count(distinct A.redirect_id)
|
||||
FROM members_click_events A
|
||||
LEFT JOIN redirects A_r on A_r.id = A.redirect_id
|
||||
LEFT JOIN redirects B_r on B_r.id = members_click_events.redirect_id
|
||||
WHERE A.member_id = members_click_events.member_id AND A_r.post_id = B_r.post_id AND (A.created_at < members_click_events.created_at OR (A.created_at = members_click_events.created_at AND A.id < members_click_events.id))`;
|
||||
|
||||
options = {
|
||||
...options,
|
||||
withRelated: ['member'],
|
||||
filter: 'custom:true',
|
||||
mongoTransformer: chainTransformers(
|
||||
// First set the filter manually
|
||||
replaceCustomFilterTransformer(filter),
|
||||
|
||||
// Map the used keys in that filter
|
||||
...mapKeys({
|
||||
'data.created_at': 'created_at',
|
||||
'data.member_id': 'member_id',
|
||||
'data.post_id': 'post_id'
|
||||
})
|
||||
),
|
||||
// We need to use MIN to make pagination work correctly
|
||||
// Note: we cannot do `count(distinct redirect_id) as count__clicks`, because we don't want the created_at filter to affect that count
|
||||
// For pagination to work correctly, we also need to return the id of the first event (or the minimum id if multiple events happend at the same time, but should be the first). Just MIN(id) won't work because that value changes if filter created_at < x is applied.
|
||||
selectRaw: `id, member_id, created_at, (${postClickQuery}) as count__clicks`,
|
||||
whereRaw: `(${postClickQueryPreceding}) = 0`
|
||||
};
|
||||
|
||||
const {data: models, meta} = await this._MemberLinkClickEvent.findPage(options);
|
||||
|
||||
const data = models.map((model) => {
|
||||
return {
|
||||
type: 'aggregated_click_event',
|
||||
data: model.toJSON(options)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
async getFeedbackEvents(options = {}, filter) {
|
||||
options = {
|
||||
...options,
|
||||
|
18
yarn.lock
18
yarn.lock
@ -4214,19 +4214,19 @@
|
||||
dependencies:
|
||||
lodash "^4.17.21"
|
||||
|
||||
"@tryghost/bookshelf-pagination@^0.1.30":
|
||||
version "0.1.30"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-pagination/-/bookshelf-pagination-0.1.30.tgz#40d104e742b874a854e3462242d5a7682088a3a9"
|
||||
integrity sha512-odMT7BqAlvK0FdRzg7RsrVrsQux2N+ALGpzqdkpS6Ck1hCA0cgwuml6EtjMaLvYWNm+nKHiS8+K14ba/YFOU0Q==
|
||||
"@tryghost/bookshelf-pagination@^0.1.31":
|
||||
version "0.1.31"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-pagination/-/bookshelf-pagination-0.1.31.tgz#7d6da3948b9bf70b6f4d9a12dc758931807669bf"
|
||||
integrity sha512-OwrcdtsNS/VrA7q9aDzJtfptB1xxt4TeRTd0wfwj3wJh/5jyE2M7lahRaJmc1WYnaSg/aC0wqJYNnKp53989OQ==
|
||||
dependencies:
|
||||
"@tryghost/errors" "^1.2.18"
|
||||
"@tryghost/tpl" "^0.1.19"
|
||||
lodash "^4.17.21"
|
||||
|
||||
"@tryghost/bookshelf-plugins@0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-plugins/-/bookshelf-plugins-0.6.0.tgz#6f037714cd381e90192bd8436a020f3299fce1e5"
|
||||
integrity sha512-WK0+Ap/cSImDbka2sksNcTZ3EGgB1g7YVGhSn6wblBu8p8JEEa7rIj6XiyOWKTaINoVmdX4mbUCMiZrGoHqW6g==
|
||||
"@tryghost/bookshelf-plugins@0.6.1":
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-plugins/-/bookshelf-plugins-0.6.1.tgz#31685cae7d4488c6837cbd7cc38872d53fbc672d"
|
||||
integrity sha512-HI5dsM0joGzCTf6GjeKBPNkwGacUPj+IOBUPci+CnNp1/CsodPEifnScTBdIhqp3eWisQzqoBIjeZWH00U7HGg==
|
||||
dependencies:
|
||||
"@tryghost/bookshelf-collision" "^0.1.28"
|
||||
"@tryghost/bookshelf-custom-query" "^0.1.15"
|
||||
@ -4235,7 +4235,7 @@
|
||||
"@tryghost/bookshelf-has-posts" "^0.1.19"
|
||||
"@tryghost/bookshelf-include-count" "^0.3.1"
|
||||
"@tryghost/bookshelf-order" "^0.1.15"
|
||||
"@tryghost/bookshelf-pagination" "^0.1.30"
|
||||
"@tryghost/bookshelf-pagination" "^0.1.31"
|
||||
"@tryghost/bookshelf-search" "^0.1.15"
|
||||
"@tryghost/bookshelf-transaction-events" "^0.2.0"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user