Improved filter support in activity API to allow pagination (#15684)

fixes https://github.com/TryGhost/Team/issues/2129

- This changes how the activity feed API parses the filter.
- We now parse the filter early to a MongoDB filter, and split it in two. One of the filters is applied to the pageActions, and the other one is used individually for every event type. We now allow to use grouping and OR's inside the filters because of this change. As long as we don't combine filters on 'type' with other filters inside grouped filters or OR, then it is allowed.
- We make use of mongoTransformer to manually inject a mongo filter without needing to parse it from a string value again (that would make it a lot harder because we would have to convert the splitted filter back to a string and we currently don't have methods for that).
- Added sorting by id for events with the same timestamp (required for reliable pagination)
- Added id to each event (required for pagination)
- Added more tests for filters
- Added test for pagination
- Removed unsued getSubscriptions and getVolume methods

Used new mongo utility methods introduced here: https://github.com/TryGhost/NQL/pull/49
This commit is contained in:
Simon Backx 2022-10-27 12:13:24 +02:00 committed by GitHub
parent 076e3c02b2
commit b911208b41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 3366 additions and 560 deletions

View File

@ -11,7 +11,7 @@ const actions = {
showNext: 'showNext'
};
export default class MembersEventsFetcher extends Resource {
export default class ActivityFeedFetcher extends Resource {
@service ajax;
@service ghostPaths;
@service store;
@ -27,7 +27,7 @@ export default class MembersEventsFetcher extends Resource {
@tracked shownEvents = 0;
@tracked totalEvents = 0;
// save last event's date of each page for easy navigation to previous page
// Save the pagination filter for each page so we can return easily
@tracked eventsBookmarks = [];
get value() {
@ -53,12 +53,12 @@ export default class MembersEventsFetcher extends Resource {
async setup() {
const currentTime = moment.utc().format('YYYY-MM-DD HH:mm:ss');
let filter = `data.created_at:<'${currentTime}'`;
this.eventsBookmarks.push(filter);
if (this.args.named.filter) {
filter += `+${this.args.named.filter}`;
}
this.eventsBookmarks.push(currentTime);
await this.loadEventsTask.perform({filter}, actions.showNext);
}
@ -66,20 +66,22 @@ export default class MembersEventsFetcher extends Resource {
loadNextPage() {
const lastEvent = this.data[this.data.length - 1];
const lastEventDate = moment.utc(lastEvent.data.created_at).format('YYYY-MM-DD HH:mm:ss');
let filter = `data.created_at:<'${lastEventDate}'`;
const lastEventId = lastEvent.data.id;
let filter = `(data.created_at:<'${lastEventDate}',(data.created_at:'${lastEventDate}'+id:<'${lastEventId}'))`;
this.eventsBookmarks.push(filter);
if (this.args.named.filter) {
filter += `+${this.args.named.filter}`;
}
this.eventsBookmarks.push(lastEventDate);
this.loadEventsTask.perform({filter}, actions.showNext);
}
@action
loadPreviousPage() {
this.eventsBookmarks.pop();
let filter = `data.created_at:<'${this.eventsBookmarks[this.eventsBookmarks.length - 1]}'`;
let filter = this.eventsBookmarks[this.eventsBookmarks.length - 1];
if (this.args.named.filter) {
filter += `+${this.args.named.filter}`;

View File

@ -53,6 +53,10 @@ const clickEventMapper = (json, frame) => {
response.created_at = data.created_at;
}
if (data.id) {
response.id = data.id;
}
return {
...json,
data: response

View File

@ -1,8 +1,9 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyEtag, anyObjectId, anyUuid, anyISODate, anyString, anyObject, anyNumber} = matchers;
const {anyEtag, anyErrorId, anyObjectId, anyUuid, anyISODate, anyString, anyObject, anyNumber} = matchers;
const models = require('../../../core/server/models');
const assert = require('assert');
const moment = require('moment');
let agent;
describe('Activity Feed API', function () {
@ -21,6 +22,97 @@ describe('Activity Feed API', function () {
mockManager.restore();
});
describe('Filter splitting',function () {
it('Can use NQL OR for type only', async function () {
// Check activity feed
await agent
.get(`/members/events?filter=type:comment_event,type:click_event`)
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(10).fill({
type: anyString,
data: anyObject
})
})
.expect(({body}) => {
assert(!body.events.find(e => e.type !== 'click_event' && e.type !== 'comment_event'), 'Expected only click and comment events');
});
});
it('Cannot combine type filter with OR filter', async function () {
// This query is not allowed because we need to split the filter in two AND filters
await agent
.get(`/members/events?filter=type:comment_event,data.post_id:123`)
.expectStatus(400)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
it('Can only combine type and other filters at the root level', async function () {
await agent
.get(`/members/events?filter=${encodeURIComponent('(type:comment_event+data.post_id:123)+data.post_id:123')}`)
.expectStatus(400)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
it('Can use OR as long as it is not combined with type', async function () {
const postId = fixtureManager.get('posts', 0).id;
const memberId = fixtureManager.get('members', 0).id;
await agent
.get(`/members/events?filter=${encodeURIComponent(`data.post_id:${postId},data.member_id:${memberId}`)}`)
.expectStatus(200)
.matchBodySnapshot({
events: new Array(10).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 && e.data?.member?.id !== memberId), 'Expected only events either from the given post or member');
});
});
it('Can AND two ORs', async function () {
const postId = fixtureManager.get('posts', 0).id;
const memberId = fixtureManager.get('members', 0).id;
await agent
.get(`/members/events?filter=${encodeURIComponent(`(type:comment_event,type:click_event)+(data.post_id:${postId},data.member_id:${memberId})`)}`)
.expectStatus(200)
.matchBodySnapshot({
events: new Array(3).fill({
type: anyString,
data: anyObject
})
})
.expect(({body}) => {
assert(!body.events.find(e => e.type !== 'click_event' && e.type !== 'comment_event'), 'Expected only click and comment events');
assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId && e.data?.member?.id !== memberId), 'Expected only events either from the given post or member');
});
});
});
// Activity feed
it('Returns comments in activity feed', async function () {
// Check activity feed
@ -54,6 +146,7 @@ describe('Activity Feed API', function () {
events: new Array(8).fill({
type: anyString,
data: {
id: anyObjectId,
created_at: anyISODate,
member: {
id: anyObjectId,
@ -219,6 +312,78 @@ describe('Activity Feed API', function () {
});
});
it('Can do filter based pagination', async function () {
const totalExpected = 13;
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'];
// 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);
});
let previousPage = firstPage;
let page = 1;
const allEvents = previousPage.events;
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);
});
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 limit events', async function () {
const postId = fixtureManager.get('posts', 0).id;
await agent

View File

@ -15,6 +15,18 @@ const memberAttributionService = require('../../../core/server/services/member-a
const urlService = require('../../../core/server/services/url');
const urlUtils = require('../../../core/shared/url-utils');
/**
* Assert that haystack and needles match, ignoring the order.
*/
function matchArrayWithoutOrder(haystack, needles) {
// Order shouldn't matter here
for (const a of needles) {
haystack.should.matchAny(a);
}
assert.equal(haystack.length, needles.length, `Expected ${needles.length} items, but got ${haystack.length}`);
}
async function assertMemberEvents({eventType, memberId, asserts}) {
const events = await models[eventType].where('member_id', memberId).fetchAll();
const eventsJSON = events.map(e => e.toJSON());
@ -1728,7 +1740,9 @@ describe('Members API', function () {
});
const events = eventsBody.events;
events.should.match([
// The order will be different in each test because two newsletter_events have the same created_at timestamp. And events are ordered by created_at desc, id desc (id will be different each time).
matchArrayWithoutOrder(events, [
{
type: 'newsletter_event',
data: {

View File

@ -1,5 +1,19 @@
const errors = require('@tryghost/errors');
const nql = require('@tryghost/nql');
const mingo = require('mingo');
const {replaceFilters, expandFilters, splitFilter, getUsedKeys, chainTransformers, mapKeys} = require('@tryghost/mongo-utils');
/**
* This mongo transformer ignores the provided filter option and replaces the filter with a custom filter that was provided to the transformer. Allowing us to set a mongo filter instead of a string based NQL filter.
*/
function replaceCustomFilterTransformer(filter) {
// Instead of adding an existing filter, we replace a filter, because mongo transformers are only applied if there is any filter (so not executed for empty filters)
return function (existingFilter) {
return replaceFilters(existingFilter, {
custom: filter
});
};
}
module.exports = class EventRepository {
constructor({
@ -36,11 +50,12 @@ module.exports = class EventRepository {
if (!options.limit) {
options.limit = 10;
}
let filters = this.getNQLSubset(options.filter);
const [typeFilter, otherFilter] = this.getNQLSubset(options.filter);
// Changing this order might need a change in the query functions
// because of the different underlying models.
options.order = 'created_at desc';
options.order = 'created_at desc, id desc';
// Create a list of all events that can be queried
const pageActions = [
@ -51,7 +66,7 @@ module.exports = class EventRepository {
];
// Some events are not filterable by post_id
if (!filters['data.post_id']) {
if (!getUsedKeys(otherFilter).includes('data.post_id')) {
pageActions.push(
{type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
{type: 'login_event', action: 'getLoginEvents'},
@ -71,10 +86,17 @@ module.exports = class EventRepository {
}
//Filter events to query
const filteredPages = filters.type ? pageActions.filter(page => nql(filters.type).queryJSON(page)) : pageActions;
let filteredPages = pageActions;
if (typeFilter) {
// Ideally we should be able to create a NQL filter without having a string
const query = new mingo.Query(typeFilter);
filteredPages = filteredPages.filter(page => query.test(page));
}
//Start the promises
const pages = filteredPages.map(page => this[page.action](options, filters));
const pages = filteredPages.map((page) => {
return this[page.action](options, otherFilter);
});
const allEventPages = await Promise.all(pages);
@ -84,7 +106,11 @@ module.exports = class EventRepository {
return {
events: allEvents.sort(
(a, b) => {
return new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime();
const diff = new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime();
if (diff !== 0) {
return diff;
}
return b.data.id.localeCompare(a.data.id);
}
).slice(0, options.limit),
meta: {
@ -109,22 +135,23 @@ module.exports = class EventRepository {
});
}
async getNewsletterSubscriptionEvents(options = {}, filters = {}) {
async getNewsletterSubscriptionEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'newsletter'],
filter: []
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.source': 'source',
'data.member_id': 'member_id'
})
)
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:'));
}
if (filters['data.source']) {
options.filter.push(filters['data.source'].replace(/data.source:/g, 'source:'));
}
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._MemberSubscribeEvent.findPage(options);
@ -141,20 +168,23 @@ module.exports = class EventRepository {
};
}
async getSubscriptionEvents(options = {}, filters = {}) {
async getSubscriptionEvents(options = {}, filter) {
if (!this._labsService.isSet('memberAttribution')){
options = {
...options,
withRelated: ['member'],
filter: []
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'
})
)
};
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);
@ -183,19 +213,27 @@ module.exports = class EventRepository {
// This is rediculous, but we need the tier name (we'll be able to shorten this later when we switch to the subscriptions table)
'stripeSubscription.stripePrice.stripeProduct.product'
],
filter: []
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'
}),
(f) => {
// Special one: when data.post_id is used, replace it with two filters: subscriptionCreatedEvent.attribution_id:x+subscriptionCreatedEvent.attribution_type:post
return expandFilters(f, [{
key: 'data.post_id',
replacement: 'subscriptionCreatedEvent.attribution_id',
expansion: {'subscriptionCreatedEvent.attribution_type': 'post'}
}]);
}
)
};
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:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'subscriptionCreatedEvent.attribution_id:'));
options.filter.push('subscriptionCreatedEvent.attribution_type:post');
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options);
@ -219,19 +257,22 @@ module.exports = class EventRepository {
};
}
async getPaymentEvents(options = {}, filters = {}) {
async getPaymentEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member'],
filter: []
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'
})
)
};
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._MemberPaymentEvent.findPage(options);
@ -248,19 +289,22 @@ module.exports = class EventRepository {
};
}
async getLoginEvents(options = {}, filters = {}) {
async getLoginEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member'],
filter: []
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'
})
)
};
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._MemberLoginEvent.findPage(options);
@ -277,20 +321,23 @@ module.exports = class EventRepository {
};
}
async getSignupEvents(options = {}, filters = {}) {
async getSignupEvents(options = {}, filter) {
if (!this._labsService.isSet('memberAttribution')){
options = {
...options,
withRelated: ['member'],
filter: ['from_status:null']
filter: 'from_status:null+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'
})
)
};
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);
@ -307,10 +354,10 @@ module.exports = class EventRepository {
};
}
return this.getCreatedEvents(options, filters);
return this.getCreatedEvents(options, filter);
}
async getCreatedEvents(options = {}, filters = {}) {
async getCreatedEvents(options = {}, filter) {
options = {
...options,
withRelated: [
@ -319,22 +366,28 @@ module.exports = class EventRepository {
'userAttribution',
'tagAttribution'
],
filter: ['subscriptionCreatedEvent.id:null']
filter: 'subscriptionCreatedEvent.id:null+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.source': 'source'
}),
(f) => {
// Special one: when data.post_id is used, replace it with two filters: attribution_id:x+attribution_type:post
return expandFilters(f, [{
key: 'data.post_id',
replacement: 'attribution_id',
expansion: {attribution_type: 'post'}
}]);
}
)
};
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:'));
}
if (filters['data.source']) {
options.filter.push(filters['data.source'].replace(/data.source:/g, 'source:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'attribution_id:'));
options.filter.push('attribution_type:post');
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberCreatedEvent.findPage(options);
@ -354,22 +407,23 @@ module.exports = class EventRepository {
};
}
async getCommentEvents(options = {}, filters = {}) {
async getCommentEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'post', 'parent'],
filter: ['member_id:-null']
filter: 'member_id:-null+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'
})
)
};
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:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'post_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._Comment.findPage(options);
@ -386,22 +440,23 @@ module.exports = class EventRepository {
};
}
async getClickEvents(options = {}, filters = {}) {
async getClickEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'link', 'link.post'],
filter: []
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'
})
)
};
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:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'post_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberLinkClickEvent.findPage(options);
@ -418,22 +473,23 @@ module.exports = class EventRepository {
};
}
async getFeedbackEvents(options = {}, filters = {}) {
async getFeedbackEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'post'],
filter: []
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'
})
)
};
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:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'post_id:'));
}
options.filter = options.filter.join('+');
const {data: models, meta} = await this._MemberFeedback.findPage(options);
@ -450,22 +506,23 @@ module.exports = class EventRepository {
};
}
async getEmailSentEvents(options = {}, filters = {}) {
async getEmailSentEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'email'],
filter: ['failed_at:null', 'processed_at:-null']
filter: 'failed_at:null+processed_at:-null+custom:true',
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
// Map the used keys in that filter
...mapKeys({
'data.created_at': 'processed_at',
'data.member_id': 'member_id',
'data.post_id': 'email.post_id'
})
)
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'processed_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'email.post_id:'));
}
options.filter = options.filter.join('+');
options.order = options.order.replace(/created_at/g, 'processed_at');
const {data: models, meta} = await this._EmailRecipient.findPage(
@ -476,6 +533,7 @@ module.exports = class EventRepository {
return {
type: 'email_sent_event',
data: {
id: model.id,
member_id: model.get('member_id'),
created_at: model.get('processed_at'),
member: model.related('member').toJSON(),
@ -490,22 +548,23 @@ module.exports = class EventRepository {
};
}
async getEmailDeliveredEvents(options = {}, filters = {}) {
async getEmailDeliveredEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'email'],
filter: ['delivered_at:-null']
filter: 'delivered_at:-null+custom:true',
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
// Map the used keys in that filter
...mapKeys({
'data.created_at': 'delivered_at',
'data.member_id': 'member_id',
'data.post_id': 'email.post_id'
})
)
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'delivered_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'email.post_id:'));
}
options.filter = options.filter.join('+');
options.order = options.order.replace(/created_at/g, 'delivered_at');
const {data: models, meta} = await this._EmailRecipient.findPage(
@ -516,6 +575,7 @@ module.exports = class EventRepository {
return {
type: 'email_delivered_event',
data: {
id: model.id,
member_id: model.get('member_id'),
created_at: model.get('delivered_at'),
member: model.related('member').toJSON(),
@ -530,22 +590,23 @@ module.exports = class EventRepository {
};
}
async getEmailOpenedEvents(options = {}, filters = {}) {
async getEmailOpenedEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'email'],
filter: ['opened_at:-null']
filter: 'opened_at:-null+custom:true',
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
// Map the used keys in that filter
...mapKeys({
'data.created_at': 'opened_at',
'data.member_id': 'member_id',
'data.post_id': 'email.post_id'
})
)
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'opened_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'email.post_id:'));
}
options.filter = options.filter.join('+');
options.order = options.order.replace(/created_at/g, 'opened_at');
const {data: models, meta} = await this._EmailRecipient.findPage(
@ -556,6 +617,7 @@ module.exports = class EventRepository {
return {
type: 'email_opened_event',
data: {
id: model.id,
member_id: model.get('member_id'),
created_at: model.get('opened_at'),
member: model.related('member').toJSON(),
@ -570,22 +632,23 @@ module.exports = class EventRepository {
};
}
async getEmailFailedEvents(options = {}, filters = {}) {
async getEmailFailedEvents(options = {}, filter) {
options = {
...options,
withRelated: ['member', 'email'],
filter: ['failed_at:-null']
filter: 'failed_at:-null+custom:true',
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
// Map the used keys in that filter
...mapKeys({
'data.created_at': 'failed_at',
'data.member_id': 'member_id',
'data.post_id': 'email.post_id'
})
)
};
if (filters['data.created_at']) {
options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'failed_at:'));
}
if (filters['data.member_id']) {
options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:'));
}
if (filters['data.post_id']) {
options.filter.push(filters['data.post_id'].replace(/data.post_id:/g, 'email.post_id:'));
}
options.filter = options.filter.join('+');
options.order = options.order.replace(/created_at/g, 'failed_at');
const {data: models, meta} = await this._EmailRecipient.findPage(
@ -596,6 +659,7 @@ module.exports = class EventRepository {
return {
type: 'email_failed_event',
data: {
id: model.id,
member_id: model.get('member_id'),
created_at: model.get('failed_at'),
member: model.related('member').toJSON(),
@ -611,83 +675,36 @@ module.exports = class EventRepository {
}
/**
* Extract a subset of NQL.
* There are only a few properties allowed.
* Parenthesis are forbidden.
* Only ANDs are supported when combining properties.
* Split the filter in two parts:
* - One with 'type' that will be applied to all the pages
* - Other filter that will be applied to each individual page
*
* Throws if splitting is not possible (e.g. OR'ing type with other filters)
*/
getNQLSubset(filter) {
if (!filter) {
return {};
return [undefined, undefined];
}
const lex = nql(filter).lex();
const allowList = ['data.created_at', 'data.member_id', 'data.post_id', 'type', 'id'];
const parsed = nql(filter).parse();
const keys = getUsedKeys(parsed);
const allowedFilters = ['type','data.created_at','data.member_id', 'data.post_id'];
const properties = lex
.filter(x => x.token === 'PROP')
.map(x => x.matched.slice(0, -1));
if (properties.some(prop => !allowedFilters.includes(prop))) {
throw new errors.IncorrectUsageError({
message: 'The only allowed filters are `type`, `data.created_at` and `data.member_id`'
});
}
if (lex.find(x => x.token === 'LPAREN')) {
throw new errors.IncorrectUsageError({
message: 'The filter can\'t contain parenthesis.'
});
}
const jsonFilter = nql(filter).toJSON();
const keys = Object.keys(jsonFilter);
if (keys.length === 1 && keys[0] === '$or') {
throw new errors.IncorrectUsageError({
message: 'The top level-filters can only combined with ANDs (+) and not ORs (,).'
});
}
// The filter is validated, it only contains one level of filters concatenated with `+`
const filters = filter.split('+');
/** @type {Object.<string, string>} */
let result = {};
for (const f of filters) {
// dirty way to parse a property, but it works according to https://github.com/NexesJS/NQL-Lang/blob/0e12d799a3a9c4d8651444e9284ce16c19cbc4f0/src/nql.l#L18
const key = f.split(':')[0];
if (!result[key]) {
result[key] = f;
} else {
result[key] += '+' + f;
for (const key of keys) {
if (!allowList.includes(key)) {
throw new errors.IncorrectUsageError({
message: 'Cannot filter by ' + key
});
}
}
return result;
}
async getSubscriptions() {
const results = await this._MemberSubscribeEvent.findAll({
aggregateSubscriptionDeltas: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((accumulator, result, index) => {
if (index === 0) {
return [{
date: result.date,
subscribed: result.subscribed_delta
}];
}
return accumulator.concat([{
date: result.date,
subscribed: result.subscribed_delta + accumulator[index - 1].subscribed
}]);
}, []);
return cumulativeResults;
try {
return splitFilter(parsed, ['type']);
} catch (e) {
throw new errors.IncorrectUsageError({
message: e.message
});
}
}
async getMRR() {
@ -721,37 +738,6 @@ module.exports = class EventRepository {
return cumulativeResults;
}
async getVolume() {
const results = await this._MemberPaymentEvent.findAll({
aggregatePaymentVolume: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((accumulator, result) => {
if (!accumulator[result.currency]) {
return {
...accumulator,
[result.currency]: [{
date: result.date,
volume: result.volume_delta,
currency: result.currency
}]
};
}
return {
...accumulator,
[result.currency]: accumulator[result.currency].concat([{
date: result.date,
volume: result.volume_delta + accumulator[result.currency].slice(-1)[0].volume,
currency: result.currency
}])
};
}, {});
return cumulativeResults;
}
async getStatuses() {
const results = await this._MemberStatusEvent.findAll({
aggregateStatusCounts: true

View File

@ -19,15 +19,6 @@ describe('EventRepository', function () {
});
});
it('throws when processing a filter with parenthesis', function () {
should.throws(() => {
eventRepository.getNQLSubset('(type:1)');
}, errors.IncorrectUsageError);
should.throws(() => {
eventRepository.getNQLSubset('type:1+(data.created_at:1+data.member_id:1)');
}, errors.IncorrectUsageError);
});
it('throws when using properties that aren\'t in the allowlist', function () {
should.throws(() => {
eventRepository.getNQLSubset('(types:1)');
@ -50,29 +41,70 @@ describe('EventRepository', function () {
it('passes when using it correctly with one filter', function () {
const res = eventRepository.getNQLSubset('type:email_delivered_event');
res.should.be.an.Object();
res.should.deepEqual({
type: 'type:email_delivered_event'
res.should.be.an.Array();
res.should.have.lengthOf(2);
res[0].should.eql({
type: 'email_delivered_event'
});
should(res[1]).be.undefined();
});
it('passes when using it correctly with multiple filters', function () {
const res = eventRepository.getNQLSubset('type:-[email_delivered_event,email_opened_event,email_failed_event]+data.created_at:<0+data.member_id:123');
res.should.be.an.Object();
res.should.deepEqual({
'data.created_at': 'data.created_at:<0',
'data.member_id': 'data.member_id:123',
type: 'type:-[email_delivered_event,email_opened_event,email_failed_event]'
res.should.be.an.Array();
res.should.have.lengthOf(2);
res[0].should.eql({
type: {
$nin: [
'email_delivered_event',
'email_opened_event',
'email_failed_event'
]
}
});
res[1].should.eql({
$and: [{
'data.created_at': {
$lt: 0
}
}, {
'data.member_id': 123
}]
});
});
it('passes when using it correctly with multiple filters used several times', function () {
const res = eventRepository.getNQLSubset('type:-email_delivered_event+data.created_at:<0+data.member_id:123+type:-[email_opened_event,email_failed_event]+data.created_at:>10');
res.should.be.an.Object();
res.should.deepEqual({
'data.created_at': 'data.created_at:<0+data.created_at:>10',
'data.member_id': 'data.member_id:123',
type: 'type:-email_delivered_event+type:-[email_opened_event,email_failed_event]'
res.should.be.an.Array();
res.should.have.lengthOf(2);
res[0].should.eql({
$and: [{
type: {
$ne: 'email_delivered_event'
}
}, {
type: {
$nin: [
'email_opened_event',
'email_failed_event'
]
}
}]
});
res[1].should.eql({
$and: [{
'data.created_at': {
$lt: 0
}
}, {
'data.member_id': 123
}, {
'data.created_at': {
$gt: 10
}
}]
});
});
});
@ -106,20 +138,23 @@ describe('EventRepository', function () {
}, {
type: 'unused'
});
fake.calledOnceWithExactly({
sinon.assert.calledOnce(fake);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'newsletter'],
filter: ''
}).should.be.eql(true);
filter: 'custom:true'
});
});
it('works when setting a created_at filter', async function () {
await eventRepository.getNewsletterSubscriptionEvents({}, {
'data.created_at': 'data.created_at:123'
});
fake.calledOnceWithExactly({
sinon.assert.calledOnce(fake);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'newsletter'],
filter: 'created_at:123'
}).should.be.eql(true);
filter: 'custom:true'
});
});
it('works when setting a combination of filters', async function () {
@ -127,10 +162,12 @@ describe('EventRepository', function () {
'data.created_at': 'data.created_at:123+data.created_at:<99999',
'data.member_id': 'data.member_id:-[3,4,5]+data.member_id:-[1,2,3]'
});
fake.calledOnceWithExactly({
sinon.assert.calledOnce(fake);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'newsletter'],
filter: 'created_at:123+created_at:<99999+member_id:-[3,4,5]+member_id:-[1,2,3]'
}).should.be.eql(true);
filter: 'custom:true'
});
});
});
@ -160,42 +197,48 @@ describe('EventRepository', function () {
it('works when setting no filters', async function () {
await eventRepository.getEmailFailedEvents({
filter: 'no used',
order: 'created_at desc'
order: 'created_at desc, id desc'
}, {
type: 'unused'
});
fake.calledOnceWithExactly({
fake.calledOnce.should.be.eql(true);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'email'],
filter: 'failed_at:-null',
order: 'failed_at desc'
}).should.be.eql(true);
filter: 'failed_at:-null+custom:true',
order: 'failed_at desc, id desc'
});
});
it('works when setting a created_at filter', async function () {
await eventRepository.getEmailDeliveredEvents({
order: 'created_at desc'
order: 'created_at desc, id desc'
}, {
'data.created_at': 'data.created_at:123'
});
fake.calledOnceWithExactly({
fake.calledOnce.should.be.eql(true);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'email'],
filter: 'delivered_at:-null+delivered_at:123',
order: 'delivered_at desc'
}).should.be.eql(true);
filter: 'delivered_at:-null+custom:true',
order: 'delivered_at desc, id desc'
});
});
it('works when setting a combination of filters', async function () {
await eventRepository.getEmailOpenedEvents({
order: 'created_at desc'
order: 'created_at desc, id desc'
}, {
'data.created_at': 'data.created_at:123+data.created_at:<99999',
'data.member_id': 'data.member_id:-[3,4,5]+data.member_id:-[1,2,3]'
});
fake.calledOnceWithExactly({
fake.calledOnce.should.be.eql(true);
fake.getCall(0).firstArg.should.match({
withRelated: ['member', 'email'],
filter: 'opened_at:-null+opened_at:123+opened_at:<99999+member_id:-[3,4,5]+member_id:-[1,2,3]',
order: 'opened_at desc'
}).should.be.eql(true);
filter: 'opened_at:-null+custom:true',
order: 'opened_at desc, id desc'
});
});
});
});