0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-25 02:31:59 -05:00

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.
This commit is contained in:
Fabien O'Carroll 2021-08-13 15:18:57 +02:00
parent 1835c22f3b
commit e71114bb8f
8 changed files with 239 additions and 11 deletions

View file

@ -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',

View file

@ -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

View file

@ -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));

View file

@ -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",

View file

@ -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);
});
});

View file

@ -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
1 email subscribed labels
2 member+bulk_add_labels_test_1@example.com true bulk-add-labels-test
3 member+bulk_add_labels_test_2@example.com true bulk-add-labels-test
4 member+bulk_add_labels_test_3@example.com true bulk-add-labels-test
5 member+bulk_add_labels_test_4@example.com true bulk-add-labels-test
6 member+bulk_add_labels_test_5@example.com true bulk-add-labels-test
7 member+bulk_add_labels_test_6@example.com true bulk-add-labels-test
8 member+bulk_add_labels_test_7@example.com true bulk-add-labels-test
9 member+bulk_add_labels_test_8@example.com true bulk-add-labels-test

View file

@ -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
1 email subscribed labels
2 member+bulk_unsubscribe_test_1@example.com true bulk-unsubscribe-test
3 member+bulk_unsubscribe_test_2@example.com true bulk-unsubscribe-test
4 member+bulk_unsubscribe_test_3@example.com true bulk-unsubscribe-test
5 member+bulk_unsubscribe_test_4@example.com true bulk-unsubscribe-test
6 member+bulk_unsubscribe_test_5@example.com true bulk-unsubscribe-test
7 member+bulk_unsubscribe_test_6@example.com true bulk-unsubscribe-test
8 member+bulk_unsubscribe_test_7@example.com true bulk-unsubscribe-test
9 member+bulk_unsubscribe_test_8@example.com true bulk-unsubscribe-test

View file

@ -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==