1370682a60
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
438 lines
13 KiB
JavaScript
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;
|
|
}
|
|
};
|