2021-06-22 13:07:16 +03:00
|
|
|
const moment = require('moment-timezone');
|
|
|
|
const path = require('path');
|
|
|
|
const fs = require('fs-extra');
|
|
|
|
const membersCSV = require('@tryghost/members-csv');
|
2021-07-19 13:46:38 +03:00
|
|
|
const errors = require('@tryghost/errors');
|
|
|
|
const tpl = require('@tryghost/tpl');
|
2021-07-20 17:42:26 +03:00
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
const emailTemplate = require('./email-template');
|
|
|
|
|
2021-07-19 13:46:38 +03:00
|
|
|
const messages = {
|
|
|
|
filenameCollision: 'Filename already exists, please try again.',
|
|
|
|
jobAlreadyComplete: 'Job is already complete.'
|
|
|
|
};
|
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
module.exports = class MembersCSVImporter {
|
|
|
|
/**
|
2021-07-20 18:34:22 +03:00
|
|
|
* @param {Object} options
|
2021-07-20 18:35:49 +03:00
|
|
|
* @param {string} options.storagePath - The path to store CSV's in before importing
|
2021-07-21 10:48:05 +03:00
|
|
|
* @param {Function} options.getTimezone - function returning currently configured timezone
|
2021-07-20 18:34:22 +03:00
|
|
|
* @param {() => Object} options.getMembersApi
|
2021-07-21 10:49:47 +03:00
|
|
|
* @param {Function} options.sendEmail - function sending an email
|
2021-07-20 18:34:22 +03:00
|
|
|
* @param {(string) => boolean} options.isSet - Method checking if specific feature is enabled
|
|
|
|
* @param {({name, at, job, data, offloaded}) => void} options.addJob - Method registering an async job
|
|
|
|
* @param {Object} options.knex - An instance of the Ghost Database connection
|
|
|
|
* @param {Function} options.urlFor - function generating urls
|
2022-04-06 22:12:38 +03:00
|
|
|
* @param {Object} options.context
|
2021-06-22 13:07:16 +03:00
|
|
|
*/
|
2022-04-06 22:12:38 +03:00
|
|
|
constructor({storagePath, getTimezone, getMembersApi, sendEmail, isSet, addJob, knex, urlFor, context}) {
|
2021-07-20 18:35:49 +03:00
|
|
|
this._storagePath = storagePath;
|
2021-07-21 10:48:05 +03:00
|
|
|
this._getTimezone = getTimezone;
|
2021-06-22 13:07:16 +03:00
|
|
|
this._getMembersApi = getMembersApi;
|
2021-07-21 10:49:47 +03:00
|
|
|
this._sendEmail = sendEmail;
|
2021-07-20 17:42:57 +03:00
|
|
|
this._isSet = isSet;
|
2021-07-20 18:21:59 +03:00
|
|
|
this._addJob = addJob;
|
2021-07-20 18:26:46 +03:00
|
|
|
this._knex = knex;
|
2021-07-20 18:31:22 +03:00
|
|
|
this._urlFor = urlFor;
|
2022-04-06 22:12:38 +03:00
|
|
|
this._context = context;
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {string} JobID
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {Object} Job
|
|
|
|
* @prop {string} filename
|
|
|
|
* @prop {JobID} id
|
|
|
|
* @prop {string} status
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the Job for a jobCode
|
|
|
|
* @param {JobID} jobId
|
|
|
|
* @returns {Promise<Job>}
|
|
|
|
*/
|
|
|
|
async getJob(jobId) {
|
|
|
|
return {
|
|
|
|
id: jobId,
|
|
|
|
filename: jobId,
|
|
|
|
status: 'pending'
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Prepares a CSV file for import
|
|
|
|
* - Maps headers based on headerMapping, this allows for a non standard CSV
|
|
|
|
* to be imported, so long as a mapping exists between it and a standard CSV
|
|
|
|
* - Stores the CSV to be imported in the storagePath
|
|
|
|
* - Creates a MemberImport Job and associated MemberImportBatch's
|
|
|
|
*
|
|
|
|
* @param {string} inputFilePath - The path to the CSV to prepare
|
|
|
|
* @param {Object.<string, string>} headerMapping - An object whos keys are headers in the input CSV and values are the header to replace it with
|
|
|
|
* @param {Array<string>} defaultLabels - A list of labels to apply to every member
|
|
|
|
*
|
|
|
|
* @returns {Promise<{id: JobID, batches: number, metadata: Object.<string, any>}>} - A promise resolving to the id of the MemberImport Job
|
|
|
|
*/
|
|
|
|
async prepare(inputFilePath, headerMapping, defaultLabels) {
|
|
|
|
const batchSize = 1;
|
|
|
|
|
2021-07-21 10:48:05 +03:00
|
|
|
const siteTimezone = this._getTimezone();
|
2021-06-22 13:07:16 +03:00
|
|
|
const currentTime = moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm:ss.SSS');
|
|
|
|
const outputFileName = `Members Import ${currentTime}.csv`;
|
|
|
|
const outputFilePath = path.join(this._storagePath, '/', outputFileName);
|
|
|
|
|
|
|
|
const pathExists = await fs.pathExists(outputFilePath);
|
|
|
|
|
|
|
|
if (pathExists) {
|
2022-02-15 15:27:22 +03:00
|
|
|
throw new errors.DataImportError({message: tpl(messages.filenameCollision)});
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const rows = await membersCSV.parse(inputFilePath, headerMapping, defaultLabels);
|
2021-11-04 20:48:39 +03:00
|
|
|
const columns = Object.keys(rows[0]);
|
2021-06-22 13:07:16 +03:00
|
|
|
const numberOfBatches = Math.ceil(rows.length / batchSize);
|
2021-11-04 20:48:39 +03:00
|
|
|
const mappedCSV = membersCSV.unparse(rows, columns);
|
2021-06-22 13:07:16 +03:00
|
|
|
|
|
|
|
const hasStripeData = rows.find(function rowHasStripeData(row) {
|
|
|
|
return !!row.stripe_customer_id || !!row.complimentary_plan;
|
|
|
|
});
|
|
|
|
|
|
|
|
await fs.writeFile(outputFilePath, mappedCSV);
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: outputFilePath,
|
|
|
|
batches: numberOfBatches,
|
|
|
|
metadata: {
|
|
|
|
hasStripeData
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Performs an import of a CSV file
|
|
|
|
*
|
|
|
|
* @param {JobID} id - The id of the job to perform
|
|
|
|
*/
|
|
|
|
async perform(id) {
|
|
|
|
const job = await this.getJob(id);
|
|
|
|
|
|
|
|
if (job.status === 'complete') {
|
2022-02-15 15:27:22 +03:00
|
|
|
throw new errors.BadRequestError({message: tpl(messages.jobAlreadyComplete)});
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const rows = membersCSV.parse(job.filename);
|
|
|
|
|
|
|
|
const membersApi = await this._getMembersApi();
|
|
|
|
|
2021-06-22 14:50:03 +03:00
|
|
|
const defaultProductPage = await membersApi.productRepository.list({
|
2022-01-17 20:32:02 +03:00
|
|
|
filter: 'type:paid',
|
2021-06-22 14:50:03 +03:00
|
|
|
limit: 1
|
|
|
|
});
|
|
|
|
|
|
|
|
const defaultProduct = defaultProductPage.data[0];
|
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
const result = await rows.reduce(async (resultPromise, row) => {
|
|
|
|
const resultAccumulator = await resultPromise;
|
|
|
|
|
2021-07-20 18:26:46 +03:00
|
|
|
const trx = await this._knex.transaction();
|
2021-06-22 13:07:16 +03:00
|
|
|
const options = {
|
2022-04-06 22:12:38 +03:00
|
|
|
transacting: trx,
|
|
|
|
context: this._context
|
2021-06-22 13:07:16 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
const existingMember = await membersApi.members.get({email: row.email}, {
|
|
|
|
...options,
|
|
|
|
withRelated: ['labels']
|
|
|
|
});
|
|
|
|
let member;
|
|
|
|
if (existingMember) {
|
|
|
|
const existingLabels = existingMember.related('labels') ? existingMember.related('labels').toJSON() : [];
|
|
|
|
member = await membersApi.members.update({
|
|
|
|
...row,
|
|
|
|
labels: existingLabels.concat(row.labels)
|
|
|
|
}, {
|
|
|
|
...options,
|
|
|
|
id: existingMember.id
|
|
|
|
});
|
|
|
|
} else {
|
2022-04-13 19:35:24 +03:00
|
|
|
member = await membersApi.members.create(row, Object.assign({}, options, {
|
|
|
|
context: {
|
|
|
|
import: true
|
|
|
|
}
|
|
|
|
}));
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (row.stripe_customer_id) {
|
|
|
|
await membersApi.members.linkStripeCustomer({
|
|
|
|
customer_id: row.stripe_customer_id,
|
|
|
|
member_id: member.id
|
|
|
|
}, options);
|
|
|
|
} else if (row.complimentary_plan) {
|
2022-05-25 11:24:56 +03:00
|
|
|
if (!row.products) {
|
2021-06-22 14:50:03 +03:00
|
|
|
await membersApi.members.update({
|
|
|
|
products: [{id: defaultProduct.id}]
|
|
|
|
}, {
|
|
|
|
...options,
|
|
|
|
id: member.id
|
|
|
|
});
|
|
|
|
}
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
|
2022-05-25 11:24:56 +03:00
|
|
|
if (row.products) {
|
|
|
|
await membersApi.members.update({
|
|
|
|
products: row.products
|
|
|
|
}, {
|
|
|
|
...options,
|
|
|
|
id: member.id
|
|
|
|
});
|
2021-06-23 12:19:10 +03:00
|
|
|
}
|
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
await trx.commit();
|
|
|
|
return {
|
|
|
|
...resultAccumulator,
|
|
|
|
imported: resultAccumulator.imported + 1
|
|
|
|
};
|
|
|
|
} catch (error) {
|
|
|
|
// The model layer can sometimes throw arrays of errors
|
2021-07-19 13:46:38 +03:00
|
|
|
const errorList = [].concat(error);
|
|
|
|
const errorMessage = errorList.map(({message}) => message).join(', ');
|
2021-06-22 13:07:16 +03:00
|
|
|
await trx.rollback();
|
|
|
|
return {
|
|
|
|
...resultAccumulator,
|
|
|
|
errors: [...resultAccumulator.errors, {
|
|
|
|
...row,
|
|
|
|
error: errorMessage
|
|
|
|
}]
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}, Promise.resolve({
|
|
|
|
imported: 0,
|
|
|
|
errors: []
|
|
|
|
}));
|
|
|
|
|
|
|
|
return {
|
|
|
|
total: result.imported + result.errors.length,
|
|
|
|
...result
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
generateCompletionEmail(result, data) {
|
2021-07-20 18:31:22 +03:00
|
|
|
const siteUrl = new URL(this._urlFor('home', null, true));
|
|
|
|
const membersUrl = new URL('members', this._urlFor('admin', null, true));
|
2021-06-22 13:07:16 +03:00
|
|
|
if (data.importLabel) {
|
|
|
|
membersUrl.searchParams.set('label', data.importLabel.slug);
|
|
|
|
}
|
|
|
|
return emailTemplate({result, siteUrl, membersUrl, ...data});
|
|
|
|
}
|
|
|
|
|
|
|
|
generateErrorCSV(result) {
|
|
|
|
const errorsWithFormattedMessages = result.errors.map((row) => {
|
|
|
|
const formattedError = row.error
|
|
|
|
.replace(
|
|
|
|
'Value in [members.email] cannot be blank.',
|
|
|
|
'Missing email address'
|
|
|
|
)
|
|
|
|
.replace(
|
|
|
|
'Value in [members.note] exceeds maximum length of 2000 characters.',
|
|
|
|
'"Note" exceeds maximum length of 2000 characters'
|
|
|
|
)
|
|
|
|
.replace(
|
|
|
|
'Value in [members.subscribed] must be one of true, false, 0 or 1.',
|
|
|
|
'Value in "Subscribed to emails" must be "true" or "false"'
|
|
|
|
)
|
|
|
|
.replace(
|
|
|
|
'Validation (isEmail) failed for email',
|
|
|
|
'Invalid email address'
|
|
|
|
)
|
|
|
|
.replace(
|
|
|
|
/No such customer:[^,]*/,
|
|
|
|
'Could not find Stripe customer'
|
|
|
|
);
|
|
|
|
|
|
|
|
return {
|
|
|
|
...row,
|
|
|
|
error: formattedError
|
|
|
|
};
|
|
|
|
});
|
|
|
|
return membersCSV.unparse(errorsWithFormattedMessages);
|
|
|
|
}
|
|
|
|
|
2022-08-23 22:19:30 +03:00
|
|
|
/**
|
|
|
|
* Send email with attached CSV containing error rows info
|
|
|
|
*
|
|
|
|
* @param {Object} config
|
|
|
|
* @param {String} config.emailRecipient - email recipient for error file
|
|
|
|
* @param {String} config.emailSubject - email subject
|
|
|
|
* @param {String} config.emailContent - html content of email
|
|
|
|
* @param {String} config.errorCSV - error CSV content
|
|
|
|
* @param {Object} config.emailSubject - email subject
|
|
|
|
* @param {Object} config.importLabel -
|
|
|
|
* @param {String} config.importLabel.name - label name
|
|
|
|
*/
|
|
|
|
async sendErrorEmail({emailRecipient, emailSubject, emailContent, errorCSV, importLabel}) {
|
|
|
|
await this._sendEmail({
|
|
|
|
to: emailRecipient,
|
|
|
|
subject: emailSubject,
|
|
|
|
html: emailContent,
|
|
|
|
forceTextContent: true,
|
|
|
|
attachments: [{
|
|
|
|
filename: `${importLabel.name} - Errors.csv`,
|
|
|
|
content: errorCSV,
|
|
|
|
contentType: 'text/csv',
|
|
|
|
contentDisposition: 'attachment'
|
|
|
|
}]
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
/**
|
|
|
|
* Processes CSV file and imports member&label records depending on the size of the imported set
|
|
|
|
*
|
|
|
|
* @param {Object} config
|
|
|
|
* @param {String} config.pathToCSV - path where imported csv with members records is stored
|
|
|
|
* @param {Object} config.headerMapping - mapping of CSV headers to member record fields
|
|
|
|
* @param {Object} [config.globalLabels] - labels to be applied to whole imported members set
|
|
|
|
* @param {Object} config.importLabel -
|
|
|
|
* @param {String} config.importLabel.name - label name
|
|
|
|
* @param {Object} config.user
|
|
|
|
* @param {String} config.user.email - calling user email
|
|
|
|
* @param {Object} config.LabelModel - instance of Ghosts Label model
|
|
|
|
*/
|
|
|
|
async process({pathToCSV, headerMapping, globalLabels, importLabel, user, LabelModel}) {
|
2021-07-23 19:29:19 +03:00
|
|
|
const meta = {};
|
2021-06-22 13:07:16 +03:00
|
|
|
const job = await this.prepare(pathToCSV, headerMapping, globalLabels);
|
|
|
|
|
2021-07-28 18:13:48 +03:00
|
|
|
meta.originalImportSize = job.batches;
|
2021-07-23 19:29:19 +03:00
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
if (job.batches <= 500 && !job.metadata.hasStripeData) {
|
|
|
|
const result = await this.perform(job.id);
|
|
|
|
const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
|
2021-07-23 19:29:19 +03:00
|
|
|
|
2021-06-22 13:07:16 +03:00
|
|
|
return {
|
2021-07-23 19:29:19 +03:00
|
|
|
meta: Object.assign(meta, {
|
2021-06-22 13:07:16 +03:00
|
|
|
stats: {
|
|
|
|
imported: result.imported,
|
|
|
|
invalid: result.errors
|
|
|
|
},
|
|
|
|
import_label: importLabelModel
|
2021-07-23 19:29:19 +03:00
|
|
|
})
|
2021-06-22 13:07:16 +03:00
|
|
|
};
|
|
|
|
} else {
|
|
|
|
const emailRecipient = user.email;
|
2021-07-20 18:21:59 +03:00
|
|
|
this._addJob({
|
2021-06-22 13:07:16 +03:00
|
|
|
job: async () => {
|
|
|
|
const result = await this.perform(job.id);
|
|
|
|
const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
|
|
|
|
const emailContent = this.generateCompletionEmail(result, {
|
|
|
|
emailRecipient,
|
|
|
|
importLabel: importLabelModel ? importLabelModel.toJSON() : null
|
|
|
|
});
|
|
|
|
const errorCSV = this.generateErrorCSV(result);
|
|
|
|
const emailSubject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
|
|
|
|
|
2022-08-23 22:19:30 +03:00
|
|
|
await this.sendErrorEmail({
|
|
|
|
emailRecipient,
|
|
|
|
emailSubject,
|
|
|
|
emailContent,
|
|
|
|
errorCSV,
|
|
|
|
importLabel
|
2021-06-22 13:07:16 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
offloaded: false
|
|
|
|
});
|
|
|
|
|
2021-07-23 19:29:19 +03:00
|
|
|
return {
|
|
|
|
meta
|
|
|
|
};
|
2021-06-22 13:07:16 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|