Filter members by email disabled (#18884)

fixes https://github.com/TryGhost/Product/issues/4108

- Updates filters behind a new alpha feature flag so you can also filter
on members who have email disabled (because the email had a permanent
bounce, they reported spam or the email address is invalid)
- When returning members, we now also use the email_disabled flag to set
email_suppression.suppressed correctly (in case they are out of sync,
which should normally never happen).
This commit is contained in:
Simon Backx 2023-11-14 14:37:01 +01:00 committed by GitHub
parent 50951d6eae
commit 370c6b465b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 221 additions and 69 deletions

View File

@ -55,6 +55,10 @@ const features = [{
title: 'AdminX Offers',
description: 'Enables the new offers UI in AdminX settings',
flag: 'adminXOffers'
},{
title: 'Filter by email disabled',
description: 'Allows filtering members by email disabled',
flag: 'filterEmailDisabled'
}];
const AlphaFeatures: React.FC = () => {

View File

@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import moment from 'moment-timezone';
import nql from '@tryghost/nql-lang';
import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTER, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters';
import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTERS, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters';
import {TrackedArray} from 'tracked-built-ins';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
@ -25,6 +25,12 @@ const FILTER_GROUPS = [
SIGNUP_ATTRIBUTION_FILTER
]
},
{
name: 'Newsletters',
filters: [
NEWSLETTERS_FILTERS
]
},
{
name: 'Subscription',
filters: [
@ -52,6 +58,15 @@ const FILTER_GROUPS = [
];
const FILTER_PROPERTIES = FILTER_GROUPS.flatMap(group => group.filters.map((f) => {
if (typeof f === 'function') {
return (options) => {
return f({
...options,
group: group.name
});
};
}
f.group = group.name;
return f;
}));
@ -148,16 +163,21 @@ export default class MembersFilter extends Component {
get filterProperties() {
let availableFilters = FILTER_PROPERTIES;
// find list of newsletters from store and add them to filter list if there are more than one newsletter
// it also removes the 'subscribed' filter from the list as that would unsubscribe members from all newsletters, instead replace it with a filter for each newsletter
if (this.newsletters?.length > 1) {
// remove the 'subscribed' filter from the list
availableFilters = availableFilters.filter(prop => prop.name !== 'subscribed');
// find the index of the 'basic' group and insert the 'multiple newsletters' filter after it
const indexes = availableFilters.map((obj, index) => (obj.group === 'Basic' ? index : null)).filter(i => i !== null);
const lastIndex = indexes.pop();
availableFilters.splice(lastIndex + 1, 0, ...NEWSLETTERS_FILTER(this.newsletters));
}
// Convert the method filters to properties
availableFilters = availableFilters.flatMap((filter) => {
if (typeof filter === 'function') {
const filters = filter({
newsletters: this.newsletters ?? [],
feature: this.feature
});
if (Array.isArray(filters)) {
return filters;
}
return [filters];
}
return [filter];
});
// only add the offers filter if there are any offers
if (this.offers.length > 0) {
availableFilters = availableFilters.concat(OFFERS_FILTER);

View File

@ -1,61 +1,167 @@
import {MATCH_RELATION_OPTIONS} from './relation-options';
export const SUBSCRIBED_FILTER = {
label: 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? '(subscribed:true+email_disabled:0)'
: '(subscribed:false,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or;
if (!comparator || comparator.length !== 2) {
return;
}
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
return;
}
const subscribed = comparator[0].subscribed;
export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
if (feature.filterEmailDisabled) {
return {
value: subscribed ? 'true' : 'false',
relation: 'is'
};
},
options: [
{label: 'Subscribed', name: 'true'},
{label: 'Unsubscribed', name: 'false'}
],
getColumnValue: (member, flt) => {
const relation = flt.relation;
const value = flt.value;
label: newsletters.length > 1 ? 'Newsletter subscriptions' : 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: newsletters.length > 1 ? 'Newsletters' : group,
// Only show the filter for multiple newsletters if feature flag is enabled
feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? 'Subscribed'
: 'Unsubscribed'
if (value === 'email-disabled') {
if (relation === 'is') {
return '(email_disabled:1)';
}
return '(email_disabled:0)';
}
if (relation === 'is') {
if (value === 'subscribed') {
return '(subscribed:true+email_disabled:0)';
}
return '(subscribed:false+email_disabled:0)';
}
// relation === 'is-not'
if (value === 'subscribed') {
return '(subscribed:false,email_disabled:1)';
}
return '(subscribed:true,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility
if (!comparator || comparator.length !== 2) {
const filter = flt.yg || flt;
if (filter && filter.email_disabled !== undefined) {
if (filter.email_disabled) {
return {
value: 'email-disabled',
relation: 'is'
};
}
return {
value: 'email-disabled',
relation: 'is-not'
};
}
return;
}
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
return;
}
const usedOr = flt.$or !== undefined;
const subscribed = comparator[0].subscribed;
if (usedOr) {
// Is not
return {
value: !subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is-not'
};
}
return {
value: subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is'
};
},
options: [
{label: newsletters.length > 1 ? 'Subscribed to one or more' : 'Subscribed', name: 'subscribed'},
{label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'},
{label: 'Email disabled', name: 'email-disabled'}
],
getColumnValue: (member) => {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
return member.newsletters.length > 0 ? {
text: 'Subscribed'
} : {
text: 'Unsubscribed'
};
}
};
}
if (newsletters.length > 1) {
// Disable
// Only show the filter for multiple newsletters if feature flag is enabled
return [];
}
return {
label: 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: group,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? '(subscribed:true+email_disabled:0)'
: '(subscribed:false,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or;
if (!comparator || comparator.length !== 2) {
return;
}
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
return;
}
const subscribed = comparator[0].subscribed;
return {
value: subscribed ? 'true' : 'false',
relation: 'is'
};
},
options: [
{label: 'Subscribed', name: 'true'},
{label: 'Unsubscribed', name: 'false'}
],
getColumnValue: (member, flt) => {
const relation = flt.relation;
const value = flt.value;
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? 'Subscribed'
: 'Unsubscribed'
};
}
};
};
export const NEWSLETTERS_FILTER = (newsletterList) => {
let newsletters = [];
newsletterList.forEach((newsletter) => {
const filter = {
export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
if (newsletters.length <= 1) {
return [];
}
return newsletters.map((newsletter) => {
return {
label: newsletter.name,
name: `newsletters.slug:${newsletter.slug}`,
relationOptions: MATCH_RELATION_OPTIONS,
group: 'Newsletters',
group,
valueType: 'options',
buildNqlFilter: (flt) => {
const relation = flt.relation;
@ -98,9 +204,26 @@ export const NEWSLETTERS_FILTER = (newsletterList) => {
options: [
{label: 'Subscribed', name: 'true'},
{label: 'Unsubscribed', name: 'false'}
]
],
columnLabel: newsletter.name,
getColumnValue: (member, flt) => {
const relation = flt.relation;
const value = flt.value;
if (feature.filterEmailDisabled) {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
}
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? 'Subscribed'
: 'Unsubscribed'
};
}
};
newsletters.push(filter);
});
return newsletters;
};

View File

@ -78,6 +78,7 @@ export default class FeatureService extends Service {
@feature('recommendations') recommendations;
@feature('lexicalIndicators') lexicalIndicators;
@feature('editorEmojiPicker') editorEmojiPicker;
@feature('filterEmailDisabled') filterEmailDisabled;
_user = null;

View File

@ -44,7 +44,9 @@ const ALPHA_FEATURES = [
'importMemberTier',
'lexicalIndicators',
'listUnsubscribeHeader',
'adminXOffers'
'editorEmojiPicker',
'adminXOffers',
'filterEmailDisabled'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -242,7 +242,7 @@ module.exports = class MemberBREADService {
const suppressionData = await this.emailSuppressionList.getSuppressionData(member.email);
member.email_suppression = {
suppressed: suppressionData.suppressed,
suppressed: suppressionData.suppressed || !!model.get('email_disabled'),
info: suppressionData.info
};
@ -401,11 +401,10 @@ module.exports = class MemberBREADService {
const subscriptions = page.data.flatMap(model => model.related('stripeSubscriptions').slice());
const offerMap = await this.fetchSubscriptionOffers(subscriptions);
const members = page.data.map(model => model.toJSON(options));
const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(page.data.map(member => member.get('email')));
const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(members.map(member => member.email));
const data = members.map((member, index) => {
const data = page.data.map((model, index) => {
const member = model.toJSON(options);
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
this.attachSubscriptionsToMember(member);
this.attachOffersToSubscriptions(member, offerMap);
@ -413,7 +412,7 @@ module.exports = class MemberBREADService {
delete member.products;
}
member.email_suppression = {
suppressed: bulkSuppressionData[index].suppressed,
suppressed: bulkSuppressionData[index].suppressed || !!model.get('email_disabled'),
info: bulkSuppressionData[index].info
};
return member;

View File

@ -42,7 +42,10 @@ describe('MemberBreadService', function () {
memberModelStub = {
id: MEMBER_ID,
related: sinon.stub().returns([]),
toJSON: sinon.stub().returns({...memberModelJSON})
toJSON: sinon.stub().returns({...memberModelJSON}),
get: function (key) {
return this[key];
}
};
memberRepositoryStub = {
get: sinon.stub().resolves(null),