Added DELETE /members/ to the Admin API for bulk member deletion (#12082)

refs https://github.com/TryGhost/Team/issues/585

- adds `DELETE /members/` route to the Admin API
- supports `?filter`, and `?search` query params to limit the members that are deleted
- `?all=true` is required if no other filter or query is provided
- uses `models.Member.bulkDestroy` which _will not_ cancel any Stripe subscriptions if members have them but _will_ clean up the Stripe relationship data in Ghost's database
This commit is contained in:
Kevin Ansfield 2021-04-08 12:03:45 +01:00 committed by GitHub
parent c99dc2f0bc
commit bb19eddeae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 146 additions and 1 deletions

View File

@ -300,6 +300,63 @@ module.exports = {
}
},
bulkDestroy: {
statusCode: 200,
headers: {},
options: [
'all',
'filter',
'search'
],
permissions: {
method: 'destroy'
},
async query(frame) {
const {all, filter, search} = frame.options;
if (!filter && !search && (!all || all !== true)) {
throw new errors.IncorrectUsageError({
message: 'DELETE /members/ must be used with a filter or ?all=true'
});
}
const knexOptions = _.pick(frame.options, ['transacting']);
const filterOptions = Object.assign({}, knexOptions);
if (all !== true) {
if (filter) {
filterOptions.filter = filter;
}
if (search) {
filterOptions.search = search;
}
}
// fetch ids of all matching members
const memberRows = await models.Member
.getFilteredCollectionQuery(filterOptions)
.select('members.id')
.distinct();
const memberIds = memberRows.map(row => row.id);
const bulkDestroyResult = await models.Member.bulkDestroy(memberIds);
// shaped to match the importer response
return {
meta: {
stats: {
successful: bulkDestroyResult.successful,
unsuccessful: bulkDestroyResult.unsuccessful
},
unsuccessfulIds: bulkDestroyResult.unsuccessfulIds,
errors: bulkDestroyResult.errors
}
};
}
},
exportCSV: {
options: [
'limit',

View File

@ -10,6 +10,7 @@ module.exports = {
edit: createSerializer('edit', singleMember),
add: createSerializer('add', singleMember),
editSubscription: createSerializer('editSubscription', singleMember),
bulkDestroy: createSerializer('bulkDestroy', passthrough),
exportCSV: createSerializer('exportCSV', exportCSV),

View File

@ -90,6 +90,7 @@ module.exports = function apiRoutes() {
// ## Members
router.get('/members', mw.authAdminApi, http(apiCanary.members.browse));
router.post('/members', mw.authAdminApi, http(apiCanary.members.add));
router.del('/members', mw.authAdminApi, http(apiCanary.members.bulkDestroy));
router.get('/members/stats/count', mw.authAdminApi, http(apiCanary.members.memberStats));
router.get('/members/stats/mrr', mw.authAdminApi, http(apiCanary.members.mrrStats));

View File

@ -7,7 +7,6 @@ const localUtils = require('./utils');
const config = require('../../../core/shared/config');
const labs = require('../../../core/server/services/labs');
const Papa = require('papaparse');
const settingsCache = require('../../../core/server/services/settings/cache');
const moment = require('moment-timezone');
describe('Members API', function () {
@ -396,4 +395,82 @@ describe('Members API', function () {
data[0].paid.should.equal(0);
data[0].comped.should.equal(0);
});
it('Can import CSV and bulk destroy via auto-added label', function () {
// HACK: mock dates otherwise we'll often get unexpected members appearing
// from previous tests with the same import label due to auto-generated
// import labels only including minutes
sinon.stub(Date, 'now').returns(new Date('2021-03-30T17:21:00.000Z'));
// import our dummy data for deletion
return request
.post(localUtils.API.getApiQuery(`members/upload/`))
.attach('membersfile', path.join(__dirname, '/../../utils/fixtures/csv/valid-members-for-bulk-delete.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
should.exist(jsonResponse.meta.import_label);
jsonResponse.meta.stats.imported.should.equal(8);
return jsonResponse.meta.import_label;
})
.then((importLabel) => {
// check that the import worked by checking browse response with filter
return request.get(localUtils.API.getApiQuery(`members/?filter=label:${importLabel.slug}`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(8);
})
.then(() => importLabel);
})
.then((importLabel) => {
// perform the bulk delete
return request
.del(localUtils.API.getApiQuery(`members/?filter=label:'${importLabel.slug}'`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
should.exist(jsonResponse.meta.stats.successful);
should.equal(jsonResponse.meta.stats.successful, 8);
})
.then(() => importLabel);
})
.then((importLabel) => {
// check that the bulk delete worked by checking browse response with filter
return request.get(localUtils.API.getApiQuery(`members/?filter=label:${importLabel.slug}`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(0);
});
});
});
});

View File

@ -0,0 +1,9 @@
email,subscribed
member+free_1@example.com,true
member+free_2@example.com,true
member+free_3@example.com,true
member+free_4@example.com,true
member+free_5@example.com,true
member+free_6@example.com,true
member+free_7@example.com,true
member+free_8@example.com,true
1 email subscribed
2 member+free_1@example.com true
3 member+free_2@example.com true
4 member+free_3@example.com true
5 member+free_4@example.com true
6 member+free_5@example.com true
7 member+free_6@example.com true
8 member+free_7@example.com true
9 member+free_8@example.com true