diff --git a/core/server/services/members/importer/email-template.js b/core/server/services/members/importer/email-template.js deleted file mode 100644 index 2c88b3cfce..0000000000 --- a/core/server/services/members/importer/email-template.js +++ /dev/null @@ -1,178 +0,0 @@ -const iff = (cond, yes, no) => (cond ? yes : no); -module.exports = ({result, siteUrl, membersUrl, emailRecipient}) => ` - - - - - - Your member import is complete - - - - - - - - - -
  -
- - - - - - - - - - - - - - -
- - - - - - - - ${iff(result.imported === 0 && result.errors.length === 0, ` - - - - `, ``)} - ${iff(result.imported > 0, ` - - - `, ``)} - ${iff(result.errors.length > 0, ` - - - `, '')} - - - -
-

${iff(result.imported > 0, `Your member import is complete`, `Your member import was unsuccessful`)}

-
-

No members were added.

-
-

A total of ${result.imported} ${iff(result.imported === 1, 'person', 'people')} were successfully added or updated in your list of members, and now have access to your site.

-
-

- ${iff(result.imported === 0, `No members were added.`, `${result.errors.length} ${iff(result.errors.length === 1, `member was`, `members were`)} skipped due to errors.`)} There's a validated CSV file attached to this email with the list of errors so that you can fix them and re-upload the CSV to complete the import.

-
- ${iff(result.imported > 0, `View members`, `Try again`)} -
-
- -
- - - -
-
 
- - -`; - diff --git a/core/server/services/members/importer/importer.js b/core/server/services/members/importer/importer.js deleted file mode 100644 index c905f3aebe..0000000000 --- a/core/server/services/members/importer/importer.js +++ /dev/null @@ -1,316 +0,0 @@ -const moment = require('moment-timezone'); -const path = require('path'); -const fs = require('fs-extra'); -const membersCSV = require('@tryghost/members-csv'); -const errors = require('@tryghost/errors'); -const tpl = require('@tryghost/tpl'); - -const emailTemplate = require('./email-template'); - -const messages = { - filenameCollision: 'Filename already exists, please try again.', - jobAlreadyComplete: 'Job is already complete.' -}; - -module.exports = class MembersCSVImporter { - /** - * @param {Object} options - * @param {string} options.storagePath - The path to store CSV's in before importing - * @param {Function} options.getTimezone - function returning currently configured timezone - * @param {() => Object} options.getMembersApi - * @param {Function} options.sendEmail - function sending an email - * @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 - */ - constructor({storagePath, getTimezone, getMembersApi, sendEmail, isSet, addJob, knex, urlFor}) { - this._storagePath = storagePath; - this._getTimezone = getTimezone; - this._getMembersApi = getMembersApi; - this._sendEmail = sendEmail; - this._isSet = isSet; - this._addJob = addJob; - this._knex = knex; - this._urlFor = urlFor; - } - - /** - * @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} - */ - 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.} headerMapping - An object whos keys are headers in the input CSV and values are the header to replace it with - * @param {Array} defaultLabels - A list of labels to apply to every member - * - * @returns {Promise<{id: JobID, batches: number, metadata: Object.}>} - A promise resolving to the id of the MemberImport Job - */ - async prepare(inputFilePath, headerMapping, defaultLabels) { - const batchSize = 1; - - const siteTimezone = this._getTimezone(); - 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) { - throw new errors.DataImportError(tpl(messages.filenameCollision)); - } - - const rows = await membersCSV.parse(inputFilePath, headerMapping, defaultLabels); - const numberOfBatches = Math.ceil(rows.length / batchSize); - const mappedCSV = membersCSV.unparse(rows); - - 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') { - throw new errors.BadRequestError(tpl(messages.jobAlreadyComplete)); - } - - const rows = membersCSV.parse(job.filename); - - const membersApi = await this._getMembersApi(); - - const defaultProductPage = await membersApi.productRepository.list({ - limit: 1 - }); - - const defaultProduct = defaultProductPage.data[0]; - - const result = await rows.reduce(async (resultPromise, row) => { - const resultAccumulator = await resultPromise; - - const trx = await this._knex.transaction(); - const options = { - transacting: trx - }; - - 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 { - member = await membersApi.members.create(row, options); - } - - 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) { - if (!this._isSet('multipleProducts')) { - await membersApi.members.setComplimentarySubscription(member, options); - } else if (!row.products) { - await membersApi.members.update({ - products: [{id: defaultProduct.id}] - }, { - ...options, - id: member.id - }); - } - } - - if (this._isSet('multipleProducts')) { - if (row.products) { - await membersApi.members.update({ - products: row.products - }, { - ...options, - id: member.id - }); - } - } - - await trx.commit(); - return { - ...resultAccumulator, - imported: resultAccumulator.imported + 1 - }; - } catch (error) { - // The model layer can sometimes throw arrays of errors - const errorList = [].concat(error); - const errorMessage = errorList.map(({message}) => message).join(', '); - 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) { - const siteUrl = new URL(this._urlFor('home', null, true)); - const membersUrl = new URL('members', this._urlFor('admin', null, true)); - 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); - } - - /** - * 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}) { - const job = await this.prepare(pathToCSV, headerMapping, globalLabels); - - if (job.batches <= 500 && !job.metadata.hasStripeData) { - const result = await this.perform(job.id); - const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null; - return { - meta: { - stats: { - imported: result.imported, - invalid: result.errors - }, - import_label: importLabelModel - } - }; - } else { - const emailRecipient = user.email; - this._addJob({ - 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'; - - await this._sendEmail({ - to: emailRecipient, - subject: emailSubject, - html: emailContent, - forceTextContent: true, - attachments: [{ - filename: `${importLabel.name} - Errors.csv`, - contents: errorCSV, - contentType: 'text/csv', - contentDisposition: 'attachment' - }] - }); - }, - offloaded: false - }); - - return {}; - } - } -}; diff --git a/core/server/services/members/importer/index.js b/core/server/services/members/importer/index.js deleted file mode 100644 index 69df4d5436..0000000000 --- a/core/server/services/members/importer/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./importer'); diff --git a/core/server/services/members/service.js b/core/server/services/members/service.js index 225b340593..5e76d6ae2d 100644 --- a/core/server/services/members/service.js +++ b/core/server/services/members/service.js @@ -3,7 +3,7 @@ const tpl = require('@tryghost/tpl'); const MembersSSR = require('@tryghost/members-ssr'); const db = require('../../data/db'); const MembersConfigProvider = require('./config'); -const MembersCSVImporter = require('./importer'); +const MembersCSVImporter = require('@tryghost/members-importer'); const MembersStats = require('./stats'); const createMembersApiInstance = require('./api'); const createMembersSettingsInstance = require('./settings'); diff --git a/package.json b/package.json index 4bb716a9b8..60a912581c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@tryghost/magic-link": "1.0.7", "@tryghost/members-api": "1.22.1", "@tryghost/members-csv": "1.1.2", + "@tryghost/members-importer": "0.1.0", "@tryghost/members-ssr": "1.0.8", "@tryghost/mw-session-from-token": "0.1.22", "@tryghost/package-json": "1.0.2", diff --git a/yarn.lock b/yarn.lock index cd41dadc66..8cd5eda5e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -845,7 +845,7 @@ node-jose "^2.0.0" stripe "^8.142.0" -"@tryghost/members-csv@1.1.2": +"@tryghost/members-csv@1.1.2", "@tryghost/members-csv@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@tryghost/members-csv/-/members-csv-1.1.2.tgz#408973028d587925ddebaf686f4b8479e2843462" integrity sha512-wUgKjC+OhUDbaejHhhLAl97LqyzDjzUc6CmbZ8zmwk0B1TaoFPuRGwMfGLyMmqp25b+9mTETrLWn/aSGa/0Uyw== @@ -856,6 +856,16 @@ papaparse "5.3.1" pump "^3.0.0" +"@tryghost/members-importer@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/members-importer/-/members-importer-0.1.0.tgz#d36544e2917c41e7f35c66c91deff567c83da9b5" + integrity sha512-eiruaYreKd7S7mGtLS2RzAMwq7T48bL4Pa5497bi3soCQCS0P8GCfonojwQmYPDHaF3xPN/oY+80q6HqP43+rw== + dependencies: + "@tryghost/errors" "^0.2.13" + "@tryghost/members-csv" "^1.1.2" + "@tryghost/tpl" "^0.1.3" + moment-timezone "0.5.23" + "@tryghost/members-ssr@1.0.8": version "1.0.8" resolved "https://registry.yarnpkg.com/@tryghost/members-ssr/-/members-ssr-1.0.8.tgz#ebf837b913fa049d6df36171206b372e1e1ebfd4"