mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 11:55:01 +03:00
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:
parent
c99dc2f0bc
commit
bb19eddeae
@ -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',
|
||||
|
@ -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),
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
|
|
Loading…
Reference in New Issue
Block a user