From e0787b4e838eaa18464cbe3b64f0cb87889806b2 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Wed, 16 Nov 2022 14:29:00 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20specific=20newsletter=20sup?= =?UTF-8?q?port=20for=20bulk=20unsubscribes=20(#15742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/TryGhost/Team/issues/2013 Added support to bulk unsubscribe a selected (filtered) list on members from specific, selected newsletters. --- .../members/modals/bulk-unsubscribe.hbs | 33 +++++++++++ .../members/modals/bulk-unsubscribe.js | 56 ++++++++++++++++--- .../models/base/plugins/bulk-operations.js | 1 - .../core/server/models/member-newsletter.js | 9 +++ .../core/core/server/services/members/api.js | 1 + .../admin/__snapshots__/members.test.js.snap | 15 +++++ ghost/core/test/e2e-api/admin/members.test.js | 32 +++++++++++ ghost/members-api/lib/MembersAPI.js | 2 + ghost/members-api/lib/repositories/member.js | 19 ++++++- 9 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 ghost/core/core/server/models/member-newsletter.js 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,