mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-19 16:42:17 +03:00
f124d142c9
refs https://github.com/TryGhost/Team/issues/1833 refs https://github.com/TryGhost/Team/issues/1834 We've added the attribution property to subscription and signup events when the flag is enabled. The attributions resource is fetched by creating multiple relations on the model, rather than polymorphic as we ran into issues with that as they can't be nullable/optional. The parse-member-event structure has been updated to make it easier to work with, specifically `getObject` is only used when the event is clickable, and there is now a join property which makes it easier to join the action and the object.
603 lines
20 KiB
JavaScript
603 lines
20 KiB
JavaScript
const errors = require('@tryghost/errors');
|
|
const nql = require('@tryghost/nql');
|
|
|
|
module.exports = class EventRepository {
|
|
constructor({
|
|
EmailRecipient,
|
|
MemberSubscribeEvent,
|
|
MemberPaymentEvent,
|
|
MemberStatusEvent,
|
|
MemberLoginEvent,
|
|
MemberCreatedEvent,
|
|
SubscriptionCreatedEvent,
|
|
MemberPaidSubscriptionEvent,
|
|
Comment,
|
|
labsService,
|
|
memberAttributionService
|
|
}) {
|
|
this._MemberSubscribeEvent = MemberSubscribeEvent;
|
|
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
|
|
this._MemberPaymentEvent = MemberPaymentEvent;
|
|
this._MemberStatusEvent = MemberStatusEvent;
|
|
this._MemberLoginEvent = MemberLoginEvent;
|
|
this._EmailRecipient = EmailRecipient;
|
|
this._Comment = Comment;
|
|
this._labsService = labsService;
|
|
this._MemberCreatedEvent = MemberCreatedEvent;
|
|
this._SubscriptionCreatedEvent = SubscriptionCreatedEvent;
|
|
this._memberAttributionService = memberAttributionService;
|
|
}
|
|
|
|
async registerPayment(data) {
|
|
await this._MemberPaymentEvent.add({
|
|
...data,
|
|
source: 'stripe'
|
|
});
|
|
}
|
|
|
|
async getNewsletterSubscriptionEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'newsletter'],
|
|
filter: []
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'newsletter_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getSubscriptionEvents(options = {}, filters = {}) {
|
|
if (!this._labsService.isSet('memberAttribution')){
|
|
options = {
|
|
...options,
|
|
withRelated: ['member'],
|
|
filter: []
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'subscription_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'subscriptionCreatedEvent.postAttribution', 'subscriptionCreatedEvent.userAttribution', 'subscriptionCreatedEvent.tagAttribution'],
|
|
filter: []
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'subscription_event',
|
|
data: {
|
|
...model.toJSON(options),
|
|
attribution: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id ? this._memberAttributionService.getEventAttribution(model.related('subscriptionCreatedEvent')) : null
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getPaymentEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member'],
|
|
filter: []
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'payment_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getLoginEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member'],
|
|
filter: []
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'login_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getSignupEvents(options = {}, filters = {}) {
|
|
if (!this._labsService.isSet('memberAttribution')){
|
|
options = {
|
|
...options,
|
|
withRelated: ['member'],
|
|
filter: ['from_status:null']
|
|
};
|
|
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);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'signup_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'postAttribution', 'userAttribution', 'tagAttribution'],
|
|
filter: []
|
|
};
|
|
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._MemberCreatedEvent.findPage(options);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'signup_event',
|
|
data: {
|
|
...model.toJSON(options),
|
|
attribution: this._memberAttributionService.getEventAttribution(model)
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getCommentEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'post', 'parent'],
|
|
filter: ['member_id:-null']
|
|
};
|
|
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._Comment.findPage(options);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'comment_event',
|
|
data: model.toJSON(options)
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getEmailDeliveredEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'email'],
|
|
filter: ['delivered_at:-null']
|
|
};
|
|
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:'));
|
|
}
|
|
options.filter = options.filter.join('+');
|
|
options.order = options.order.replace(/created_at/g, 'delivered_at');
|
|
|
|
const {data: models, meta} = await this._EmailRecipient.findPage(
|
|
options
|
|
);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'email_delivered_event',
|
|
data: {
|
|
member_id: model.get('member_id'),
|
|
created_at: model.get('delivered_at'),
|
|
member: model.related('member').toJSON(),
|
|
email: model.related('email').toJSON()
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getEmailOpenedEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'email'],
|
|
filter: ['opened_at:-null']
|
|
};
|
|
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:'));
|
|
}
|
|
options.filter = options.filter.join('+');
|
|
options.order = options.order.replace(/created_at/g, 'opened_at');
|
|
|
|
const {data: models, meta} = await this._EmailRecipient.findPage(
|
|
options
|
|
);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'email_opened_event',
|
|
data: {
|
|
member_id: model.get('member_id'),
|
|
created_at: model.get('opened_at'),
|
|
member: model.related('member').toJSON(),
|
|
email: model.related('email').toJSON()
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta
|
|
};
|
|
}
|
|
|
|
async getEmailFailedEvents(options = {}, filters = {}) {
|
|
options = {
|
|
...options,
|
|
withRelated: ['member', 'email'],
|
|
filter: ['failed_at:-null']
|
|
};
|
|
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:'));
|
|
}
|
|
options.filter = options.filter.join('+');
|
|
options.order = options.order.replace(/created_at/g, 'failed_at');
|
|
|
|
const {data: models, meta} = await this._EmailRecipient.findPage(
|
|
options
|
|
);
|
|
|
|
const data = models.map((model) => {
|
|
return {
|
|
type: 'email_failed_event',
|
|
data: {
|
|
member_id: model.get('member_id'),
|
|
created_at: model.get('failed_at'),
|
|
member: model.related('member').toJSON(),
|
|
email: model.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;
|
|
}
|
|
|
|
// Changing this order might need a change in the query functions
|
|
// because of the different underlying models.
|
|
options.order = 'created_at desc';
|
|
|
|
// Create a list of all events that can be queried
|
|
const pageActions = [
|
|
{type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
|
|
{type: 'subscription_event', action: 'getSubscriptionEvents'},
|
|
{type: 'login_event', action: 'getLoginEvents'},
|
|
{type: 'payment_event', action: 'getPaymentEvents'},
|
|
{type: 'signup_event', action: 'getSignupEvents'},
|
|
{type: 'comment_event', action: 'getCommentEvents'}
|
|
];
|
|
if (this._EmailRecipient) {
|
|
pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'});
|
|
pageActions.push({type: 'email_opened_event', action: 'getEmailOpenedEvents'});
|
|
pageActions.push({type: 'email_failed_event', action: 'getEmailFailedEvents'});
|
|
}
|
|
|
|
let filters = this.getNQLSubset(options.filter);
|
|
|
|
//Filter events to query
|
|
const filteredPages = filters.type ? pageActions.filter(page => nql(filters.type).queryJSON(page)) : pageActions;
|
|
|
|
//Start the promises
|
|
const pages = filteredPages.map(page => this[page.action](options, filters));
|
|
|
|
const allEventPages = await Promise.all(pages);
|
|
|
|
const allEvents = allEventPages.reduce((accumulator, page) => accumulator.concat(page.data), []);
|
|
|
|
return allEvents.sort((a, b) => {
|
|
return new Date(b.data.created_at) - new Date(a.data.created_at);
|
|
}).reduce((memo, event) => {
|
|
//disable the event filtering
|
|
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((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;
|
|
}
|
|
|
|
async getMRR() {
|
|
const results = await this._MemberPaidSubscriptionEvent.findAll({
|
|
aggregateMRRDeltas: true
|
|
});
|
|
|
|
const resultsJSON = results.toJSON();
|
|
|
|
const cumulativeResults = resultsJSON.reduce((accumulator, result) => {
|
|
if (!accumulator[result.currency]) {
|
|
return {
|
|
...accumulator,
|
|
[result.currency]: [{
|
|
date: result.date,
|
|
mrr: result.mrr_delta,
|
|
currency: result.currency
|
|
}]
|
|
};
|
|
}
|
|
return {
|
|
...accumulator,
|
|
[result.currency]: accumulator[result.currency].concat([{
|
|
date: result.date,
|
|
mrr: result.mrr_delta + accumulator[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((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
|
|
});
|
|
|
|
const resultsJSON = results.toJSON();
|
|
|
|
const cumulativeResults = resultsJSON.reduce((accumulator, result, index) => {
|
|
if (index === 0) {
|
|
return [{
|
|
date: result.date,
|
|
paid: result.paid_delta,
|
|
comped: result.comped_delta,
|
|
free: result.free_delta
|
|
}];
|
|
}
|
|
return accumulator.concat([{
|
|
date: result.date,
|
|
paid: result.paid_delta + accumulator[index - 1].paid,
|
|
comped: result.comped_delta + accumulator[index - 1].comped,
|
|
free: result.free_delta + accumulator[index - 1].free
|
|
}]);
|
|
}, []);
|
|
|
|
return cumulativeResults;
|
|
}
|
|
};
|