From f9936d1df347e2f4de0680c10c51a09c0d518e97 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 7 Apr 2026 11:13:25 +0100 Subject: [PATCH] Added staff email notification for gift subscription purchases ref https://linear.app/ghost/issue/BER-3484 After a gift is purchased, the `GiftService` sends a staff notification email to all admin / owner users with the purchaser info and amount --- .../services/gifts/gift-service-wrapper.js | 4 + .../server/services/gifts/gift-service.ts | 23 +++++- .../services/staff/email-templates/gift.hbs | 81 +++++++++++++++++++ .../staff/email-templates/gift.txt.js | 13 +++ .../services/staff/staff-service-emails.js | 74 ++++++++++++++--- .../services/gifts/gift-service.test.ts | 54 ++++++++++++- .../services/staff/staff-service.test.js | 68 ++++++++++++++++ 7 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 ghost/core/core/server/services/staff/email-templates/gift.hbs create mode 100644 ghost/core/core/server/services/staff/email-templates/gift.txt.js diff --git a/ghost/core/core/server/services/gifts/gift-service-wrapper.js b/ghost/core/core/server/services/gifts/gift-service-wrapper.js index ef79022374e..77d350e95dd 100644 --- a/ghost/core/core/server/services/gifts/gift-service-wrapper.js +++ b/ghost/core/core/server/services/gifts/gift-service-wrapper.js @@ -10,6 +10,7 @@ class GiftServiceWrapper { const {GiftBookshelfRepository} = require('./gift-bookshelf-repository'); const {GiftService} = require('./gift-service'); const membersService = require('../members'); + const staffService = require('../staff'); const repository = new GiftBookshelfRepository({ GiftModel @@ -19,6 +20,9 @@ class GiftServiceWrapper { giftRepository: repository, get memberRepository() { return membersService.api.members; + }, + get staffServiceEmails() { + return staffService.api.emails; } }); } diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 25b387f5a44..0d7fa73c93c 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -1,9 +1,14 @@ import errors from '@tryghost/errors'; +import logging from '@tryghost/logging'; import {Gift} from './gift'; import type {GiftBookshelfRepository} from './gift-bookshelf-repository'; interface MemberRepository { - get(filter: Record): Promise<{id: string} | null>; + get(filter: Record): Promise<{id: string; get(key: string): string | null} | null>; +} + +interface StaffServiceEmails { + notifyGiftReceived(data: {name: string | null; email: string; memberId: string | null; amount: number; currency: string}): Promise; } interface GiftPurchaseData { @@ -22,10 +27,12 @@ interface GiftPurchaseData { export class GiftService { readonly #giftRepository: GiftBookshelfRepository; readonly #memberRepository: MemberRepository; + readonly #staffServiceEmails: StaffServiceEmails; - constructor({giftRepository, memberRepository}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository}) { + constructor({giftRepository, memberRepository, staffServiceEmails}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository; staffServiceEmails: StaffServiceEmails}) { this.#giftRepository = giftRepository; this.#memberRepository = memberRepository; + this.#staffServiceEmails = staffServiceEmails; } async recordPurchase(data: GiftPurchaseData): Promise { @@ -58,6 +65,18 @@ export class GiftService { await this.#giftRepository.create(gift); + try { + await this.#staffServiceEmails.notifyGiftReceived({ + name: member?.get('name') ?? null, + email: member?.get('email') ?? data.buyerEmail, + memberId: member?.id ?? null, + amount: data.amount, + currency: data.currency + }); + } catch (err) { + logging.error(err); + } + return true; } } diff --git a/ghost/core/core/server/services/staff/email-templates/gift.hbs b/ghost/core/core/server/services/staff/email-templates/gift.hbs new file mode 100644 index 00000000000..007a3707882 --- /dev/null +++ b/ghost/core/core/server/services/staff/email-templates/gift.hbs @@ -0,0 +1,81 @@ + + + + + + 🎁 Gift subscription purchased: {{gift.amount}} from {{gift.name}} + {{> styles}} + + + + + + + + +
  +
+ + + + + + + + + + + +
+ + {{#if siteIconUrl}} + + + + {{/if}} + + + + + + + + + + + + + +
{{siteTitle}}
+

Someone purchased a gift subscription!

+ + + + + + +
+ + + + +
+

From

+ +

Amount received

+

{{gift.amount}}

+
+
+
+

This message was sent from {{siteDomain}} to {{toEmail}}

+
+

Don't want to receive these emails? Manage your preferences here.

+
+
+ + + +
+
 
+ + diff --git a/ghost/core/core/server/services/staff/email-templates/gift.txt.js b/ghost/core/core/server/services/staff/email-templates/gift.txt.js new file mode 100644 index 00000000000..72de03f171b --- /dev/null +++ b/ghost/core/core/server/services/staff/email-templates/gift.txt.js @@ -0,0 +1,13 @@ +module.exports = function giftText(data) { + // Be careful when you indent the email, because whitespaces are visible in emails! + return ` +Someone purchased a gift subscription! + +You received a gift subscription purchase of ${data.gift.amount} from "${data.gift.name}". + +--- + +Sent to ${data.toEmail} from ${data.siteDomain}. +If you would no longer like to receive these notifications you can adjust your settings at ${data.staffUrl}. + `; +}; diff --git a/ghost/core/core/server/services/staff/staff-service-emails.js b/ghost/core/core/server/services/staff/staff-service-emails.js index d3dbf913cb1..aa56d8a8314 100644 --- a/ghost/core/core/server/services/staff/staff-service-emails.js +++ b/ghost/core/core/server/services/staff/staff-service-emails.js @@ -268,7 +268,6 @@ class StaffServiceEmails { * @returns {Promise} */ async notifyDonationReceived({donationPaymentEvent}) { - const emailPromises = []; const users = await this.models.User.getEmailAlertUsers('donation'); const formattedAmount = this.getFormattedAmount({currency: donationPaymentEvent.currency, amount: donationPaymentEvent.amount / 100}); @@ -279,12 +278,70 @@ class StaffServiceEmails { email: donationPaymentEvent.email }) : null; + await this.sendToStaff({ + users, + subject, + template: 'donation', + memberData, + templateData: { + donation: { + name: donationPaymentEvent.name ?? donationPaymentEvent.email, + email: donationPaymentEvent.email, + amount: formattedAmount, + donationMessage: donationPaymentEvent.donationMessage + } + } + }); + } + + /** + * @param {object} eventData + * @param {string|null} eventData.name + * @param {string} eventData.email + * @param {string|null} eventData.memberId + * @param {number} eventData.amount - amount in cents + * @param {string} eventData.currency + * + * @returns {Promise} + */ + async notifyGiftReceived({name, email, memberId, amount, currency}) { + const users = await this.models.User.getEmailAlertUsers('gift-purchased'); + const formattedAmount = this.getFormattedAmount({currency, amount: amount / 100}); + + const displayName = name ?? email; + const subject = `🎁 Gift subscription purchased: ${formattedAmount} from ${displayName}`; + const memberData = memberId ? this.getMemberData({ + id: memberId, + name: name ?? null, + email + }) : null; + + await this.sendToStaff({ + users, + subject, + template: 'gift', + memberData, + templateData: { + gift: { + name: displayName, + amount: formattedAmount + } + } + }); + } + + // Utils + + /** @private */ + async sendToStaff({users, subject, template, memberData, templateData}) { + const emailPromises = []; + for (const user of users) { const to = user.email; let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}/email-notifications`); - const templateData = { + const data = { siteTitle: this.settingsCache.get('title'), siteUrl: this.urlUtils.getSiteUrl(), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), @@ -293,17 +350,12 @@ class StaffServiceEmails { toEmail: to, adminUrl: this.urlUtils.urlFor('admin', true), staffUrl: staffUrl, - donation: { - name: donationPaymentEvent.name ?? donationPaymentEvent.email, - email: donationPaymentEvent.email, - amount: formattedAmount, - donationMessage: donationPaymentEvent.donationMessage - }, memberData, - accentColor: this.settingsCache.get('accent_color') + accentColor: this.settingsCache.get('accent_color'), + ...templateData }; - const {html, text} = await this.renderEmailTemplate('donation', templateData); + const {html, text} = await this.renderEmailTemplate(template, data); emailPromises.push(await this.sendMail({ to, @@ -322,8 +374,6 @@ class StaffServiceEmails { } } - // Utils - /** @private */ getMemberData(member) { let name = member?.name || member?.email; diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index eab9d3cb4ac..00373cf57a6 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -11,6 +11,9 @@ describe('GiftService', function () { let memberRepository: { get: sinon.SinonStub; }; + let staffServiceEmails: { + notifyGiftReceived: sinon.SinonStub; + }; const purchaseData = { token: 'abc-123', buyerEmail: 'buyer@example.com', @@ -30,7 +33,10 @@ describe('GiftService', function () { existsByCheckoutSessionId: sinon.stub().resolves(false) }; memberRepository = { - get: sinon.stub().resolves({id: 'member_1'}) + get: sinon.stub().resolves({id: 'member_1', get: sinon.stub().returns(null)}) + }; + staffServiceEmails = { + notifyGiftReceived: sinon.stub() }; }); @@ -39,7 +45,7 @@ describe('GiftService', function () { }); function createService() { - return new GiftService({giftRepository: giftRepository as any, memberRepository}); + return new GiftService({giftRepository: giftRepository as any, memberRepository, staffServiceEmails}); } describe('recordPurchase', function () { @@ -70,6 +76,13 @@ describe('GiftService', function () { }); it('resolves member by stripeCustomerId', async function () { + const memberGet = sinon.stub(); + + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + const service = createService(); await service.recordPurchase(purchaseData); @@ -131,5 +144,42 @@ describe('GiftService', function () { sinon.assert.notCalled(giftRepository.create); }); + + it('sends staff notification email after recording purchase', async function () { + const memberGet = sinon.stub(); + + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + + await service.recordPurchase(purchaseData); + + sinon.assert.calledOnce(staffServiceEmails.notifyGiftReceived); + + const emailData = staffServiceEmails.notifyGiftReceived.getCall(0).args[0]; + + assert.equal(emailData.name, 'Member Name'); + assert.equal(emailData.email, 'member@example.com'); + assert.equal(emailData.memberId, 'member_1'); + assert.equal(emailData.amount, 5000); + assert.equal(emailData.currency, 'usd'); + }); + + it('uses buyerEmail and null name when purchaser is not a member', async function () { + const service = createService(); + + await service.recordPurchase({...purchaseData, stripeCustomerId: null}); + + sinon.assert.calledOnce(staffServiceEmails.notifyGiftReceived); + + const emailData = staffServiceEmails.notifyGiftReceived.getCall(0).args[0]; + + assert.equal(emailData.name, null); + assert.equal(emailData.email, 'buyer@example.com'); + assert.equal(emailData.memberId, null); + }); }); }); diff --git a/ghost/core/test/unit/server/services/staff/staff-service.test.js b/ghost/core/test/unit/server/services/staff/staff-service.test.js index 9d6fc71775a..afcd541dfd4 100644 --- a/ghost/core/test/unit/server/services/staff/staff-service.test.js +++ b/ghost/core/test/unit/server/services/staff/staff-service.test.js @@ -973,5 +973,73 @@ describe('StaffService', function () { sinon.assert.calledWith(mailStub, sinon.match.has('text', sinon.match('No message provided'))); }); }); + + describe('notifyGiftReceived', function () { + it('sends gift email with correct subject', async function () { + await service.emails.notifyGiftReceived({ + name: 'Alice', + email: 'alice@example.com', + memberId: null, + amount: 6000, + currency: 'usd' + }); + + sinon.assert.calledWith(getEmailAlertUsersStub, 'gift-purchased'); + sinon.assert.calledOnce(mailStub); + sinon.assert.calledWith(mailStub, sinon.match.has('subject', sinon.match('Gift subscription purchased: $60.00 from Alice'))); + }); + + it('includes amount in HTML', async function () { + await service.emails.notifyGiftReceived({ + name: 'Bob', + email: 'bob@example.com', + memberId: null, + amount: 1500, + currency: 'eur' + }); + + sinon.assert.calledOnce(mailStub); + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('€15.00'))); + }); + + it('includes purchaser name in HTML', async function () { + await service.emails.notifyGiftReceived({ + name: 'Charlie', + email: 'charlie@example.com', + memberId: null, + amount: 5000, + currency: 'usd' + }); + + sinon.assert.calledOnce(mailStub); + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Charlie'))); + }); + + it('includes amount in plain text', async function () { + await service.emails.notifyGiftReceived({ + name: 'Diana', + email: 'diana@example.com', + memberId: null, + amount: 2000, + currency: 'gbp' + }); + + sinon.assert.calledOnce(mailStub); + sinon.assert.calledWith(mailStub, sinon.match.has('text', sinon.match('£20.00'))); + }); + + it('falls back to email when name is null', async function () { + await service.emails.notifyGiftReceived({ + name: null, + email: 'anon@example.com', + memberId: null, + amount: 3000, + currency: 'usd' + }); + + sinon.assert.calledOnce(mailStub); + sinon.assert.calledWith(mailStub, sinon.match.has('subject', sinon.match('from anon@example.com'))); + }); + }); }); });