From 986a7526f559edaa5bf60060407fddf4a971293e Mon Sep 17 00:00:00 2001 From: Naz Date: Thu, 1 Jul 2021 20:52:55 +0400 Subject: [PATCH] Added member partitioner based on segment refs https://github.com/TryGhost/Team/issues/828 - Before sending out batches with members we need to partition all members based on the segment they belong to. Special segment "unsegmented" is used in case none of the segments used in the emal cards cover part of the members set (for example only free members card used when emailing all members) --- core/server/services/mega/mega.js | 45 +++++++++++++- test/unit/services/mega/mega_spec.js | 88 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/unit/services/mega/mega_spec.js diff --git a/core/server/services/mega/mega.js b/core/server/services/mega/mega.js index 55427f7dd6..ce8f25223b 100644 --- a/core/server/services/mega/mega.js +++ b/core/server/services/mega/mega.js @@ -1,6 +1,7 @@ const _ = require('lodash'); const Promise = require('bluebird'); const debug = require('@tryghost/debug')('mega'); +const tpl = require('@tryghost/tpl'); const url = require('url'); const moment = require('moment'); const ObjectID = require('bson-objectid'); @@ -18,6 +19,10 @@ const models = require('../../models'); const postEmailSerializer = require('./post-email-serializer'); const {getSegmentsFromHtml} = require('./segment-parser'); +const messages = { + invalidSegment: 'Invalid segment value. Use one of the valid:"status:free" or "status:-free" values.' +}; + const getFromAddress = () => { let fromAddress = membersService.config.getEmailFromAddress(); @@ -358,6 +363,43 @@ async function getEmailMemberRows({emailModel, memberSegment, options}) { return memberRows; } +/** + * Partitions array of member records according to the segment they belong to + * + * @param {Object[]} memberRows raw member rows to partition + * @param {string[]} segments segment filters to partition batches by + * + * @returns {Object} partitioned memberRows with keys that correspond segment names + */ +function partitionMembersBySegment(memberRows, segments) { + const partitions = {}; + + for (const memberSegment of segments) { + let segmentedMemberRows; + + // NOTE: because we only support two types of segments at the moment the logic was kept dead simple + // in the future this segmentation should probably be substituted with NQL: + // memberRows.filter(member => nql(memberSegment).queryJSON(member)); + if (memberSegment === 'status:free') { + segmentedMemberRows = memberRows.filter(member => member.status === 'free'); + memberRows = memberRows.filter(member => member.status !== 'free'); + } else if (memberSegment === 'status:-free') { + segmentedMemberRows = memberRows.filter(member => member.status !== 'free'); + memberRows = memberRows.filter(member => member.status === 'free'); + } else { + throw new errors.ValidationError(tpl(messages.invalidSegment)); + } + + partitions[memberSegment] = segmentedMemberRows; + } + + if (memberRows.length) { + partitions.unsegmented = memberRows; + } + + return partitions; +} + /** * Detects segment filters in emailModel's html and creates separate batches per segment * @@ -471,7 +513,8 @@ module.exports = { addEmail, retryFailedEmail, sendTestEmail, - handleUnsubscribeRequest + handleUnsubscribeRequest, + partitionMembersBySegment // NOTE: only exposed for testing }; /** diff --git a/test/unit/services/mega/mega_spec.js b/test/unit/services/mega/mega_spec.js new file mode 100644 index 0000000000..1f5d0b2758 --- /dev/null +++ b/test/unit/services/mega/mega_spec.js @@ -0,0 +1,88 @@ +const should = require('should'); +const errors = require('@tryghost/errors'); +const {partitionMembersBySegment} = require('../../../../core/server/services/mega/mega'); + +describe('MEGA', function () { + describe('partitionMembersBySegment', function () { + it('partition with no segments', function () { + const members = [{ + name: 'Free Rish', + status: 'free' + }, { + name: 'Free Matt', + status: 'free' + }, { + name: 'Paid Daniel', + status: 'paid' + }]; + const segments = []; + + const partitions = partitionMembersBySegment(members, segments); + + partitions.unsegmented.length.should.equal(3); + partitions.unsegmented[0].name.should.equal('Free Rish'); + }); + + it('partition members with single segment', function () { + const members = [{ + name: 'Free Rish', + status: 'free' + }, { + name: 'Free Matt', + status: 'free' + }, { + name: 'Paid Daniel', + status: 'paid' + }]; + const segments = ['status:free']; + + const partitions = partitionMembersBySegment(members, segments); + + should.exist(partitions['status:free']); + partitions['status:free'].length.should.equal(2); + partitions['status:free'][0].name.should.equal('Free Rish'); + partitions['status:free'][1].name.should.equal('Free Matt'); + + should.exist(partitions.unsegmented); + partitions.unsegmented.length.should.equal(1); + partitions.unsegmented[0].name.should.equal('Paid Daniel'); + }); + + it('partition members with two segments', function () { + const members = [{ + name: 'Free Rish', + status: 'free' + }, { + name: 'Free Matt', + status: 'free' + }, { + name: 'Paid Daniel', + status: 'paid' + }]; + const segments = ['status:free', 'status:-free']; + + const partitions = partitionMembersBySegment(members, segments); + + should.exist(partitions['status:free']); + partitions['status:free'].length.should.equal(2); + partitions['status:free'][0].name.should.equal('Free Rish'); + partitions['status:free'][1].name.should.equal('Free Matt'); + + should.exist(partitions['status:-free']); + partitions['status:-free'].length.should.equal(1); + partitions['status:-free'][0].name.should.equal('Paid Daniel'); + + should.not.exist(partitions.unsegmented); + }); + + it('throws if unsupported segment has been used', function () { + const members = []; + + const segments = ['not a valid segment']; + + should.throws(() => { + partitionMembersBySegment(members, segments) + }, errors.ValidationError); + }); + }); +});