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:
parent
1835c22f3b
commit
e71114bb8f
8 changed files with 239 additions and 11 deletions
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
9
test/utils/fixtures/csv/members-for-bulk-add-labels.csv
Normal file
9
test/utils/fixtures/csv/members-for-bulk-add-labels.csv
Normal 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
|
|
9
test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv
Normal file
9
test/utils/fixtures/csv/members-for-bulk-unsubscribe.csv
Normal 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
|
|
24
yarn.lock
24
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==
|
||||
|
|
Loading…
Add table
Reference in a new issue