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:
Simon Backx 2022-10-27 17:23:45 +02:00 committed by GitHub
parent 08427809ec
commit b916300ceb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 20932 additions and 546 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,8 +35,13 @@ export default class MembersEventFilter extends Helper {
let filterParts = [];
const excludedEventsArray = Array.from(excludedEventsSet).reject(isBlank);
if (excludedEventsArray.length > 0) {
filterParts.push(`type:-[${excludedEventsArray.join(',')}]`);
if (includeEvents !== null) {
filterParts.push(`type:[${includeEvents.join(',')}]`);
} else {
if (excludedEventsArray.length > 0) {
filterParts.push(`type:-[${excludedEventsArray.join(',')}]`);
}
}
if (member) {

View File

@ -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 ? '+' : '-';

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
await testPagination(skippedTypes, postId, 13);
});
// Assert total is correct
assert.equal(body.meta.pagination.total, totalExpected);
});
let previousPage = firstPage;
let page = 1;
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'];
const allEvents = previousPage.events;
while (allEvents.length < totalExpected && page < 20) {
page += 1;
await testPagination(skippedTypes, postId, 9);
});
// 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);
});
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);
});
});
});

View File

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

View File

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