Added specific newsletter support for bulk unsubscribes (#15742)

closes https://github.com/TryGhost/Team/issues/2013

Added support to bulk unsubscribe a selected (filtered) list on members from specific, selected newsletters.
This commit is contained in:
Ronald Langeveld 2022-11-16 14:29:00 +07:00 committed by GitHub
parent 548a3c7b93
commit e0787b4e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 155 additions and 13 deletions

View File

@ -43,11 +43,28 @@
{{#if countFetcher.isLoading}}
<GhLoadingSpinner />
{{else}}
{{#if this.hasMultipleNewsletters }}
<p>
Which newsletter should these
<strong data-test-text="unsubscribe-count">{{gh-pluralize countFetcher.count "member"}}</strong> be unsubscribed from?
</p>
<OneWaySelect
@value={{this.selectedNewsletterId}}
@options={{this.newsletterList}}
@optionLabelPath="name"
@optionValuePath="value"
@optionTargetPath="value"
@update={{this.setSelectedNewsletter}}
data-test-select="members-single-newsletter-unsubscribe"
/>
{{else}}
<p>
You're about to unsubscribe
<strong data-test-text="unsubscribe-count">{{gh-pluralize countFetcher.count "member"}}</strong> from email newsletters.
Are you sure?
</p>
{{/if}}
{{/if}}
{{/let}}
{{else}}
@ -62,6 +79,7 @@
<span>Close</span>
</button>
{{else}}
{{#if this.hasMultipleNewsletters}}
<button class="gh-btn" data-test-button="cancel" type="button" {{on "click" @close}}>
<span>Cancel</span>
</button>
@ -74,6 +92,21 @@
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
{{else}}
<button class="gh-btn" data-test-button="cancel" type="button" {{on "click" @close}}>
<span>Cancel</span>
</button>
<GhTaskButton
@disabled={{this.isDisabled}}
@buttonText="Unsubscribe members"
@successText="Unsubscribed"
@task={{this.bulkUnsubscribeTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
{{/if}}
{{/if}}
</div>

View File

@ -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;

View File

@ -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);
}
});

View File

@ -0,0 +1,9 @@
const ghostBookshelf = require('./base');
const MemberNewsletter = ghostBookshelf.Model.extend({
tableName: 'members_newsletters'
});
module.exports = {
MemberNewsletter: ghostBookshelf.model('MemberNewsletter', MemberNewsletter)
};

View File

@ -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,

View File

@ -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 {

View File

@ -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`)

View File

@ -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,

View File

@ -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,