Email verification for imports based on 30 days of import

refs: https://github.com/TryGhost/Toolbox/issues/293

Things needed to create this:
* MemberSubscriptionEvent now has an import source
* Importer now creates events with this type
* Verification trigger logic changed to use 30 day window of imports
This commit is contained in:
Sam Lord 2022-04-13 17:35:24 +01:00
parent b76d850620
commit 455778662c
5 changed files with 87 additions and 8 deletions

View File

@ -220,7 +220,9 @@ module.exports = class MemberRepository {
const context = options && options.context || {}; const context = options && options.context || {};
let source; let source;
if (context.internal) { if (context.import) {
source = 'import';
} else if (context.internal) {
source = 'system'; source = 'system';
} else if (context.user) { } else if (context.user) {
source = 'admin'; source = 'admin';

View File

@ -153,7 +153,11 @@ module.exports = class MembersCSVImporter {
id: existingMember.id id: existingMember.id
}); });
} else { } else {
member = await membersApi.members.create(row, options); member = await membersApi.members.create(row, Object.assign({}, options, {
context: {
import: true
}
}));
} }
if (row.stripe_customer_id) { if (row.stripe_customer_id) {

View File

@ -32,6 +32,7 @@ describe('Importer', function () {
id: 'default_product_id' id: 'default_product_id'
}; };
const memberCreateStub = sinon.stub().resolves(null);
const membersApi = { const membersApi = {
productRepository: { productRepository: {
list: async () => { list: async () => {
@ -44,9 +45,7 @@ describe('Importer', function () {
get: async () => { get: async () => {
return null; return null;
}, },
create: async (row) => { create: memberCreateStub
return row;
}
} }
}; };
@ -96,6 +95,10 @@ describe('Importer', function () {
result.meta.originalImportSize.should.equal(2); result.meta.originalImportSize.should.equal(2);
fsWriteSpy.calledOnce.should.be.true(); fsWriteSpy.calledOnce.should.be.true();
// Called at least once
memberCreateStub.notCalled.should.be.false();
memberCreateStub.firstCall.lastArg.context.import.should.be.true();
}); });
}); });

View File

@ -5,7 +5,7 @@ const {MemberSubscribeEvent} = require('@tryghost/member-events');
const messages = { const messages = {
emailVerificationNeeded: `We're hard at work processing your import. To make sure you get great deliverability on a list of that size, we'll need to enable some extra features for your account. A member of our team will be in touch with you by email to review your account make sure everything is configured correctly so you're ready to go.`, emailVerificationNeeded: `We're hard at work processing your import. To make sure you get great deliverability on a list of that size, we'll need to enable some extra features for your account. A member of our team will be in touch with you by email to review your account make sure everything is configured correctly so you're ready to go.`,
emailVerificationEmailSubject: `Email needs verification`, emailVerificationEmailSubject: `Email needs verification`,
emailVerificationEmailMessageImport: `Email verification needed for site: {siteUrl}, just imported: {importedNumber} members.`, emailVerificationEmailMessageImport: `Email verification needed for site: {siteUrl}, has imported: {importedNumber} members in the last 30 days.`,
emailVerificationEmailMessageAPI: `Email verification needed for site: {siteUrl} has added: {importedNumber} members through the API in the last 30 days.` emailVerificationEmailMessageAPI: `Email verification needed for site: {siteUrl} has added: {importedNumber} members through the API in the last 30 days.`
}; };
@ -68,6 +68,33 @@ class VerificationTrigger {
} }
} }
async testImportThreshold() {
const createdAt = new Date();
createdAt.setDate(createdAt.getDate() - 30);
const events = await this._eventRepository.getNewsletterSubscriptionEvents({}, {
'data.source': `data.source:'import'`,
'data.created_at': `data.created_at:>'${createdAt.toISOString().replace('T', ' ').substring(0, 19)}'`
});
if (!isFinite(this._configThreshold)) {
// Inifinte threshold, quick path
return;
}
const membersTotal = await this._membersStats.getTotalMembers();
// Import threshold is either the total number of members (discounting any created by imports in
// the last 30 days) or the threshold defined in config, whichever is greater.
const importThreshold = Math.max(membersTotal - events.meta.pagination.total, this._configThreshold);
if (isFinite(importThreshold) && events.meta.pagination.total > importThreshold) {
await this.startVerificationProcess({
amountImported: events.meta.pagination.total,
throwOnTrigger: false,
source: 'import'
});
}
}
/** /**
* @typedef IVerificationResult * @typedef IVerificationResult
* @property {boolean} needsVerification Whether the verification workflow was triggered * @property {boolean} needsVerification Whether the verification workflow was triggered

View File

@ -2,7 +2,7 @@
// const testUtils = require('./utils'); // const testUtils = require('./utils');
const sinon = require('sinon'); const sinon = require('sinon');
require('./utils'); require('./utils');
const VerificationTrigger = require('../lib/verification-trigger'); const VerificationTrigger = require('../index');
const DomainEvents = require('@tryghost/domain-events'); const DomainEvents = require('@tryghost/domain-events');
const {MemberSubscribeEvent} = require('@tryghost/member-events'); const {MemberSubscribeEvent} = require('@tryghost/member-events');
@ -150,7 +150,7 @@ describe('Email verification flow', function () {
emailStub.lastCall.firstArg.should.eql({ emailStub.lastCall.firstArg.should.eql({
subject: 'Email needs verification', subject: 'Email needs verification',
message: 'Email verification needed for site: {siteUrl}, just imported: {importedNumber} members.', message: 'Email verification needed for site: {siteUrl}, has imported: {importedNumber} members in the last 30 days.',
amountImported: 10 amountImported: 10
}); });
}); });
@ -190,4 +190,47 @@ describe('Email verification flow', function () {
eventStub.lastCall.lastArg['data.source'].should.eql(`data.source:'api'`); eventStub.lastCall.lastArg['data.source'].should.eql(`data.source:'api'`);
eventStub.lastCall.lastArg['data.created_at'].should.startWith(`data.created_at:>'`); eventStub.lastCall.lastArg['data.created_at'].should.startWith(`data.created_at:>'`);
}); });
it('Triggers when a number of members are imported', async function () {
const emailStub = sinon.stub().resolves(null);
const settingsStub = sinon.stub().resolves(null);
const eventStub = sinon.stub().resolves({
meta: {
pagination: {
total: 10
}
}
});
const trigger = new VerificationTrigger({
configThreshold: 2,
Settings: {
edit: settingsStub
},
membersStats: {
getTotalMembers: () => 15
},
isVerified: () => false,
isVerificationRequired: () => false,
sendVerificationEmail: emailStub,
eventRepository: {
getNewsletterSubscriptionEvents: eventStub
}
});
await trigger.testImportThreshold();
eventStub.callCount.should.eql(1);
eventStub.lastCall.lastArg.should.have.property('data.source');
eventStub.lastCall.lastArg.should.have.property('data.created_at');
eventStub.lastCall.lastArg['data.source'].should.eql(`data.source:'admin'`);
eventStub.lastCall.lastArg['data.created_at'].should.startWith(`data.created_at:>'`);
emailStub.callCount.should.eql(1);
emailStub.lastCall.firstArg.should.eql({
subject: 'Email needs verification',
message: 'Email verification needed for site: {siteUrl}, has imported: {importedNumber} members in the last 30 days.',
amountImported: 10
});
});
}); });