From e71114bb8f64b732a19467cf01793ad04970c9fd Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Fri, 13 Aug 2021 15:18:57 +0200 Subject: [PATCH] Added Members bulk actions endpoint refs https://github.com/TryGhost/Team/issues/946 This adds the initial bulk actions endpoint used for the members filtering feature. The idea is to eventually move bulk destroy into this endpoint to and provide a consistent interface for applying bulk actions to members. The @tryghost/members-api package has been bumped to include the new bulkEdit method. The sinon.restore in tests was moved to an afterEach so that stubs did not effect other tests. --- core/server/api/canary/members.js | 28 ++++ .../utils/serializers/output/members.js | 40 +++++- core/server/web/api/canary/admin/routes.js | 2 + package.json | 2 +- test/api-acceptance/admin/members.test.js | 136 +++++++++++++++++- .../csv/members-for-bulk-add-labels.csv | 9 ++ .../csv/members-for-bulk-unsubscribe.csv | 9 ++ yarn.lock | 24 +++- 8 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 test/utils/fixtures/csv/members-for-bulk-add-labels.csv create mode 100644 test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv diff --git a/core/server/api/canary/members.js b/core/server/api/canary/members.js index 53f5bbc329..63c245a3a7 100644 --- a/core/server/api/canary/members.js +++ b/core/server/api/canary/members.js @@ -395,6 +395,34 @@ module.exports = { } }, + bulkEdit: { + statusCode: 200, + headers: {}, + options: [ + 'all', + 'filter', + 'search' + ], + data: [ + 'action', + 'meta' + ], + validation: { + data: { + action: { + required: true, + values: ['unsubscribe', 'addLabel', 'removeLabel'] + } + } + }, + permissions: { + method: 'edit' + }, + async query(frame) { + return membersService.api.members.bulkEdit(frame.data, frame.options); + } + }, + exportCSV: { options: [ 'limit', diff --git a/core/server/api/canary/utils/serializers/output/members.js b/core/server/api/canary/utils/serializers/output/members.js index 44f1157315..dcc551d56b 100644 --- a/core/server/api/canary/utils/serializers/output/members.js +++ b/core/server/api/canary/utils/serializers/output/members.js @@ -12,7 +12,7 @@ module.exports = { editSubscription: createSerializer('editSubscription', singleMember), createSubscription: createSerializer('createSubscription', singleMember), bulkDestroy: createSerializer('bulkDestroy', passthrough), - + bulkEdit: createSerializer('bulkEdit', bulkAction), exportCSV: createSerializer('exportCSV', exportCSV), importCSV: createSerializer('importCSV', passthrough), @@ -53,6 +53,29 @@ function singleMember(model, _apiConfig, frame) { }; } +/** + * @param {object} bulkActionResult + * @param {APIConfig} _apiConfig + * @param {Frame} frame + * + * @returns {{bulk: SerializedBulkAction}} + */ +function bulkAction(bulkActionResult, _apiConfig, frame) { + return { + bulk: { + action: frame.data.action, + meta: { + stats: { + successful: bulkActionResult.successful, + unsuccessful: bulkActionResult.unsuccessful + }, + errors: bulkActionResult.errors, + unsuccessfulData: bulkActionResult.unsuccessfulData + } + } + }; +} + /** * @template PageMeta * @@ -252,6 +275,21 @@ function createSerializer(debugString, serialize) { * @prop {string} updated_by */ +/** + * + * @typedef {Object} SerializedBulkAction + * + * @prop {string} action + * + * @prop {object} meta + * @prop {object[]} meta.unsuccessfulData + * @prop {Error[]} meta.errors + * @prop {object} meta.stats + * + * @prop {number} meta.stats.successful + * @prop {number} meta.stats.unsuccessful + */ + /** * @typedef {Object} APIConfig * @prop {string} docName diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 5956612669..acabc1684b 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -4,6 +4,7 @@ const apiMw = require('../../middleware'); const mw = require('./middleware'); const shared = require('../../../shared'); +const labs = require('../../../../../shared/labs'); module.exports = function apiRoutes() { const router = express.Router('canary admin'); @@ -97,6 +98,7 @@ module.exports = function apiRoutes() { router.get('/members', mw.authAdminApi, http(api.members.browse)); router.post('/members', mw.authAdminApi, http(api.members.add)); router.del('/members', mw.authAdminApi, http(api.members.bulkDestroy)); + router.put('/members/bulk', labs.enabledMiddleware('membersFiltering'), mw.authAdminApi, http(api.members.bulkEdit)); router.get('/members/stats/count', mw.authAdminApi, http(api.members.memberStats)); router.get('/members/stats/mrr', mw.authAdminApi, http(api.members.mrrStats)); diff --git a/package.json b/package.json index f832892f67..26f7fee7f9 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "@tryghost/limit-service": "0.6.1", "@tryghost/logging": "0.1.5", "@tryghost/magic-link": "1.0.9", - "@tryghost/members-api": "1.24.1", + "@tryghost/members-api": "1.25.2", "@tryghost/members-csv": "1.1.4", "@tryghost/members-importer": "0.3.0", "@tryghost/members-ssr": "1.0.10", diff --git a/test/api-acceptance/admin/members.test.js b/test/api-acceptance/admin/members.test.js index a5cd1ef465..900964bf89 100644 --- a/test/api-acceptance/admin/members.test.js +++ b/test/api-acceptance/admin/members.test.js @@ -7,12 +7,11 @@ const localUtils = require('./utils'); const config = require('../../../core/shared/config'); const labs = require('../../../core/shared/labs'); const Papa = require('papaparse'); -const moment = require('moment-timezone'); describe('Members API', function () { let request; - after(function () { + afterEach(function () { sinon.restore(); }); @@ -473,4 +472,137 @@ describe('Members API', function () { }); }); }); + + it('Can bulk unsubscribe members with filter', async function () { + // import our dummy data for deletion + await request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .attach('membersfile', path.join(__dirname, '/../../utils/fixtures/csv/members-for-bulk-unsubscribe.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private); + + const browseResponse = await request + .get(localUtils.API.getApiQuery('members/?filter=label:bulk-unsubscribe-test')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + browseResponse.body.members.should.have.length(8); + const allMembersSubscribed = browseResponse.body.members.every((member) => { + return member.subscribed; + }); + + should.ok(allMembersSubscribed); + + const bulkUnsubscribeResponse = await request + .put(localUtils.API.getApiQuery('members/bulk/?filter=label:bulk-unsubscribe-test')) + .set('Origin', config.get('url')) + .send({ + action: 'unsubscribe' + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + should.exist(bulkUnsubscribeResponse.body.bulk); + should.exist(bulkUnsubscribeResponse.body.bulk.meta); + should.exist(bulkUnsubscribeResponse.body.bulk.meta.stats); + should.exist(bulkUnsubscribeResponse.body.bulk.meta.stats.successful); + should.equal(bulkUnsubscribeResponse.body.bulk.meta.stats.successful, 8); + + const postUnsubscribeBrowseResponse = await request + .get(localUtils.API.getApiQuery('members/?filter=label:bulk-unsubscribe-test')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + postUnsubscribeBrowseResponse.body.members.should.have.length(8); + const allMembersUnsubscribed = postUnsubscribeBrowseResponse.body.members.every((member) => { + return !member.subscribed; + }); + + should.ok(allMembersUnsubscribed); + }); + + it('Can bulk add and remove labels to members with filter', async function () { + // import our dummy data for deletion + await request + .post(localUtils.API.getApiQuery('members/upload/')) + .attach('membersfile', path.join(__dirname, '/../../utils/fixtures/csv/members-for-bulk-add-labels.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private); + + const newLabelResponse = await request + .post(localUtils.API.getApiQuery('labels')) + .set('Origin', config.get('url')) + .send({ + labels: [{ + name: 'Awesome Label For Testing Bulk Add' + }] + }); + + const labelToAdd = newLabelResponse.body.labels[0]; + + const bulkAddLabelResponse = await request + .put(localUtils.API.getApiQuery('members/bulk/?filter=label:bulk-add-labels-test')) + .set('Origin', config.get('url')) + .send({ + action: 'addLabel', + meta: { + label: labelToAdd + } + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + should.exist(bulkAddLabelResponse.body.bulk); + should.exist(bulkAddLabelResponse.body.bulk.meta); + should.exist(bulkAddLabelResponse.body.bulk.meta.stats); + should.exist(bulkAddLabelResponse.body.bulk.meta.stats.successful); + should.equal(bulkAddLabelResponse.body.bulk.meta.stats.successful, 8); + + const postLabelAddBrowseResponse = await request + .get(localUtils.API.getApiQuery(`members/?filter=label:${labelToAdd.slug}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + postLabelAddBrowseResponse.body.members.should.have.length(8); + + const labelToRemove = newLabelResponse.body.labels[0]; + + const bulkRemoveLabelResponse = await request + .put(localUtils.API.getApiQuery('members/bulk/?filter=label:bulk-add-labels-test')) + .set('Origin', config.get('url')) + .send({ + action: 'removeLabel', + meta: { + label: labelToRemove + } + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + should.exist(bulkRemoveLabelResponse.body.bulk); + should.exist(bulkRemoveLabelResponse.body.bulk.meta); + should.exist(bulkRemoveLabelResponse.body.bulk.meta.stats); + should.exist(bulkRemoveLabelResponse.body.bulk.meta.stats.successful); + should.equal(bulkRemoveLabelResponse.body.bulk.meta.stats.successful, 8); + + const postLabelRemoveBrowseResponse = await request + .get(localUtils.API.getApiQuery(`members/?filter=label:${labelToRemove.slug}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + + postLabelRemoveBrowseResponse.body.members.should.have.length(0); + }); }); diff --git a/test/utils/fixtures/csv/members-for-bulk-add-labels.csv b/test/utils/fixtures/csv/members-for-bulk-add-labels.csv new file mode 100644 index 0000000000..5adb5e4298 --- /dev/null +++ b/test/utils/fixtures/csv/members-for-bulk-add-labels.csv @@ -0,0 +1,9 @@ +email,subscribed,labels +member+bulk_add_labels_test_1@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_2@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_3@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_4@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_5@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_6@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_7@example.com,true,bulk-add-labels-test +member+bulk_add_labels_test_8@example.com,true,bulk-add-labels-test diff --git a/test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv b/test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv new file mode 100644 index 0000000000..38fa2e79e6 --- /dev/null +++ b/test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv @@ -0,0 +1,9 @@ +email,subscribed,labels +member+bulk_unsubscribe_test_1@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_2@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_3@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_4@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_5@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_6@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_7@example.com,true,bulk-unsubscribe-test +member+bulk_unsubscribe_test_8@example.com,true,bulk-unsubscribe-test diff --git a/yarn.lock b/yarn.lock index 6123655b58..990bf3d7f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -899,7 +899,7 @@ moment "^2.29.1" prettyjson "^1.2.1" -"@tryghost/magic-link@1.0.9", "@tryghost/magic-link@^1.0.8": +"@tryghost/magic-link@1.0.9": version "1.0.9" resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-1.0.9.tgz#d76725d1509a2e545dd7d0b7beb5105a61114a97" integrity sha512-5ALQY2UKhyB4hak22FJ5ILXH546IvEIkGL9PuB6os+2qdqPtAHjVOdBynTCmP9MrzU6ODAfIvtRUauu3Xmllnw== @@ -908,19 +908,29 @@ jsonwebtoken "^8.5.1" lodash "^4.17.15" -"@tryghost/members-api@1.24.1": - version "1.24.1" - resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-1.24.1.tgz#034a40c755ce2f73bee0b3b982e55062ddd0ad97" - integrity sha512-bcYt2qFPGBHB5QO5c034quqPSvEFZPIAcfCCVfO7Y9d6h5dykGuS/ZDg5TMMv0E5u2o9Nf+MIWrcRk0mqumPUQ== +"@tryghost/magic-link@^1.0.10": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-1.0.10.tgz#934bbc8823c73b665212d51f0624dc8882f549cb" + integrity sha512-SqR1bd0iRfVcC8fuIX0mDuWbinhLtpFejeZRnAhhpBi5EitbnboKXH9BBWSJkWxpzJQhfP0Aru/q6XQalVDC4Q== + dependencies: + bluebird "^3.5.5" + jsonwebtoken "^8.5.1" + lodash "^4.17.15" + +"@tryghost/members-api@1.25.2": + version "1.25.2" + resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-1.25.2.tgz#b9e0150dad7b3dce66e7cc202de810e2598a771e" + integrity sha512-/zcOxNtgxw2mLj6+X8XI0iw4/DS64H/3qhXLKXiiu1ySL6ea5pK8TsvMx06jVCDu9thUzj1NHitCi3tPbYkZ9Q== dependencies: "@tryghost/debug" "^0.1.2" "@tryghost/errors" "^0.2.9" "@tryghost/ignition-errors" "^0.1.2" - "@tryghost/magic-link" "^1.0.8" + "@tryghost/magic-link" "^1.0.10" "@tryghost/tpl" "^0.1.2" "@types/jsonwebtoken" "^8.5.1" bluebird "^3.5.4" body-parser "^1.19.0" + bson-objectid "^2.0.1" cookies "^0.8.0" express "^4.16.4" got "^9.6.0" @@ -1914,7 +1924,7 @@ brute-knex@4.0.1: express-brute "^1.0.1" knex "^0.20" -bson-objectid@2.0.1: +bson-objectid@2.0.1, bson-objectid@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/bson-objectid/-/bson-objectid-2.0.1.tgz#226a9ffecd3a8d52f565d71012dd5b176560fef1" integrity sha512-b4D1/G4uP9Yks4rv+nDVsZ4ybT1W5nQYw4lfpfaRP2Q18azlR6Oe2BAuirG1lzrwQFtHnJ0nrK5kWKKZVEMUng==