diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs b/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs index 2fe6418472..4f2e11ef3a 100644 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs +++ b/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs @@ -43,11 +43,28 @@ {{#if countFetcher.isLoading}} {{else}} + {{#if this.hasMultipleNewsletters }} +

+ Which newsletter should these + {{gh-pluralize countFetcher.count "member"}} be unsubscribed from? +

+ + {{else}}

You're about to unsubscribe {{gh-pluralize countFetcher.count "member"}} from email newsletters. Are you sure?

+ {{/if}} + {{/if}} {{/let}} {{else}} @@ -62,6 +79,7 @@ Close {{else}} + {{#if this.hasMultipleNewsletters}} @@ -74,6 +92,21 @@ @class="gh-btn gh-btn-red gh-btn-icon" data-test-button="confirm" /> + + {{else}} + + + + {{/if}} {{/if}} diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js b/ghost/admin/app/components/members/modals/bulk-unsubscribe.js index 450bcb98df..a584adaf2a 100644 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js +++ b/ghost/admin/app/components/members/modals/bulk-unsubscribe.js @@ -7,10 +7,13 @@ import {tracked} from '@glimmer/tracking'; export default class BulkUnsubscribeMembersModal extends Component { @service ajax; @service ghostPaths; + @service store; @tracked error; @tracked response; + @tracked selectedNewsletterId = null; + get isDisabled() { return !this.args.data.query; } @@ -19,27 +22,62 @@ export default class BulkUnsubscribeMembersModal extends Component { return !!(this.error || this.response); } + get hasMultipleNewsletters() { + const newsletters = this.store.peekAll('newsletter'); + const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); + if (activeNewsletters.length <= 1) { + return false; + } else { + return true; + } + } + + get newsletterList() { + const newsletters = this.store.peekAll('newsletter'); + const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); + let list = [{ + name: 'All newsletters', + value: 'all' + }]; + activeNewsletters.forEach((newsletter) => { + list.push({ + name: newsletter.name, + value: newsletter.id + }); + }); + return list; + } + @action setLabel(label) { this.selectedLabel = label; } + @action + setSelectedNewsletter(newsletter) { + if (newsletter === 'all') { + this.selectedNewsletterId = null; + } else { + this.selectedNewsletterId = newsletter; + } + } + @task({drop: true}) *bulkUnsubscribeTask() { try { - const query = new URLSearchParams(this.args.data.query); + let args = this.args.data.query; + const query = new URLSearchParams(args); const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(removeLabelUrl, { - data: { - bulk: { - action: 'unsubscribe', - meta: {} - } + const response = yield this.ajax.put(removeLabelUrl, {data: { + bulk: { + action: 'unsubscribe', + newsletter: (this.selectedNewsletterId ? this.selectedNewsletterId : null), + meta: {} } - }); + }}); this.args.data.onComplete?.(); - + this.response = response?.bulk?.meta; return true; diff --git a/ghost/core/core/server/models/base/plugins/bulk-operations.js b/ghost/core/core/server/models/base/plugins/bulk-operations.js index 63235ed883..c270913589 100644 --- a/ghost/core/core/server/models/base/plugins/bulk-operations.js +++ b/ghost/core/core/server/models/base/plugins/bulk-operations.js @@ -101,7 +101,6 @@ module.exports = function (Bookshelf) { */ bulkDestroy: function bulkDestroy(data, tableName, options = {}) { tableName = tableName || this.prototype.tableName; - return del(Bookshelf.knex, tableName, data, options); } }); diff --git a/ghost/core/core/server/models/member-newsletter.js b/ghost/core/core/server/models/member-newsletter.js new file mode 100644 index 0000000000..92b0dbc0b6 --- /dev/null +++ b/ghost/core/core/server/models/member-newsletter.js @@ -0,0 +1,9 @@ +const ghostBookshelf = require('./base'); + +const MemberNewsletter = ghostBookshelf.Model.extend({ + tableName: 'members_newsletters' +}); + +module.exports = { + MemberNewsletter: ghostBookshelf.model('MemberNewsletter', MemberNewsletter) +}; diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 5e6b9f6983..f9f13d01d8 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -177,6 +177,7 @@ function createApiInstance(config) { StripeCustomer: models.MemberStripeCustomer, StripeCustomerSubscription: models.StripeCustomerSubscription, Member: models.Member, + MemberNewsletter: models.MemberNewsletter, MemberCancelEvent: models.MemberCancelEvent, MemberSubscribeEvent: models.MemberSubscribeEvent, MemberPaidSubscriptionEvent: models.MemberPaidSubscriptionEvent, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 9d0aaec5de..a661a1aa93 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -468,6 +468,21 @@ Object { } `; +exports[`Members API Bulk operations Can bulk unsubscribe members from specific newsletter 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 4, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + exports[`Members API Bulk operations Can bulk unsubscribe members with deprecated subscribed filter (actual) 1: [body] 1`] = ` Object { "bulk": Object { diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 2addb51025..00168aabfa 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -2617,6 +2617,38 @@ describe('Members API Bulk operations', function () { }); }); + it('Can bulk unsubscribe members from specific newsletter', async function () { + const member = fixtureManager.get('members', 4); + const newsletterCount = 2; + + const model = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'}); + should(model.relations.newsletters.models.length).equal(newsletterCount, 'This test requires a member with 2 or more newsletters'); + + await agent + .put(`/members/bulk/?all=true`) + .body({bulk: { + action: 'unsubscribe', + newsletter: model.relations.newsletters.models[0].id, + meta: {} + }}) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + meta: { + stats: { + successful: 4, + unsuccessful: 0 + }, + unsuccessfulData: [], + errors: [] + } + } + }); + const updatedModel = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'}); + // ensure they were unsubscribed from the single 'chosen' newsletter + should(updatedModel.relations.newsletters.models.length).equal(newsletterCount - 1); + }); + it('Can bulk unsubscribe members with deprecated subscribed filter', async function () { await agent .put(`/members/bulk/?filter=subscribed:false`) diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index 023552d8ce..0881637f89 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -38,6 +38,7 @@ module.exports = function MembersAPI({ StripeCustomer, StripeCustomerSubscription, Member, + MemberNewsletter, MemberCancelEvent, MemberSubscribeEvent, MemberLoginEvent, @@ -86,6 +87,7 @@ module.exports = function MembersAPI({ labsService, productRepository, Member, + MemberNewsletter, MemberCancelEvent, MemberSubscribeEventModel: MemberSubscribeEvent, MemberPaidSubscriptionEvent, diff --git a/ghost/members-api/lib/repositories/member.js b/ghost/members-api/lib/repositories/member.js index b40726354f..9b09163965 100644 --- a/ghost/members-api/lib/repositories/member.js +++ b/ghost/members-api/lib/repositories/member.js @@ -28,6 +28,7 @@ module.exports = class MemberRepository { /** * @param {object} deps * @param {any} deps.Member + * @param {any} deps.MemberNewsletter * @param {any} deps.MemberCancelEvent * @param {any} deps.MemberSubscribeEventModel * @param {any} deps.MemberEmailChangeEvent @@ -46,6 +47,7 @@ module.exports = class MemberRepository { */ constructor({ Member, + MemberNewsletter, MemberCancelEvent, MemberSubscribeEventModel, MemberEmailChangeEvent, @@ -63,6 +65,7 @@ module.exports = class MemberRepository { newslettersService }) { this._Member = Member; + this._MemberNewsletter = MemberNewsletter; this._MemberCancelEvent = MemberCancelEvent; this._MemberSubscribeEvent = MemberSubscribeEventModel; this._MemberEmailChangeEvent = MemberEmailChangeEvent; @@ -718,7 +721,6 @@ module.exports = class MemberRepository { // Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer'])); } - const memberRows = await this._Member.getFilteredCollectionQuery(filterOptions) .select('members.id') .distinct(); @@ -726,9 +728,20 @@ module.exports = class MemberRepository { const memberIds = memberRows.map(row => row.id); if (data.action === 'unsubscribe') { - return await this._Member.bulkDestroy(memberIds, 'members_newsletters', {column: 'member_id'}); + const hasNewsletterSelected = (Object.prototype.hasOwnProperty.call(data, 'newsletter') && data.newsletter !== null); + if (hasNewsletterSelected) { + const membersArr = memberIds.join(','); + const unsubscribeRows = await this._MemberNewsletter.getFilteredCollectionQuery({ + filter: `newsletter_id:${data.newsletter}+member_id:[${membersArr}]` + }); + const toUnsubscribe = unsubscribeRows.map(row => row.id); + + return await this._MemberNewsletter.bulkDestroy(toUnsubscribe); + } + if (!hasNewsletterSelected) { + return await this._Member.bulkDestroy(memberIds, 'members_newsletters', {column: 'member_id'}); + } } - if (data.action === 'removeLabel') { const membersLabelsRows = await this._Member.getLabelRelations({ labelId: data.meta.label.id,