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.} */ 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; } };