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:
parent
50951d6eae
commit
370c6b465b
@ -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 = () => {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -78,6 +78,7 @@ export default class FeatureService extends Service {
|
||||
@feature('recommendations') recommendations;
|
||||
@feature('lexicalIndicators') lexicalIndicators;
|
||||
@feature('editorEmojiPicker') editorEmojiPicker;
|
||||
@feature('filterEmailDisabled') filterEmailDisabled;
|
||||
|
||||
_user = null;
|
||||
|
||||
|
@ -44,7 +44,9 @@ const ALPHA_FEATURES = [
|
||||
'importMemberTier',
|
||||
'lexicalIndicators',
|
||||
'listUnsubscribeHeader',
|
||||
'adminXOffers'
|
||||
'editorEmojiPicker',
|
||||
'adminXOffers',
|
||||
'filterEmailDisabled'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
@ -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;
|
||||
|
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user