UNPKG

ghost

Version:

The professional publishing platform

205 lines (180 loc) 9.39 kB
const _ = require('lodash'); const debug = require('@tryghost/debug')('services:email-analytics'); const db = require('../../../data/db'); const logging = require('@tryghost/logging'); const {default: ObjectID} = require('bson-objectid'); const MIN_EMAIL_COUNT_FOR_OPEN_RATE = 5; /** @typedef {'email-analytics-latest-opened'|'email-analytics-latest-others'|'email-analytics-missing'|'email-analytics-scheduled'} EmailAnalyticsJobName */ /** @typedef {'delivered'|'opened'|'failed'} EmailAnalyticsEvent */ /** * Creates a job in the jobs table if it does not already exist. * @param {EmailAnalyticsJobName} jobName - The name of the job to create. * @returns {Promise<void>} */ async function createJobIfNotExists(jobName) { await db.knex('jobs').insert({ id: new ObjectID().toHexString(), name: jobName, started_at: new Date(), created_at: new Date(), status: 'started' }).onConflict('name').ignore(); } module.exports = { async shouldFetchStats() { // don't fetch stats from Mailgun if we haven't sent any emails const [emailCount] = await db.knex('emails').count('id as count'); return emailCount && emailCount.count > 0; }, /** * Retrieves the timestamp of the last seen event for the specified email analytics events. * @param {EmailAnalyticsJobName} jobName - The name of the job to update. * @param {EmailAnalyticsEvent[]} [events=['delivered', 'opened', 'failed']] - The email analytics events to consider. * @returns {Promise<Date|null>} The timestamp of the last seen event, or null if no events are found. */ async getLastEventTimestamp(jobName, events = ['delivered', 'opened', 'failed']) { const startDate = new Date(); let maxOpenedAt; let maxDeliveredAt; let maxFailedAt; const lastJobRunTimestamp = await this.getLastJobRunTimestamp(jobName); if (lastJobRunTimestamp) { debug(`Using job data for ${jobName}`); maxOpenedAt = events.includes('opened') ? lastJobRunTimestamp : null; maxDeliveredAt = events.includes('delivered') ? lastJobRunTimestamp : null; maxFailedAt = events.includes('failed') ? lastJobRunTimestamp : null; } else { debug(`Job data not found for ${jobName}, using email_recipients data`); logging.info(`Job data not found for ${jobName}, using email_recipients data`); if (events.includes('opened')) { maxOpenedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first()).maxOpenedAt; } if (events.includes('delivered')) { maxDeliveredAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first()).maxDeliveredAt; } if (events.includes('failed')) { maxFailedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first()).maxFailedAt; } await createJobIfNotExists(jobName); } // Convert string dates to Date objects for SQLite compatibility [maxOpenedAt, maxDeliveredAt, maxFailedAt] = [maxOpenedAt, maxDeliveredAt, maxFailedAt].map(date => ( date && !(date instanceof Date) ? new Date(date) : date )); const lastSeenEventTimestamp = _.max([maxOpenedAt, maxDeliveredAt, maxFailedAt]); debug(`getLastEventTimestamp: finished in ${Date.now() - startDate}ms`); return lastSeenEventTimestamp; }, /** * Retrieves the job data for the specified job name. * @param {EmailAnalyticsJobName} jobName - The name of the job to retrieve data for. * @returns {Promise<Object|null>} The job data, or null if no job data is found. */ async getJobData(jobName) { return await db.knex('jobs').select('finished_at', 'started_at').where('name', jobName).first(); }, /** * Retrieves the timestamp of the last job run for the specified job name. * @param {EmailAnalyticsJobName} jobName - The name of the job to retrieve the last run timestamp for. * @returns {Promise<Date|null>} The timestamp of the last job run, or null if no job data is found. */ async getLastJobRunTimestamp(jobName) { const jobData = await this.getJobData(jobName); return jobData ? jobData.finished_at || jobData.started_at : null; }, /** * Sets the timestamp of the last seen event for the specified email analytics events. * @param {EmailAnalyticsJobName} jobName - The name of the job to update. * @param {'finished'|'started'} field - The field to update. * @param {Date} date - The timestamp of the last seen event. * @returns {Promise<void>} * @description * Updates the `finished_at` or `started_at` column of the specified job in the `jobs` table with the provided timestamp. * This is used to keep track of the last time the job was run to avoid expensive queries following reboot. */ async setJobTimestamp(jobName, field, date) { // Convert string dates to Date objects for SQLite compatibility try { debug(`Setting ${field} timestamp for job ${jobName} to ${date}`); const updateField = field === 'finished' ? 'finished_at' : 'started_at'; const status = field === 'finished' ? 'finished' : 'started'; const result = await db.knex('jobs').update({[updateField]: date, updated_at: new Date(), status: status}).where('name', jobName); if (result === 0) { await db.knex('jobs').insert({ id: new ObjectID().toHexString(), name: jobName, [updateField]: date.toISOString(), // force to iso string for sqlite updated_at: date.toISOString(), // force to iso string for sqlite status: status }); } } catch (err) { debug(`Error setting ${field} timestamp for job ${jobName}: ${err.message}`); } }, /** * Sets the status of the specified email analytics job. * @param {EmailAnalyticsJobName} jobName - The name of the job to update. * @param {'started'|'finished'|'failed'} status - The new status of the job. * @returns {Promise<void>} * @description * Updates the `status` column of the specified job in the `jobs` table with the provided status. * This is used to keep track of the current state of the job. */ async setJobStatus(jobName, status) { debug(`Setting status for job ${jobName} to ${status}`); try { const result = await db.knex('jobs') .update({ status: status, updated_at: new Date() }) .where('name', jobName); if (result === 0) { await db.knex('jobs').insert({ id: new ObjectID().toHexString(), name: jobName, status: status, created_at: new Date(), updated_at: new Date() }); } } catch (err) { debug(`Error setting status for job ${jobName}: ${err.message}`); throw err; } }, async aggregateEmailStats(emailId, updateOpenedCount) { const [deliveredCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND delivered_at IS NOT NULL', [emailId]); const [failedCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND failed_at IS NOT NULL', [emailId]); const updateData = { delivered_count: deliveredCount.count, failed_count: failedCount.count }; if (updateOpenedCount) { const [openedCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND opened_at IS NOT NULL', [emailId]); updateData.opened_count = openedCount.count; } await db.knex('emails').update(updateData).where('id', emailId); }, async aggregateMemberStats(memberId) { const {trackedEmailCount} = await db.knex('email_recipients') .select(db.knex.raw('COUNT(email_recipients.id) as trackedEmailCount')) .leftJoin('emails', 'email_recipients.email_id', 'emails.id') .where('email_recipients.member_id', memberId) .where('emails.track_opens', true) .first() || {}; const [emailCount] = await db.knex('email_recipients').count('id as count').whereRaw('member_id = ?', [memberId]); const [emailOpenedCount] = await db.knex('email_recipients').count('id as count').whereRaw('member_id = ? AND opened_at IS NOT NULL', [memberId]); const updateQuery = { email_count: emailCount.count, email_opened_count: emailOpenedCount.count }; if (trackedEmailCount >= MIN_EMAIL_COUNT_FOR_OPEN_RATE) { updateQuery.email_open_rate = Math.round(emailOpenedCount.count / trackedEmailCount * 100); } await db.knex('members') .update(updateQuery) .where('id', memberId); } };