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,