Ghost/ghost/members-api/lib/repositories/event.js
Thibaut Patel 1370682a60 Added a function to parse a NQL subset
refs https://github.com/TryGhost/Team/issues/1277

- This will allow to filter events within `getEventTimeline`
- The subset of NQL has the following rules:
  - Only one level of filters, now parenthesis allowed
  - Only three filter keys allowed
  - No `or` allowed outside of the bracket notation (this is allowed: `type:-[email_opened_event,email_failed_event]` but this isn't: `type:1,data.created_at:1`)
- The return is an object with a NQL filter by allowed filter key
2022-01-24 18:53:14 +01:00

438 lines
13 KiB
JavaScript

const errors = require('@tryghost/errors');
const nql = require('@nexes/nql');
module.exports = class EventRepository {
constructor({
EmailRecipient,
MemberSubscribeEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
labsService
}) {
this._MemberSubscribeEvent = MemberSubscribeEvent;
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
this._MemberPaymentEvent = MemberPaymentEvent;
this._MemberStatusEvent = MemberStatusEvent;
this._MemberLoginEvent = MemberLoginEvent;
this._EmailRecipient = EmailRecipient;
this._labsService = labsService;
}
async registerPayment(data) {
await this._MemberPaymentEvent.add({
...data,
source: 'stripe'
});
}
async getNewsletterSubscriptionEvents(options = {}) {
options = {
...options,
withRelated: ['member'],
filter: ''
};
const {data: models, meta} = await this._MemberSubscribeEvent.findPage(options);
const data = models.map((data) => {
return {
type: 'newsletter_event',
data: data.toJSON(options)
};
});
return {
data,
meta
};
}
async getSubscriptionEvents(options = {}) {
options = {
...options,
withRelated: ['member'],
filter: ''
};
const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options);
const data = models.map((data) => {
return {
type: 'subscription_event',
data: data.toJSON(options)
};
});
return {
data,
meta
};
}
async getPaymentEvents(options = {}) {
options = {
...options,
withRelated: ['member'],
filter: ''
};
const {data: models, meta} = await this._MemberPaymentEvent.findPage(options);
const data = models.map((data) => {
return {
type: 'payment_event',
data: data.toJSON(options)
};
});
return {
data,
meta
};
}
async getLoginEvents(options = {}) {
options = {
...options,
withRelated: ['member'],
filter: ''
};
const {data: models, meta} = await this._MemberLoginEvent.findPage(options);
const data = models.map((data) => {
return {
type: 'login_event',
data: data.toJSON(options)
};
});
return {
data,
meta
};
}
async getSignupEvents(options = {}) {
options = {
...options,
withRelated: ['member'],
filter: 'from_status:null'
};
const {data: models, meta} = await this._MemberStatusEvent.findPage(options);
const data = models.map((data) => {
return {
type: 'signup_event',
data: data.toJSON(options)
};
});
return {
data,
meta
};
}
async getEmailDelieveredEvents(options = {}) {
options = {
...options,
withRelated: ['member', 'email'],
filter: 'delivered_at:-null'
};
const {data: models, meta} = await this._EmailRecipient.findPage(
options
);
const data = models.map((data) => {
return {
type: 'email_delivered_event',
data: {
member_id: data.get('member_id'),
created_at: data.get('delivered_at'),
member: data.related('member').toJSON(),
email: data.related('email').toJSON()
}
};
});
return {
data,
meta
};
}
async getEmailOpenedEvents(options = {}) {
options = {
...options,
withRelated: ['member', 'email'],
filter: 'opened_at:-null'
};
const {data: models, meta} = await this._EmailRecipient.findPage(
options
);
const data = models.map((data) => {
return {
type: 'email_opened_event',
data: {
member_id: data.get('member_id'),
created_at: data.get('opened_at'),
member: data.related('member').toJSON(),
email: data.related('email').toJSON()
}
};
});
return {
data,
meta
};
}
async getEmailFailedEvents(options = {}) {
options = {
...options,
withRelated: ['member', 'email'],
filter: 'failed_at:-null'
};
const {data: models, meta} = await this._EmailRecipient.findPage(
options
);
const data = models.map((data) => {
return {
type: 'email_failed_event',
data: {
member_id: data.get('member_id'),
created_at: data.get('failed_at'),
member: data.related('member').toJSON(),
email: data.related('email').toJSON()
}
};
});
return {
data,
meta
};
}
/**
* Extract a subset of NQL.
* There are only a few properties allowed.
* Parenthesis are forbidden.
* Only ANDs are supported when combining properties.
*/
getNQLSubset(filter) {
if (!filter) {
return {};
}
const lex = nql(filter).lex();
const allowedFilters = ['type','data.created_at','data.member_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;
}
}
return result;
}
async getEventTimeline(options = {}) {
if (!options.limit) {
options.limit = 10;
}
options.order = 'created_at desc';
const pages = [
this.getNewsletterSubscriptionEvents(options),
this.getSubscriptionEvents(options),
this.getLoginEvents(options),
this.getSignupEvents(options)
];
if (this._labsService.isSet('membersActivityFeed') && this._EmailRecipient) {
pages.push(this.getEmailDelieveredEvents(options));
pages.push(this.getEmailOpenedEvents(options));
pages.push(this.getEmailFailedEvents(options));
}
const allEventPages = await Promise.all(pages);
const allEvents = allEventPages.reduce((allEvents, page) => allEvents.concat(page.data), []);
return allEvents.sort((a, b) => {
return new Date(b.data.created_at) - new Date(a.data.created_at);
}).reduce((memo, event, i) => {
if (event.type === 'newsletter_event' && event.data.subscribed) {
const previousEvent = allEvents[i - 1];
const nextEvent = allEvents[i + 1];
const currentMember = event.data.member_id;
if (previousEvent && previousEvent.type === 'signup_event') {
const previousMember = previousEvent.data.member_id;
if (currentMember === previousMember) {
return memo;
}
}
if (nextEvent && nextEvent.type === 'signup_event') {
const nextMember = nextEvent.data.member_id;
if (currentMember === nextMember) {
return memo;
}
}
}
return memo.concat(event);
}, []).slice(0, options.limit);
}
async getSubscriptions() {
const results = await this._MemberSubscribeEvent.findAll({
aggregateSubscriptionDeltas: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result, index) => {
if (index === 0) {
return [{
date: result.date,
subscribed: result.subscribed_delta
}];
}
return cumulativeResults.concat([{
date: result.date,
subscribed: result.subscribed_delta + cumulativeResults[index - 1].subscribed
}]);
}, []);
return cumulativeResults;
}
async getMRR() {
const results = await this._MemberPaidSubscriptionEvent.findAll({
aggregateMRRDeltas: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result) => {
if (!cumulativeResults[result.currency]) {
return {
...cumulativeResults,
[result.currency]: [{
date: result.date,
mrr: result.mrr_delta,
currency: result.currency
}]
};
}
return {
...cumulativeResults,
[result.currency]: cumulativeResults[result.currency].concat([{
date: result.date,
mrr: result.mrr_delta + cumulativeResults[result.currency].slice(-1)[0].mrr,
currency: result.currency
}])
};
}, {});
return cumulativeResults;
}
async getVolume() {
const results = await this._MemberPaymentEvent.findAll({
aggregatePaymentVolume: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result) => {
if (!cumulativeResults[result.currency]) {
return {
...cumulativeResults,
[result.currency]: [{
date: result.date,
volume: result.volume_delta,
currency: result.currency
}]
};
}
return {
...cumulativeResults,
[result.currency]: cumulativeResults[result.currency].concat([{
date: result.date,
volume: result.volume_delta + cumulativeResults[result.currency].slice(-1)[0].volume,
currency: result.currency
}])
};
}, {});
return cumulativeResults;
}
async getStatuses() {
const results = await this._MemberStatusEvent.findAll({
aggregateStatusCounts: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result, index) => {
if (index === 0) {
return [{
date: result.date,
paid: result.paid_delta,
comped: result.comped_delta,
free: result.free_delta
}];
}
return cumulativeResults.concat([{
date: result.date,
paid: result.paid_delta + cumulativeResults[index - 1].paid,
comped: result.comped_delta + cumulativeResults[index - 1].comped,
free: result.free_delta + cumulativeResults[index - 1].free
}]);
}, []);
return cumulativeResults;
}
};