mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-01 02:41:39 -05:00
Retain newsletter subscriptions on suppression (#17373)
refs https://github.com/TryGhost/Product/issues/2610
This commit is contained in:
parent
5d5d33b930
commit
184c6ae951
34 changed files with 242 additions and 117 deletions
|
@ -22,7 +22,7 @@
|
|||
<span data-test-select-option={{newsletter.name}}>{{newsletter.name}}</span>
|
||||
{{!-- TODO: remove conditional when author/editor can fetch member counts --}}
|
||||
{{#if @publishOptions.user.isAdmin}}
|
||||
<span class="gh-newsletter-count">{{format-number newsletter.count.members}}</span>
|
||||
<span class="gh-newsletter-count">{{format-number newsletter.count.active_members}}</span>
|
||||
{{/if}}
|
||||
</PowerSelect>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import {MATCH_RELATION_OPTIONS} from './relation-options';
|
|||
|
||||
export const SUBSCRIBED_FILTER = {
|
||||
label: 'Newsletter subscription',
|
||||
name: 'subscribed',
|
||||
name: 'subscribed',
|
||||
columnLabel: 'Subscribed',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
valueType: 'options',
|
||||
|
@ -31,8 +31,8 @@ export const NEWSLETTERS_FILTER = (newsletterList) => {
|
|||
const value = flt.value;
|
||||
|
||||
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
|
||||
? `newsletters.slug:${newsletter.slug}`
|
||||
: `newsletters.slug:-${newsletter.slug}`;
|
||||
? `newsletters.slug:${newsletter.slug}+email_disabled:0`
|
||||
: `newsletters.slug:-${newsletter.slug},email_disabled:1`;
|
||||
},
|
||||
parseNqlFilter: (flt) => {
|
||||
if (!flt['newsletters.slug']) {
|
||||
|
@ -62,7 +62,7 @@ export const NEWSLETTERS_FILTER = (newsletterList) => {
|
|||
{label: 'Unsubscribed', name: 'false'}
|
||||
]
|
||||
};
|
||||
newsletters.push(filter);
|
||||
newsletters.push(filter);
|
||||
});
|
||||
return newsletters;
|
||||
};
|
||||
|
|
|
@ -52,7 +52,7 @@ export default class EditNewsletterModal extends Component {
|
|||
|
||||
const result = yield newsletter.save({
|
||||
adapterOptions: {
|
||||
include: 'count.members,count.posts'
|
||||
include: 'count.active_members,count.posts'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
<label for="opt-in-existing" class="modal-fullsettings-title">Opt-in existing subscribers</label>
|
||||
<p>
|
||||
{{#if this.optInExisting}}
|
||||
{{#let (members-count-fetcher query=(hash filter="newsletters.status:active")) as |countFetcher|}}
|
||||
{{#let (members-count-fetcher query=(hash filter="newsletters.status:active+email_disabled:0")) as |countFetcher|}}
|
||||
This newsletter will be available to <strong>all members</strong>. Your {{#if countFetcher.count}}<strong>{{countFetcher.count}}</strong>{{/if}} existing subscriber{{#if (gt countFetcher.count 1)}}s{{/if}} will also be opted-in to receive it.
|
||||
{{/let}}
|
||||
{{else}}
|
||||
|
|
|
@ -49,7 +49,7 @@ export default class NewNewsletterModal extends Component {
|
|||
});
|
||||
|
||||
// Re-fetch newsletter data to refresh counts
|
||||
yield this.store.query('newsletter', {include: 'count.members,count.posts', limit: 'all'});
|
||||
yield this.store.query('newsletter', {include: 'count.active_members,count.posts', limit: 'all'});
|
||||
this.args.data.afterSave?.(result);
|
||||
|
||||
return result;
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
{{/if}}
|
||||
<div class="gh-newsletter-card-block stats-block {{unless this.displayingDefault "multiple"}}">
|
||||
<div>
|
||||
<h3 class="gh-newsletter-card-name">{{format-number newsletter.count.members}}</h3>
|
||||
<h3 class="gh-newsletter-card-name">{{format-number newsletter.count.active_members}}</h3>
|
||||
<p class="gh-newsletter-card-description">Subscribers</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
@ -132,7 +132,7 @@ export default class NewsletterManagementComponent extends Component {
|
|||
|
||||
@task
|
||||
*loadNewslettersTask() {
|
||||
const newsletters = yield this.store.query('newsletter', {include: 'count.members,count.posts', limit: 'all'});
|
||||
const newsletters = yield this.store.query('newsletter', {include: 'count.active_members,count.posts', limit: 'all'});
|
||||
|
||||
this.updateFilteredNewsletters();
|
||||
|
||||
|
|
|
@ -43,13 +43,13 @@ export default class Newsletter extends Model.extend(ValidationEngine) {
|
|||
@attr _meta;
|
||||
|
||||
/**
|
||||
* The filter that we should use to filter out members that are subscribed to this newsletter
|
||||
* The filter that we should use to filter out members that are actively subscribed to this newsletter
|
||||
*/
|
||||
get recipientFilter() {
|
||||
const idFilter = 'newsletters.slug:' + this.slug;
|
||||
const filter = [`newsletters.slug:${this.slug}`, 'email_disabled:0'];
|
||||
if (this.visibility === 'paid') {
|
||||
return idFilter + '+status:-free';
|
||||
filter.push('status:-free');
|
||||
}
|
||||
return idFilter;
|
||||
return filter.join('+');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -298,7 +298,7 @@ export default class PublishOptions {
|
|||
|
||||
// newsletters
|
||||
if (!this.user.isContributor) {
|
||||
promises.push(this.store.query('newsletter', {status: 'active', limit: 'all', include: 'count.members'}));
|
||||
promises.push(this.store.query('newsletter', {status: 'active', limit: 'all', include: 'count.active_members'}));
|
||||
}
|
||||
|
||||
yield Promise.all(promises);
|
||||
|
|
|
@ -156,8 +156,8 @@ describe('Acceptance: Publish flow', function () {
|
|||
|
||||
// at least one member is required for publish+send to be available
|
||||
const label = this.server.create('label');
|
||||
this.server.createList('member', 3, {status: 'free', labels: [label]});
|
||||
this.server.createList('member', 4, {status: 'paid'});
|
||||
this.server.createList('member', 3, {status: 'free', email_disabled: 0, labels: [label]});
|
||||
this.server.createList('member', 4, {status: 'paid', email_disabled: 0});
|
||||
});
|
||||
|
||||
it('can publish+send with single newsletter', async function () {
|
||||
|
@ -270,7 +270,7 @@ describe('Acceptance: Publish flow', function () {
|
|||
subscribeOnSignup: true
|
||||
});
|
||||
|
||||
this.server.create('member', {newsletters: [newsletter], status: 'free'});
|
||||
this.server.create('member', {newsletters: [newsletter], status: 'free', email_disabled: 0});
|
||||
|
||||
await loginAsRole('Administrator', this.server);
|
||||
const post = this.server.create('post', {status: 'draft'});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const models = require('../../models');
|
||||
const allowedIncludes = ['count.posts', 'count.members'];
|
||||
const allowedIncludes = ['count.posts', 'count.members', 'count.active_members'];
|
||||
|
||||
const newslettersService = require('../../services/newsletters');
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
const {createAddColumnMigration} = require('../../utils');
|
||||
|
||||
module.exports = createAddColumnMigration('members', 'email_disabled', {
|
||||
type: 'boolean',
|
||||
nullable: false,
|
||||
defaultTo: false
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
const logging = require('@tryghost/logging');
|
||||
const {createTransactionalMigration} = require('../../utils');
|
||||
|
||||
module.exports = createTransactionalMigration(
|
||||
async function up(knex) {
|
||||
logging.info('Setting email_disabled to true for all members that have their email on the suppression list');
|
||||
|
||||
await knex('members')
|
||||
.join('suppressions', 'members.email', 'suppressions.email')
|
||||
.update({
|
||||
email_disabled: true
|
||||
});
|
||||
},
|
||||
async function down(knex) {
|
||||
logging.info('Setting email_disabled to false for all members');
|
||||
|
||||
await knex('members')
|
||||
.update({
|
||||
email_disabled: false
|
||||
});
|
||||
}
|
||||
);
|
|
@ -429,6 +429,7 @@ module.exports = {
|
|||
email_count: {type: 'integer', unsigned: true, nullable: false, defaultTo: 0},
|
||||
email_opened_count: {type: 'integer', unsigned: true, nullable: false, defaultTo: 0},
|
||||
email_open_rate: {type: 'integer', unsigned: true, nullable: true, index: true},
|
||||
email_disabled: {type: 'boolean', nullable: false, defaultTo: false},
|
||||
last_seen_at: {type: 'dateTime', nullable: true},
|
||||
last_commented_at: {type: 'dateTime', nullable: true},
|
||||
created_at: {type: 'dateTime', nullable: false},
|
||||
|
|
|
@ -471,7 +471,11 @@ const Member = ghostBookshelf.Model.extend({
|
|||
// we use raw queries instead of model relationships because model hydration is expensive
|
||||
const query = ghostBookshelf.knex('members_newsletters')
|
||||
.join('newsletters', 'members_newsletters.newsletter_id', '=', 'newsletters.id')
|
||||
.where('newsletters.status', 'active')
|
||||
.join('members', 'members_newsletters.member_id', '=', 'members.id')
|
||||
.where({
|
||||
'newsletters.status': 'active',
|
||||
'members.email_disabled': false
|
||||
})
|
||||
.distinct('member_id as id');
|
||||
|
||||
if (unfilteredOptions.transacting) {
|
||||
|
|
|
@ -151,6 +151,16 @@ const Newsletter = ghostBookshelf.Model.extend({
|
|||
.whereRaw('members_newsletters.newsletter_id = newsletters.id')
|
||||
.as('count__members');
|
||||
});
|
||||
},
|
||||
active_members(modelOrCollection) {
|
||||
modelOrCollection.query('columns', 'newsletters.*', (qb) => {
|
||||
qb.count('members_newsletters.id')
|
||||
.from('members_newsletters')
|
||||
.join('members', 'members.id', 'members_newsletters.member_id')
|
||||
.whereRaw('members_newsletters.newsletter_id = newsletters.id')
|
||||
.andWhere('members.email_disabled', false)
|
||||
.as('count__active_members');
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
|
|
|
@ -105,11 +105,10 @@ const deleteSuppression = async function (req, res) {
|
|||
try {
|
||||
const member = await membersService.ssr.getMemberDataFromSession(req, res);
|
||||
const options = {
|
||||
id: member.id,
|
||||
withRelated: ['newsletters']
|
||||
id: member.id
|
||||
};
|
||||
await emailSuppressionList.removeEmail(member.email);
|
||||
await membersService.api.members.update({subscribed: true}, options);
|
||||
await membersService.api.members.update({email_disabled: false}, options);
|
||||
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
|
|
|
@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "20437",
|
||||
"content-length": "20667",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -23852,7 +23852,7 @@ exports[`Activity Feed API Returns email delivered events in activity feed 2: [h
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "1338",
|
||||
"content-length": "1361",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -23886,7 +23886,7 @@ exports[`Activity Feed API Returns email opened events in activity feed 2: [head
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "1335",
|
||||
"content-length": "1358",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -23944,7 +23944,7 @@ exports[`Activity Feed API Returns email sent events in activity feed 2: [header
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "5004",
|
||||
"content-length": "5096",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -24204,7 +24204,7 @@ exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "23443",
|
||||
"content-length": "23627",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
|
|
@ -316,7 +316,7 @@ exports[`Members API - member attribution Returns sign up attributions of all ty
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "9427",
|
||||
"content-length": "9542",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -5174,7 +5174,7 @@ exports[`Members API Can subscribe to a newsletter 5: [headers] 1`] = `
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "5686",
|
||||
"content-length": "5778",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
|
|
@ -1462,7 +1462,68 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when browsing newsletters 1: [body] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when adding a newsletter 1: [body] 1`] = `
|
||||
Object {
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
"background_color": "light",
|
||||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 0,
|
||||
"members": 0,
|
||||
"posts": 0,
|
||||
},
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"description": null,
|
||||
"feedback_enabled": false,
|
||||
"footer_content": null,
|
||||
"header_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "My test newsletter 2",
|
||||
"sender_email": null,
|
||||
"sender_name": "Test",
|
||||
"sender_reply_to": "newsletter",
|
||||
"show_badge": true,
|
||||
"show_comment_cta": true,
|
||||
"show_feature_image": true,
|
||||
"show_header_icon": true,
|
||||
"show_header_name": true,
|
||||
"show_header_title": true,
|
||||
"show_latest_posts": false,
|
||||
"show_post_title_section": true,
|
||||
"show_subscription_details": false,
|
||||
"slug": "my-test-newsletter-2",
|
||||
"sort_order": 4,
|
||||
"status": "active",
|
||||
"subscribe_on_signup": true,
|
||||
"title_alignment": "center",
|
||||
"title_color": null,
|
||||
"title_font_category": "serif",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"visibility": "members",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members, active members & posts counts when adding a newsletter 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "913",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/newsletters\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-cache-invalidate": "/*",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members, active members & posts counts when browsing newsletters 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
|
@ -1480,6 +1541,7 @@ Object {
|
|||
"body_font_category": "sans_serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 0,
|
||||
"members": 0,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1518,6 +1580,7 @@ Object {
|
|||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 4,
|
||||
"members": 4,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1556,6 +1619,7 @@ Object {
|
|||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 3,
|
||||
"members": 3,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1594,6 +1658,7 @@ Object {
|
|||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 2,
|
||||
"members": 2,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1631,11 +1696,11 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when browsing newsletters 2: [headers] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when browsing newsletters 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "3795",
|
||||
"content-length": "3871",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -1644,7 +1709,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when editing newsletters 1: [body] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when editing newsletters 1: [body] 1`] = `
|
||||
Object {
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
|
@ -1652,6 +1717,7 @@ Object {
|
|||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 4,
|
||||
"members": 4,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1689,11 +1755,11 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when editing newsletters 2: [headers] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when editing newsletters 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "963",
|
||||
"content-length": "982",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -1703,7 +1769,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when reading a newsletter 1: [body] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when reading a newsletter 1: [body] 1`] = `
|
||||
Object {
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
|
@ -1711,6 +1777,7 @@ Object {
|
|||
"body_font_category": "serif",
|
||||
"border_color": null,
|
||||
"count": Object {
|
||||
"active_members": 4,
|
||||
"members": 4,
|
||||
"posts": 0,
|
||||
},
|
||||
|
@ -1748,11 +1815,11 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Newsletters API Can include members & posts counts when reading a newsletter 2: [headers] 1`] = `
|
||||
exports[`Newsletters API Can include members, active members & posts counts when reading a newsletter 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "954",
|
||||
"content-length": "973",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
|
|
@ -11,6 +11,7 @@ async function createMember(data) {
|
|||
const member = await models.Member.add({
|
||||
email: uuid.v4() + '@example.com',
|
||||
name: '',
|
||||
email_disabled: false,
|
||||
...data
|
||||
});
|
||||
|
||||
|
|
|
@ -72,9 +72,9 @@ describe('Newsletters API', function () {
|
|||
});
|
||||
});
|
||||
|
||||
it('Can include members & posts counts when browsing newsletters', async function () {
|
||||
it('Can include members, active members & posts counts when browsing newsletters', async function () {
|
||||
await agent
|
||||
.get(`newsletters/?include=count.members,count.posts`)
|
||||
.get(`newsletters/?include=count.members,count.active_members,count.posts`)
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
newsletters: new Array(4).fill(newsletterSnapshot)
|
||||
|
@ -85,9 +85,9 @@ describe('Newsletters API', function () {
|
|||
});
|
||||
});
|
||||
|
||||
it('Can include members & posts counts when reading a newsletter', async function () {
|
||||
it('Can include members, active members & posts counts when reading a newsletter', async function () {
|
||||
await agent
|
||||
.get(`newsletters/${fixtureManager.get('newsletters', 0).id}/?include=count.members,count.posts`)
|
||||
.get(`newsletters/${fixtureManager.get('newsletters', 0).id}/?include=count.members,count.active_members,count.posts`)
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
newsletters: new Array(1).fill(newsletterSnapshot)
|
||||
|
@ -143,7 +143,7 @@ describe('Newsletters API', function () {
|
|||
assert.equal(header_image, transformReadyPath);
|
||||
});
|
||||
|
||||
it('Can include members & posts counts when adding a newsletter', async function () {
|
||||
it('Can include members, active members & posts counts when adding a newsletter', async function () {
|
||||
const newsletter = {
|
||||
name: 'My test newsletter 2',
|
||||
sender_name: 'Test',
|
||||
|
@ -160,7 +160,7 @@ describe('Newsletters API', function () {
|
|||
};
|
||||
|
||||
await agent
|
||||
.post(`newsletters/?include=count.members,count.posts`)
|
||||
.post(`newsletters/?include=count.members,count.active_members,count.posts`)
|
||||
.body({newsletters: [newsletter]})
|
||||
.expectStatus(201)
|
||||
.matchBodySnapshot({
|
||||
|
@ -305,9 +305,9 @@ describe('Newsletters API', function () {
|
|||
});
|
||||
});
|
||||
|
||||
it('Can include members & posts counts when editing newsletters', async function () {
|
||||
it('Can include members, active members & posts counts when editing newsletters', async function () {
|
||||
const id = fixtureManager.get('newsletters', 0).id;
|
||||
await agent.put(`newsletters/${id}/?include=count.members,count.posts`)
|
||||
await agent.put(`newsletters/${id}/?include=count.members,count.active_members,count.posts`)
|
||||
.body({
|
||||
newsletters: [{
|
||||
name: 'Updated newsletter name 2'
|
||||
|
|
|
@ -429,7 +429,7 @@ exports[`Members API Member attribution Returns subscription created attribution
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "7960",
|
||||
"content-length": "8144",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyEtag, anyObjectId, anyUuid, anyISODateTime} = matchers;
|
||||
const models = require('../../../core/server/models');
|
||||
require('should');
|
||||
const should = require('should');
|
||||
|
||||
let membersAgent;
|
||||
|
||||
|
@ -32,10 +32,6 @@ const memberMatcherUnserialised = (newslettersCount) => {
|
|||
};
|
||||
};
|
||||
|
||||
async function getDefaultNewsletters() {
|
||||
return (await models.Newsletter.findAll({filter: 'status:active+subscribe_on_signup:true+visibility:members'})).models;
|
||||
}
|
||||
|
||||
describe('Comments API', function () {
|
||||
before(async function () {
|
||||
membersAgent = await agentProvider.getMembersAPIAgent();
|
||||
|
@ -202,43 +198,31 @@ describe('Comments API', function () {
|
|||
member.get('enable_comment_notifications').should.eql(true);
|
||||
});
|
||||
|
||||
it('can remove member from suppression list and resubscribe to default newsletters', async function () {
|
||||
const newsletters = await getDefaultNewsletters();
|
||||
it('can remove a member\'s email from the suppression list', async function () {
|
||||
// add member's email to the suppression list
|
||||
await models.Suppression.add({
|
||||
email: member.get('email'),
|
||||
reason: 'bounce'
|
||||
});
|
||||
|
||||
// unsubscribe member from all newsletters
|
||||
await membersAgent
|
||||
.put(`/api/member/`)
|
||||
.body({
|
||||
newsletters: []
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot(memberMatcher(0))
|
||||
.expect(({body}) => {
|
||||
body.newsletters.should.eql([]);
|
||||
});
|
||||
// disable member's email
|
||||
await member.save({email_disabled: true});
|
||||
|
||||
// remove email from suppression list
|
||||
// remove suppression
|
||||
await membersAgent
|
||||
.delete(`/api/member/suppression`)
|
||||
.expectStatus(204)
|
||||
.expectEmptyBody();
|
||||
|
||||
// check that member re-subscribed to default newsletters after removing from suppression list
|
||||
await membersAgent
|
||||
.get(`/api/member/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot(memberMatcher(2))
|
||||
.expect(({body}) => {
|
||||
// body should contain default newsletters
|
||||
body.newsletters[0].id.should.eql(newsletters[0].get('id'));
|
||||
body.newsletters[1].id.should.eql(newsletters[1].get('id'));
|
||||
});
|
||||
// check that member is removed from suppression list
|
||||
const suppression = await models.Suppression.findOne({email: member.get('email')});
|
||||
|
||||
should(suppression).be.null();
|
||||
|
||||
// check that member's email is enabled
|
||||
await member.refresh();
|
||||
|
||||
should(member.get('email_disabled')).be.false();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -207,7 +207,8 @@ describe('Batch sending tests', function () {
|
|||
status: 'free',
|
||||
newsletters: [{
|
||||
id: fixtureManager.get('newsletters', 0).id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
return r;
|
||||
|
@ -652,7 +653,8 @@ describe('Batch sending tests', function () {
|
|||
labels: [{name: 'replacements-tests'}],
|
||||
newsletters: [{
|
||||
id: fixtureManager.get('newsletters', 0).id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
const {html, plaintext} = await sendEmail(agent, {
|
||||
|
@ -685,7 +687,8 @@ describe('Batch sending tests', function () {
|
|||
labels: [{name: 'replacements-tests-2'}],
|
||||
newsletters: [{
|
||||
id: fixtureManager.get('newsletters', 0).id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
const {html, plaintext} = await sendEmail(agent, {
|
||||
|
@ -859,7 +862,8 @@ describe('Batch sending tests', function () {
|
|||
labels: [{name: 'subscription-box-tests'}],
|
||||
newsletters: [{
|
||||
id: fixtureManager.get('newsletters', 0).id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
mockSetting('email_track_clicks', false); // Disable link replacement for this test
|
||||
|
@ -892,7 +896,8 @@ describe('Batch sending tests', function () {
|
|||
newsletters: [{
|
||||
id: fixtureManager.get('newsletters', 0).id
|
||||
}],
|
||||
status: 'comped'
|
||||
status: 'comped',
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
mockSetting('email_track_clicks', false); // Disable link replacement for this test
|
||||
|
|
|
@ -18,7 +18,8 @@ describe('MemberStripeCustomer Model', function run() {
|
|||
const context = testUtils.context.admin;
|
||||
|
||||
const member = await Member.add({
|
||||
email: 'test@test.test'
|
||||
email: 'test@test.test',
|
||||
email_disabled: false
|
||||
});
|
||||
|
||||
const product = await Product.add({
|
||||
|
@ -83,7 +84,8 @@ describe('MemberStripeCustomer Model', function run() {
|
|||
it('Is correctly mapped to the member', async function () {
|
||||
const context = testUtils.context.admin;
|
||||
const member = await Member.add({
|
||||
email: 'test@test.member'
|
||||
email: 'test@test.member',
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
await MemberStripeCustomer.add({
|
||||
|
@ -110,7 +112,8 @@ describe('MemberStripeCustomer Model', function run() {
|
|||
it('Cascades to members_stripe_customers_subscriptions', async function () {
|
||||
const context = testUtils.context.admin;
|
||||
const member = await Member.add({
|
||||
email: 'test@test.member'
|
||||
email: 'test@test.member',
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
await MemberStripeCustomer.add({
|
||||
|
|
|
@ -22,7 +22,8 @@ describe('Member Model', function run() {
|
|||
const context = testUtils.context.admin;
|
||||
await Member.add({
|
||||
email: 'test@test.member',
|
||||
labels: []
|
||||
labels: [],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
const member = await Member.findOne({
|
||||
email: 'test@test.member'
|
||||
|
@ -115,7 +116,8 @@ describe('Member Model', function run() {
|
|||
it('Is correctly mapped to the stripe customers', async function () {
|
||||
const context = testUtils.context.admin;
|
||||
const testMember = await Member.add({
|
||||
email: 'test@test.member'
|
||||
email: 'test@test.member',
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
await MemberStripeCustomer.add({
|
||||
|
@ -159,7 +161,8 @@ describe('Member Model', function run() {
|
|||
labels: [{
|
||||
name: 'A label',
|
||||
slug: 'a-unique-slug-for-testing-members-model'
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
const member = await Member.findOne({
|
||||
email: 'test@test.member'
|
||||
|
@ -284,7 +287,8 @@ describe('Member Model', function run() {
|
|||
email: 'testing-products@test.member',
|
||||
products: [{
|
||||
id: product.id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
}, {
|
||||
...context,
|
||||
withRelated: ['products']
|
||||
|
@ -317,7 +321,8 @@ describe('Member Model', function run() {
|
|||
email: 'filter-test@test.member',
|
||||
products: [{
|
||||
id: vipProduct.id
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
const member = await Member.findOne({
|
||||
|
@ -362,7 +367,8 @@ describe('Member Model', function run() {
|
|||
name: 'VIP',
|
||||
slug: 'vip',
|
||||
type: 'paid'
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
const member = await Member.findOne({
|
||||
|
@ -386,7 +392,8 @@ describe('Member Model', function run() {
|
|||
name: 'VIP',
|
||||
slug: 'vip',
|
||||
type: 'paid'
|
||||
}]
|
||||
}],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
const member = await Member.findOne({
|
||||
|
@ -406,7 +413,8 @@ describe('Member Model', function run() {
|
|||
|
||||
const member = await Member.add({
|
||||
email: 'test@test.member',
|
||||
labels: []
|
||||
labels: [],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
const product = await Product.add({
|
||||
|
@ -511,7 +519,8 @@ describe('Member Model', function run() {
|
|||
let email = 'test@offers.com';
|
||||
const member = await Member.add({
|
||||
email: email,
|
||||
labels: []
|
||||
labels: [],
|
||||
email_disabled: false
|
||||
}, context);
|
||||
|
||||
const product = await Product.add({
|
||||
|
|
|
@ -17,7 +17,8 @@ describe('StripeCustomerSubscription Model', function run() {
|
|||
it('Is correctly mapped to the stripe customer', async function () {
|
||||
const context = testUtils.context.admin;
|
||||
const member = await Member.add({
|
||||
email: 'test@test.member'
|
||||
email: 'test@test.member',
|
||||
email_disabled: false
|
||||
}, context);
|
||||
await MemberStripeCustomer.add({
|
||||
member_id: member.get('id'),
|
||||
|
|
|
@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route
|
|||
*/
|
||||
describe('DB version integrity', function () {
|
||||
// Only these variables should need updating
|
||||
const currentSchemaHash = '8e299caf33efbcf8a12c9cefaf8f6500';
|
||||
const currentSchemaHash = '66b77c27bab9cb43e1076fbb6c6d1233';
|
||||
const currentFixturesHash = 'af43eef1ac4f14fc1bc0ea351300420f';
|
||||
const currentSettingsHash = '4f23a583335dcb4cb3fae553122ea200';
|
||||
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
|
||||
|
|
|
@ -302,14 +302,16 @@ DataGenerator.Content = {
|
|||
email: 'member1@test.com',
|
||||
name: 'Mr Egg',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b340',
|
||||
status: 'free'
|
||||
status: 'free',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
email: 'member2@test.com',
|
||||
email_open_rate: 50,
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b341',
|
||||
status: 'free'
|
||||
status: 'free',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
|
@ -317,28 +319,32 @@ DataGenerator.Content = {
|
|||
name: 'Egon Spengler',
|
||||
email_open_rate: 80,
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b342',
|
||||
status: 'paid'
|
||||
status: 'paid',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
email: 'trialing@test.com',
|
||||
name: 'Ray Stantz',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b343',
|
||||
status: 'paid'
|
||||
status: 'paid',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
email: 'comped@test.com',
|
||||
name: 'Vinz Clortho',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b344',
|
||||
status: 'paid'
|
||||
status: 'paid',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
email: 'vip@test.com',
|
||||
name: 'Winston Zeddemore',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b345',
|
||||
status: 'free'
|
||||
status: 'free',
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
|
@ -346,7 +352,8 @@ DataGenerator.Content = {
|
|||
name: 'Peter Venkman',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b346',
|
||||
status: 'paid',
|
||||
subscribed: false
|
||||
subscribed: false,
|
||||
email_disabled: false
|
||||
},
|
||||
{
|
||||
id: ObjectId().toHexString(),
|
||||
|
@ -354,7 +361,8 @@ DataGenerator.Content = {
|
|||
name: 'Dana Barrett',
|
||||
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b347',
|
||||
status: 'paid',
|
||||
subscribed: false
|
||||
subscribed: false,
|
||||
email_disabled: false
|
||||
}
|
||||
],
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ class EmailSegmenter {
|
|||
}
|
||||
|
||||
getMemberFilterForSegment(newsletter, emailRecipientFilter, segment) {
|
||||
const filter = [`newsletters.id:${newsletter.id}`];
|
||||
const filter = [`newsletters.id:${newsletter.id}`, 'email_disabled:0'];
|
||||
|
||||
switch (emailRecipientFilter) {
|
||||
case 'all':
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('Email segmenter', function () {
|
|||
);
|
||||
listStub.calledOnce.should.be.true();
|
||||
listStub.calledWith({
|
||||
filter: 'newsletters.id:newsletter-123'
|
||||
filter: 'newsletters.id:newsletter-123+email_disabled:0'
|
||||
}).should.be.true();
|
||||
response.should.eql(12);
|
||||
});
|
||||
|
@ -94,7 +94,7 @@ describe('Email segmenter', function () {
|
|||
|
||||
listStub.calledOnce.should.be.true();
|
||||
listStub.calledWith({
|
||||
filter: 'newsletters.id:newsletter-123+(labels:test)+status:-free'
|
||||
filter: 'newsletters.id:newsletter-123+email_disabled:0+(labels:test)+status:-free'
|
||||
}).should.be.true();
|
||||
response.should.eql(12);
|
||||
});
|
||||
|
@ -118,7 +118,7 @@ describe('Email segmenter', function () {
|
|||
|
||||
listStub.calledOnce.should.be.true();
|
||||
listStub.calledWith({
|
||||
filter: 'newsletters.id:newsletter-123+(labels:test)+(status:free)'
|
||||
filter: 'newsletters.id:newsletter-123+email_disabled:0+(labels:test)+(status:free)'
|
||||
}).should.be.true();
|
||||
response.should.eql(12);
|
||||
});
|
||||
|
|
|
@ -355,7 +355,7 @@ module.exports = function MembersAPI({
|
|||
if (!member) {
|
||||
return;
|
||||
}
|
||||
await memberRepository.update({newsletters: []}, {id: member.id});
|
||||
await memberRepository.update({email_disabled: true}, {id: member.id});
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -224,6 +224,7 @@ module.exports = class MemberRepository {
|
|||
* @param {Object} [data.stripeCustomer]
|
||||
* @param {string} [data.offerId]
|
||||
* @param {import('@tryghost/member-attribution/lib/Attribution').AttributionResource} [data.attribution]
|
||||
* @param {boolean} [data.email_disabled]
|
||||
* @param {*} options
|
||||
* @returns
|
||||
*/
|
||||
|
@ -247,7 +248,7 @@ module.exports = class MemberRepository {
|
|||
});
|
||||
}
|
||||
|
||||
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters']);
|
||||
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled']);
|
||||
|
||||
// Throw error if email is invalid using latest validator
|
||||
if (!validator.isEmail(memberData.email, {legacy: false})) {
|
||||
|
@ -257,6 +258,8 @@ module.exports = class MemberRepository {
|
|||
});
|
||||
}
|
||||
|
||||
memberData.email_disabled = !!memberData.email_disabled;
|
||||
|
||||
if (memberData.products && memberData.products.length > 1) {
|
||||
throw new errors.BadRequestError({message: tpl(messages.moreThanOneProduct)});
|
||||
}
|
||||
|
@ -415,7 +418,8 @@ module.exports = class MemberRepository {
|
|||
'enable_comment_notifications',
|
||||
'last_seen_at',
|
||||
'last_commented_at',
|
||||
'expertise'
|
||||
'expertise',
|
||||
'email_disabled'
|
||||
]);
|
||||
|
||||
// Trim whitespaces from expertise
|
||||
|
|
Loading…
Add table
Reference in a new issue