Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ghost/core/core/server/services/gifts/gift-service-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +20,9 @@ class GiftServiceWrapper {
giftRepository: repository,
get memberRepository() {
return membersService.api.members;
},
get staffServiceEmails() {
return staffService.api.emails;
}
});
}
Expand Down
23 changes: 21 additions & 2 deletions ghost/core/core/server/services/gifts/gift-service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<{id: string} | null>;
get(filter: Record<string, unknown>): 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<void>;
}

interface GiftPurchaseData {
Expand All @@ -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<boolean> {
Expand Down Expand Up @@ -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;
}
}
81 changes: 81 additions & 0 deletions ghost/core/core/server/services/staff/email-templates/gift.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>🎁 Gift subscription purchased: {{gift.amount}} from {{gift.name}}</title>
{{> styles}}
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">

<!-- START CENTERED CONTAINER -->

<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">

<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
{{#if siteIconUrl}}
<tr>
<td align="center" style="padding-bottom: 56px; text-align: center;"><a href="{{siteUrl}}"><img src="{{siteIconUrl}}" alt="{{siteTitle}}" border="0" width="48" height="48"></a></td>
</tr>
{{/if}}
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<h1 style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 26px; color: #15212A; font-weight: bold; line-height: 28px; margin: 0; padding-bottom: 24px;">Someone purchased a gift&nbsp;subscription!</h1>
<table width="100" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F4F5F6; border-radius: 8px;">
<tbody>
<tr>
<td align="left" style="padding: 24px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px; background-color: #F4F5F6; text-align: left; vertical-align: middle;" valign="middle">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">From</p>
<p class="text-link-accent large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{gift.name}} {{#if memberData}}&bull; <a href="{{memberData.adminUrl}}" target="_blank" style="display: inline; color: {{accentColor}} !important; text-decoration: none !important;">View</a>{{/if}}</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Amount received</p>
<p class="large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 0; color: #15171A; font-weight: 400;">{{gift.amount}}</p>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>

<!-- START FOOTER -->
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top; padding-top: 56px;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">This message was sent from <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{siteDomain}}</a> to <a class="small" href="mailto:{{toEmail}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{toEmail}}</a></p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">Don't want to receive these emails? Manage your preferences <a class="small" href="{{staffUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">here</a>.</p>
</td>
</tr>

<!-- END FOOTER -->
</table>
</td>
</tr>

<!-- END MAIN CONTENT AREA -->
</table>


<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
13 changes: 13 additions & 0 deletions ghost/core/core/server/services/staff/email-templates/gift.txt.js
Original file line number Diff line number Diff line change
@@ -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}.
`;
};
74 changes: 62 additions & 12 deletions ghost/core/core/server/services/staff/staff-service-emails.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,6 @@ class StaffServiceEmails {
* @returns {Promise<void>}
*/
async notifyDonationReceived({donationPaymentEvent}) {
const emailPromises = [];
const users = await this.models.User.getEmailAlertUsers('donation');
const formattedAmount = this.getFormattedAmount({currency: donationPaymentEvent.currency, amount: donationPaymentEvent.amount / 100});

Expand All @@ -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<void>}
*/
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}),
Expand All @@ -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,
Expand All @@ -322,8 +374,6 @@ class StaffServiceEmails {
}
}

// Utils

/** @private */
getMemberData(member) {
let name = member?.name || member?.email;
Expand Down
54 changes: 52 additions & 2 deletions ghost/core/test/unit/server/services/gifts/gift-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()
};
});

Expand All @@ -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 () {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading