Skip to content

Commit 6441d6a

Browse files
sagzytroyciesco
andauthored
Added paid welcome email for existing members redeeming a gift (#27659)
closes https://linear.app/ghost/issue/BER-3541 - Existing free members who redeem a gift subscription now receive the paid welcome email — previously only new gift signups got it. - Centralised the paid welcome email enqueue inside `gift-service.redeem()` so new and existing-member redemptions share one code path. - Refactored existing welcome email paths to use the new shared `enqueueWelcomeEmailRun()` method --------- Co-authored-by: Troy Ciesco <tmciesco@gmail.com>
1 parent dc2485e commit 6441d6a

5 files changed

Lines changed: 203 additions & 149 deletions

File tree

ghost/core/core/server/services/gifts/gift-service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {Gift} from './gift';
55
import type {GiftRepository} from './gift-repository';
66
import tpl from '@tryghost/tpl';
77
import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants';
8+
import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants';
89

910
const MS_PER_DAY = 24 * 60 * 60 * 1000;
1011
const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY;
@@ -37,6 +38,7 @@ interface MemberModel {
3738
interface MemberRepository {
3839
get(filter: Record<string, unknown>, options?: Record<string, unknown>): Promise<MemberModel | null>;
3940
update(data: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
41+
enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record<string, unknown>): Promise<unknown>;
4042
}
4143

4244
type Tier = {
@@ -325,6 +327,9 @@ export class GiftService {
325327

326328
await this.deps.giftRepository.update(redeemed, {transacting});
327329

330+
// Gift members receive the paid welcome email, as they receive access to paid content
331+
await this.deps.memberRepository.enqueueWelcomeEmailRun(memberId, MEMBER_WELCOME_EMAIL_SLUGS.paid, {transacting});
332+
328333
return {redeemed, member};
329334
};
330335

ghost/core/core/server/services/members/members-api/repositories/member-repository.js

Lines changed: 63 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,57 @@ module.exports = class MemberRepository {
173173
return nickname && nickname.toLowerCase() === 'complimentary';
174174
}
175175

176+
/**
177+
* Looks up the active welcome email automation for the given slug and enqueues a
178+
* `WelcomeEmailAutomationRun` for the member. Dispatches `StartAutomationsPollEvent`
179+
* so the poll picks it up. Returns the created run, or null if there is no active
180+
* automation/email for that slug.
181+
*
182+
* Callers are responsible for any eligibility gating (member status, source, etc.)
183+
* before calling this — this helper just looks up + inserts + dispatches. Pass
184+
* `options.transacting` to run the insert inside an existing transaction; the
185+
* dispatch is automatically deferred until that transaction commits.
186+
*
187+
* @param {string} memberId
188+
* @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS
189+
* @param {object} [options] bookshelf options (transacting, context, etc.)
190+
*/
191+
async enqueueWelcomeEmailRun(memberId, slug, options = {}) {
192+
if (!this._WelcomeEmailAutomation || !this._WelcomeEmailAutomationRun) {
193+
return null;
194+
}
195+
196+
const automation = await this._WelcomeEmailAutomation.findOne(
197+
{slug},
198+
{...options, withRelated: ['welcomeEmailAutomatedEmail']}
199+
);
200+
const email = automation?.related('welcomeEmailAutomatedEmail');
201+
const isActive = Boolean(
202+
automation &&
203+
email &&
204+
email.get('lexical') &&
205+
automation.get('status') === 'active'
206+
);
207+
208+
if (!isActive) {
209+
return null;
210+
}
211+
212+
const run = await this._WelcomeEmailAutomationRun.add({
213+
welcome_email_automation_id: automation.id,
214+
member_id: memberId,
215+
next_welcome_email_automated_email_id: email.id,
216+
ready_at: new Date(),
217+
step_started_at: null,
218+
step_attempts: 0,
219+
exit_reason: null
220+
}, options);
221+
222+
this.dispatchEvent(StartAutomationsPollEvent.create(), options);
223+
224+
return run;
225+
}
226+
176227
/**
177228
* Maps the framework context to members_*.source table record value
178229
* @param {Object} context instance of ghost framework context object
@@ -382,50 +433,15 @@ module.exports = class MemberRepository {
382433
let member;
383434

384435
const isFreeSignup = !stripeCustomer && memberData.status === 'free';
385-
const isGiftSignup = !stripeCustomer && memberData.status === 'gift';
386-
let welcomeEmailToEnqueue = null;
387-
388-
if (this._WelcomeEmailAutomation && WELCOME_EMAIL_SOURCES.includes(source)) {
389-
const getActiveWelcomeEmailToEnqueue = async (slug) => {
390-
const automation = await this._WelcomeEmailAutomation.findOne(
391-
{slug},
392-
{...options, withRelated: ['welcomeEmailAutomatedEmail']}
393-
);
394-
const email = automation?.related('welcomeEmailAutomatedEmail');
395-
const isActive = Boolean(
396-
automation &&
397-
email &&
398-
email.get('lexical') &&
399-
automation.get('status') === 'active'
400-
);
401-
402-
return isActive ? {automation, email} : null;
403-
};
404-
405-
if (isFreeSignup) {
406-
welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.free);
407-
} else if (isGiftSignup) {
408-
// As gift members get access to a paid tier, they receive the paid welcome email
409-
welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.paid);
410-
}
411-
}
412436

413-
if (welcomeEmailToEnqueue) {
437+
if (isFreeSignup && WELCOME_EMAIL_SOURCES.includes(source)) {
414438
const runMemberCreation = async (transacting) => {
415439
const newMember = await this._Member.add({
416440
...memberData,
417441
labels
418442
}, {...memberAddOptions, transacting});
419443

420-
await this._WelcomeEmailAutomationRun.add({
421-
welcome_email_automation_id: welcomeEmailToEnqueue.automation.id,
422-
member_id: newMember.id,
423-
next_welcome_email_automated_email_id: welcomeEmailToEnqueue.email.id,
424-
ready_at: new Date(),
425-
step_started_at: null,
426-
step_attempts: 0,
427-
exit_reason: null
428-
}, {transacting});
444+
await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting});
429445

430446
return newMember;
431447
};
@@ -435,8 +451,6 @@ module.exports = class MemberRepository {
435451
} else {
436452
member = await this._Member.transaction(runMemberCreation);
437453
}
438-
439-
this.dispatchEvent(StartAutomationsPollEvent.create(), memberAddOptions);
440454
} else {
441455
member = await this._Member.add({
442456
...memberData,
@@ -1516,38 +1530,17 @@ module.exports = class MemberRepository {
15161530

15171531
const context = options?.context || {};
15181532
const source = this._resolveContextSource(context);
1519-
const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source);
1520-
let isPaidWelcomeEmailActive = false;
1521-
let paidWelcomeAutomation = null;
1522-
let paidWelcomeEmail = null;
1523-
if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) {
1524-
paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne(
1525-
{slug: MEMBER_WELCOME_EMAIL_SLUGS.paid},
1526-
{...options, withRelated: ['welcomeEmailAutomatedEmail']}
1527-
);
1528-
paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail');
1529-
isPaidWelcomeEmailActive = Boolean(
1530-
paidWelcomeAutomation &&
1531-
paidWelcomeEmail &&
1532-
paidWelcomeEmail.get('lexical') &&
1533-
paidWelcomeAutomation.get('status') === 'active'
1534-
);
1535-
}
1536-
// Send paid welcome email if:
1537-
// 1. The paid welcome email is active
1533+
1534+
// Enqueue paid welcome email if:
1535+
// 1. The source is allowed to send welcome emails
15381536
// 2. The member status changed to 'paid'
1539-
// 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on signup
1540-
if (updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' && isPaidWelcomeEmailActive) {
1541-
await this._WelcomeEmailAutomationRun.add({
1542-
welcome_email_automation_id: paidWelcomeAutomation.id,
1543-
member_id: memberModel.id,
1544-
next_welcome_email_automated_email_id: paidWelcomeEmail.id,
1545-
ready_at: new Date(),
1546-
step_started_at: null,
1547-
step_attempts: 0,
1548-
exit_reason: null
1549-
}, options);
1550-
this.dispatchEvent(StartAutomationsPollEvent.create(), options);
1537+
// 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption
1538+
if (
1539+
WELCOME_EMAIL_SOURCES.includes(source) &&
1540+
updatedMember.get('status') === 'paid' &&
1541+
updatedMember._previousAttributes.status !== 'gift'
1542+
) {
1543+
await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options);
15511544
}
15521545
}
15531546
}

ghost/core/test/e2e-api/members/gift-subscriptions.test.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,21 +873,62 @@ describe('Gift Subscriptions', function () {
873873
const email = 'gift-existing-member@test.com';
874874

875875
// Create member first
876-
await models.Member.add({email, name: 'Existing Member', email_disabled: false});
876+
const existingMember = await models.Member.add({email, name: 'Existing Member', email_disabled: false});
877877
await DomainEvents.allSettled();
878878

879879
const gift = await createGift();
880880
const originalWelcomePageUrl = paidProduct.get('welcome_page_url');
881881
const redirectUrl = new URL(urlUtils.getSiteUrl());
882882
redirectUrl.hash = '#/portal/account?giftRedemption=true';
883883

884+
let freeWelcomeAutomation;
885+
let paidWelcomeAutomation;
886+
884887
try {
888+
// Set up both free and paid welcome email automations to verify gift
889+
// redemption picks the paid welcome email (not the free one) — same as
890+
// the new-member case above.
891+
const emailDesignSetting = await models.EmailDesignSetting.findOne(
892+
{slug: 'default-automated-email'},
893+
{require: true}
894+
);
895+
freeWelcomeAutomation = await models.WelcomeEmailAutomation.add({
896+
name: 'Free welcome email',
897+
slug: 'member-welcome-email-free',
898+
status: 'active'
899+
});
900+
await models.WelcomeEmailAutomatedEmail.add({
901+
welcome_email_automation_id: freeWelcomeAutomation.id,
902+
delay_days: 0,
903+
subject: 'Welcome to the site!',
904+
lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome'}]}]}}),
905+
email_design_setting_id: emailDesignSetting.id
906+
});
907+
paidWelcomeAutomation = await models.WelcomeEmailAutomation.add({
908+
name: 'Paid welcome email',
909+
slug: 'member-welcome-email-paid',
910+
status: 'active'
911+
});
912+
await models.WelcomeEmailAutomatedEmail.add({
913+
welcome_email_automation_id: paidWelcomeAutomation.id,
914+
delay_days: 0,
915+
subject: 'Welcome to the paid tier!',
916+
lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome paid'}]}]}}),
917+
email_design_setting_id: emailDesignSetting.id
918+
});
919+
885920
await models.Product.edit({
886921
welcome_page_url: ''
887922
}, {
888923
id: paidProduct.id
889924
});
890925

926+
// The existing free member shouldn't have any welcome runs yet
927+
const runsBefore = await models.WelcomeEmailAutomationRun.findAll({
928+
filter: `member_id:'${existingMember.id}'`
929+
});
930+
assert.equal(runsBefore.length, 0, 'Existing free member should have no welcome email runs before redemption');
931+
891932
const magicLink = await membersService.api.getMagicLink(email, 'subscribe', {
892933
giftToken: gift.get('token')
893934
});
@@ -916,6 +957,19 @@ describe('Gift Subscriptions', function () {
916957
assert.ok(gift.get('redeemed_at'));
917958
assert.ok(gift.get('consumes_at'));
918959

960+
// Verify the paid welcome automation enqueued a run for this member,
961+
// and that the free welcome automation did NOT (gift redemption
962+
// delivers the paid welcome email regardless of pre-redemption status).
963+
const welcomeRuns = await models.WelcomeEmailAutomationRun.findAll({
964+
filter: `member_id:'${member.id}'`
965+
});
966+
assert.equal(welcomeRuns.length, 1, 'Should enqueue exactly one welcome email automation run for an existing free member redeeming a gift');
967+
assert.equal(
968+
welcomeRuns.models[0].get('welcome_email_automation_id'),
969+
paidWelcomeAutomation.id,
970+
'Should enqueue the paid welcome email automation, not the free one'
971+
);
972+
919973
// Verify gift subscription started staff notification was sent
920974
mockManager.assert.sentEmail({
921975
subject: /paid subscription started/i,
@@ -933,6 +987,19 @@ describe('Gift Subscriptions', function () {
933987
}, {
934988
id: paidProduct.id
935989
});
990+
991+
for (const automation of [freeWelcomeAutomation, paidWelcomeAutomation]) {
992+
if (!automation) {
993+
continue;
994+
}
995+
const runs = await models.WelcomeEmailAutomationRun.findAll({
996+
filter: `welcome_email_automation_id:'${automation.id}'`
997+
});
998+
for (const run of runs.models) {
999+
await models.WelcomeEmailAutomationRun.destroy({id: run.id});
1000+
}
1001+
await models.WelcomeEmailAutomation.destroy({id: automation.id});
1002+
}
9361003
}
9371004
});
9381005
});

ghost/core/test/unit/server/services/gifts/gift-service.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe('GiftService', function () {
4747
let memberRepository: {
4848
get: sinon.SinonStub;
4949
update: sinon.SinonStub;
50+
enqueueWelcomeEmailRun: sinon.SinonStub;
5051
};
5152
let staffServiceEmails: {
5253
notifyGiftReceived: sinon.SinonStub;
@@ -97,7 +98,8 @@ describe('GiftService', function () {
9798
memberGet.withArgs('status').returns('free');
9899
return Promise.resolve({id: 'member_1', get: memberGet});
99100
}),
100-
update: sinon.stub().resolves(undefined)
101+
update: sinon.stub().resolves(undefined),
102+
enqueueWelcomeEmailRun: sinon.stub().resolves(undefined)
101103
};
102104
staffServiceEmails = {
103105
notifyGiftReceived: sinon.stub(),
@@ -1289,6 +1291,70 @@ describe('GiftService', function () {
12891291
sinon.assert.notCalled(giftRepository.update);
12901292
sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted);
12911293
});
1294+
1295+
it('enqueues the paid welcome email run for a new gift signup', async function () {
1296+
const gift = buildGift();
1297+
const memberGet = sinon.stub();
1298+
memberGet.withArgs('status').returns('gift');
1299+
memberGet.withArgs('name').returns('Member Name');
1300+
memberGet.withArgs('email').returns('member@example.com');
1301+
1302+
giftRepository.getByToken.resolves(gift);
1303+
memberRepository.get.resolves({id: 'member_1', get: memberGet});
1304+
1305+
const service = createService();
1306+
await service.redeem('gift-token', 'member_1', {newMember: true});
1307+
1308+
sinon.assert.calledOnceWithExactly(
1309+
memberRepository.enqueueWelcomeEmailRun,
1310+
'member_1',
1311+
'member-welcome-email-paid',
1312+
{transacting: 'trx'}
1313+
);
1314+
});
1315+
1316+
it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () {
1317+
const gift = buildGift();
1318+
const memberGet = sinon.stub();
1319+
memberGet.withArgs('status').returns('free');
1320+
memberGet.withArgs('name').returns('Member Name');
1321+
memberGet.withArgs('email').returns('member@example.com');
1322+
1323+
giftRepository.getByToken.resolves(gift);
1324+
memberRepository.get.resolves({id: 'member_1', get: memberGet});
1325+
1326+
const service = createService();
1327+
await service.redeem('gift-token', 'member_1');
1328+
1329+
sinon.assert.calledOnceWithExactly(
1330+
memberRepository.enqueueWelcomeEmailRun,
1331+
'member_1',
1332+
'member-welcome-email-paid',
1333+
{transacting: 'trx'}
1334+
);
1335+
});
1336+
1337+
it('passes the external transaction through to the welcome email enqueue', async function () {
1338+
const gift = buildGift();
1339+
const memberGet = sinon.stub();
1340+
memberGet.withArgs('status').returns('free');
1341+
memberGet.withArgs('name').returns('Member Name');
1342+
memberGet.withArgs('email').returns('member@example.com');
1343+
1344+
giftRepository.getByToken.resolves(gift);
1345+
memberRepository.get.resolves({id: 'member_1', get: memberGet});
1346+
1347+
const service = createService();
1348+
const externalTrx = {executionPromise: Promise.resolve()};
1349+
await service.redeem('gift-token', 'member_1', {transacting: externalTrx});
1350+
1351+
sinon.assert.calledOnceWithExactly(
1352+
memberRepository.enqueueWelcomeEmailRun,
1353+
'member_1',
1354+
'member-welcome-email-paid',
1355+
{transacting: externalTrx}
1356+
);
1357+
});
12921358
});
12931359

12941360
describe('scheduleReminder (via redeem)', function () {

0 commit comments

Comments
 (0)