diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 6852ff1a197..eab5b5fd940 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -141,7 +141,11 @@ export default class ParseMemberEventHelper extends Helper { } if (event.type === 'gift_purchase_event') { - icon = 'subscriptions'; + icon = 'gift'; + } + + if (event.type === 'gift_redemption_event') { + icon = 'gift'; } if (event.type === 'email_change_event') { @@ -268,6 +272,11 @@ export default class ParseMemberEventHelper extends Helper { return `Purchased a gift subscription for ${formattedAmount} (${tierName}, ${duration} ${cadenceLabel})`; } + + if (event.type === 'gift_redemption_event') { + const tierName = event.data.tier_name; + return `Started paid subscription (${tierName}) via gift`; + } } /** diff --git a/ghost/admin/app/utils/member-event-types.js b/ghost/admin/app/utils/member-event-types.js index 0a8512bf3ea..9471d257f1f 100644 --- a/ghost/admin/app/utils/member-event-types.js +++ b/ghost/admin/app/utils/member-event-types.js @@ -34,10 +34,12 @@ export function toggleEventType(eventType, eventTypes) { excludedEvents.delete('payment_event'); excludedEvents.delete('donation_event'); excludedEvents.delete('gift_purchase_event'); + excludedEvents.delete('gift_redemption_event'); } else { excludedEvents.add('payment_event'); excludedEvents.add('donation_event'); excludedEvents.add('gift_purchase_event'); + excludedEvents.add('gift_redemption_event'); } } else { if (excludedEvents.has(eventType)) { diff --git a/ghost/admin/public/assets/icons/event-gift.svg b/ghost/admin/public/assets/icons/event-gift.svg new file mode 100644 index 00000000000..bcbaae2d703 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-gift.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/tests/unit/utils/member-event-types-test.js b/ghost/admin/tests/unit/utils/member-event-types-test.js index 162926a37f4..afd74adcf41 100644 --- a/ghost/admin/tests/unit/utils/member-event-types-test.js +++ b/ghost/admin/tests/unit/utils/member-event-types-test.js @@ -24,7 +24,7 @@ describe('Unit | Utility | event-type-utils', function () { const newExcludedEvents = toggleEventType('payment_event', eventTypes); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,gift_redemption_event'); }); it('should toggle both payment_event and donation_event off when toggling payment_event off', function () { diff --git a/ghost/core/core/server/services/gifts/constants.ts b/ghost/core/core/server/services/gifts/constants.ts index 8738d2a97ff..1edb50db532 100644 --- a/ghost/core/core/server/services/gifts/constants.ts +++ b/ghost/core/core/server/services/gifts/constants.ts @@ -1 +1,3 @@ export const GIFT_EXPIRY_DAYS = 365; +export const GIFT_REMINDER_LEAD_DAYS = 7; +export const GIFT_REMINDER_FLOOR_DAYS = 3; diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs new file mode 100644 index 00000000000..2f8b842e397 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs @@ -0,0 +1,110 @@ + + + + + + Your gift subscription is ending soon + + + + + + + + + +
  +
+ + + + + + + + + + + +
+ + {{#if siteIconUrl}} + + + + {{/if}} + + + + + + + + + + + + + +
{{siteTitle}}
+

Your gift subscription is ending soon

+

{{#if memberName}}Hi {{memberName}}, your{{else}}Your{{/if}} gift subscription to {{siteTitle}} ends on {{gift.consumesAt}}. Continue with a paid subscription to keep your access.

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

Gift subscription

+

{{gift.tierName}} • {{gift.cadenceLabel}}

+

Ends on

+

{{gift.consumesAt}}

+
+ + + + + + +
+ + + + + + +
+ Manage subscription +
+
+
+
+

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

+
+

You received this email because your gift subscription to {{siteTitle}} is ending soon.

+
+
+ + + +
+
 
+ + diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts new file mode 100644 index 00000000000..d0c990e3f47 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts @@ -0,0 +1,33 @@ +export interface GiftReminderData { + siteTitle: string; + siteUrl: string; + siteIconUrl: string | null; + siteDomain: string; + accentColor: string | undefined; + memberEmail: string; + memberName: string | null; + gift: { + tierName: string; + cadenceLabel: string; + consumesAt: string; + manageSubscriptionUrl: string; + }; +} + +export function renderText(data: GiftReminderData): string { + const greeting = data.memberName ? `Hi ${data.memberName},` : 'Hi,'; + + return `${greeting} + +Your gift subscription to ${data.siteTitle} ends on ${data.gift.consumesAt}. + +Gift subscription: ${data.gift.tierName} • ${data.gift.cadenceLabel} + +To keep your access, continue with a paid subscription before your gift ends: +${data.gift.manageSubscriptionUrl} + +--- + +Sent to ${data.memberEmail} from ${data.siteDomain}. +You received this email because your gift subscription to ${data.siteTitle} is ending soon.`; +} diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts index 3b1fa3688ca..64fabec2bfb 100644 --- a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts @@ -1,6 +1,6 @@ import errors from '@tryghost/errors'; import {Gift, type GiftCadence, type GiftStatus} from './gift'; -import type {GiftRepository, RepositoryTransactionOptions} from './gift-repository'; +import type {FindPendingReminderOptions, GiftRepository, RepositoryTransactionOptions} from './gift-repository'; type BookshelfDocument = { save(data: Partial, options?: unknown): Promise; @@ -40,6 +40,7 @@ type GiftRow = { consumed_at: Date | null; expired_at: Date | null; refunded_at: Date | null; + consumes_soon_reminder_sent_at: Date | null; }; type GiftBookshelfModel = BookshelfModel; @@ -95,6 +96,18 @@ export class GiftBookshelfRepository implements GiftRepository { return collection.models.map(model => this.toGift(model)); } + async findPendingReminder({now, reminderLeadMs, reminderFloorMs, transacting}: FindPendingReminderOptions): Promise { + const upper = new Date(now.getTime() + reminderLeadMs).toISOString(); + const lower = new Date(now.getTime() + reminderFloorMs).toISOString(); + + const collection = await this.model.findAll({ + filter: `status:redeemed+consumes_at:<='${upper}'+consumes_at:>'${lower}'+consumes_soon_reminder_sent_at:null`, + transacting + }); + + return collection.models.map(model => this.toGift(model)); + } + async create(gift: Gift, options: RepositoryTransactionOptions = {}) { await this.model.add(this.toRow(gift), options); } @@ -140,7 +153,8 @@ export class GiftBookshelfRepository implements GiftRepository { redeemed_at: gift.redeemedAt, consumed_at: gift.consumedAt, expired_at: gift.expiredAt, - refunded_at: gift.refundedAt + refunded_at: gift.refundedAt, + consumes_soon_reminder_sent_at: gift.consumesSoonReminderSentAt }; } @@ -166,7 +180,8 @@ export class GiftBookshelfRepository implements GiftRepository { redeemedAt: json.redeemed_at, consumedAt: json.consumed_at, expiredAt: json.expired_at, - refundedAt: json.refunded_at + refundedAt: json.refunded_at, + consumesSoonReminderSentAt: json.consumes_soon_reminder_sent_at ?? null }); } } diff --git a/ghost/core/core/server/services/gifts/gift-email-renderer.ts b/ghost/core/core/server/services/gifts/gift-email-renderer.ts index 08d71ea7e87..89f8bc81bdd 100644 --- a/ghost/core/core/server/services/gifts/gift-email-renderer.ts +++ b/ghost/core/core/server/services/gifts/gift-email-renderer.ts @@ -3,11 +3,14 @@ import path from 'node:path'; import Handlebars from 'handlebars'; import type {GiftPurchaseConfirmationData} from './email-templates/gift-purchase-confirmation'; import {renderText as renderPurchaseConfirmationText} from './email-templates/gift-purchase-confirmation'; +import type {GiftReminderData} from './email-templates/gift-reminder'; +import {renderText as renderReminderText} from './email-templates/gift-reminder'; export class GiftEmailRenderer { private readonly handlebars: typeof Handlebars; private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null; + private reminderTemplate: HandlebarsTemplateDelegate | null = null; constructor() { this.handlebars = Handlebars.create(); @@ -25,4 +28,17 @@ export class GiftEmailRenderer { text: renderPurchaseConfirmationText(data) }; } + + async renderReminder(data: GiftReminderData): Promise<{html: string; text: string}> { + if (!this.reminderTemplate) { + const source = await fs.readFile(path.join(__dirname, './email-templates/gift-reminder.hbs'), 'utf8'); + + this.reminderTemplate = this.handlebars.compile(source); + } + + return { + html: this.reminderTemplate(data), + text: renderReminderText(data) + }; + } } diff --git a/ghost/core/core/server/services/gifts/gift-email-service.ts b/ghost/core/core/server/services/gifts/gift-email-service.ts index 64e0da34212..d102762931a 100644 --- a/ghost/core/core/server/services/gifts/gift-email-service.ts +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -35,6 +35,15 @@ interface PurchaseConfirmationData { expiresAt: Date; } +interface ReminderData { + memberEmail: string; + memberName: string | null; + tierName: string; + cadence: 'month' | 'year'; + duration: number; + consumesAt: Date; +} + export class GiftEmailService { private readonly mailer: Mailer; private readonly settingsCache: SettingsCache; @@ -69,8 +78,7 @@ export class GiftEmailService { const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; - const unit = cadence === 'month' ? 'month' : 'year'; - const cadenceLabel = duration === 1 ? `1 ${unit}` : `${duration} ${unit}s`; + const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`; // Pre-build a mailto: URL the buyer can click to open their default mail // client with a friendly draft already filled in. Recipient is left blank @@ -108,6 +116,43 @@ export class GiftEmailService { }); } + async sendReminder({memberEmail, memberName, tierName, cadence, duration, consumesAt}: ReminderData): Promise { + const siteDomain = this.siteDomain; + const siteUrl = this.urlUtils.getSiteUrl(); + const siteTitle = this.settingsCache.get('title') ?? siteDomain; + + const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`; + + const manageSubscriptionUrl = new URL('#/portal/account', siteUrl).href; + + const templateData = { + siteTitle, + siteUrl, + siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), + siteDomain, + accentColor: this.settingsCache.get('accent_color'), + memberEmail, + memberName, + gift: { + tierName, + cadenceLabel, + consumesAt: moment(consumesAt).format('D MMM YYYY'), + manageSubscriptionUrl + } + }; + + const {html, text} = await this.renderer.renderReminder(templateData); + + await this.mailer.send({ + to: memberEmail, + subject: `Your gift subscription to ${siteTitle} is ending soon`, + html, + text, + from: this.getFromAddress(), + forceTextContent: true + }); + } + private formatAmount({amount = 0, currency}: {amount?: number; currency?: string}): string { if (!currency) { return Intl.NumberFormat('en', {maximumFractionDigits: 2}).format(amount); diff --git a/ghost/core/core/server/services/gifts/gift-repository.ts b/ghost/core/core/server/services/gifts/gift-repository.ts index 5c9ed528d2d..03ef8d4bc93 100644 --- a/ghost/core/core/server/services/gifts/gift-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-repository.ts @@ -5,12 +5,20 @@ export interface RepositoryTransactionOptions { forUpdate?: boolean; } +export interface FindPendingReminderOptions { + now: Date; + reminderLeadMs: number; + reminderFloorMs: number; + transacting?: unknown; +} + export interface GiftRepository { existsByCheckoutSessionId(checkoutSessionId: string): Promise; getByToken(token: string, options?: RepositoryTransactionOptions): Promise; getByPaymentIntentId(paymentIntentId: string): Promise; findPendingConsumption(): Promise; findPendingExpiration(): Promise; + findPendingReminder(options: FindPendingReminderOptions): Promise; create(gift: Gift, options?: RepositoryTransactionOptions): Promise; update(gift: Gift, options?: RepositoryTransactionOptions): Promise; transaction(callback: (transacting: unknown) => Promise): Promise; diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 251dc34aca2..1e9d95f37e2 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -3,6 +3,9 @@ import logging from '@tryghost/logging'; import {Gift} from './gift'; import type {GiftRepository} from './gift-repository'; import tpl from '@tryghost/tpl'; +import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; const errorMessages = { giftSubscriptionsNotEnabled: 'Gift subscriptions are not enabled on this site.', @@ -14,8 +17,17 @@ const errorMessages = { paidMember: 'You already have an active subscription.' }; +interface MemberModel { + id: string; + get(key: 'email'): string; + get(key: 'status'): string; + get(key: 'name'): string | null; + get(key: 'email_disabled'): boolean; + get(key: string): unknown; +} + interface MemberRepository { - get(filter: Record, options?: Record): Promise<{id: string; get(key: string): string | null} | null>; + get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; } @@ -46,6 +58,14 @@ interface GiftEmailService { duration: number; expiresAt: Date; }): Promise; + sendReminder(data: { + memberEmail: string; + memberName: string | null; + tierName: string; + cadence: 'month' | 'year'; + duration: number; + consumesAt: Date; + }): Promise; } interface StaffServiceEmails { @@ -89,6 +109,14 @@ interface GiftServiceDeps { staffServiceEmails: StaffServiceEmails; } +interface ReminderSend { + memberEmail: string; + memberName: string | null; + cadence: 'month' | 'year'; + duration: number; + consumesAt: Date; +} + export class GiftService { private readonly deps: GiftServiceDeps; @@ -165,16 +193,8 @@ export class GiftService { return true; } - async getByToken(token: string): Promise { - const gift = await this.deps.giftRepository.getByToken(token); - - if (!gift) { - throw new errors.NotFoundError({ - message: tpl(errorMessages.giftNotFound) - }); - } - - return gift; + async getByToken(token: string): Promise { + return this.deps.giftRepository.getByToken(token); } async assertRedeemable(gift: Gift, memberStatus: string | null): Promise { @@ -270,8 +290,8 @@ export class GiftService { await this.deps.staffServiceEmails.notifyGiftSubscriptionStarted({ memberId: member.id, - memberEmail: member.get('email')!, - memberName: member.get('name') ?? null, + memberEmail: member.get('email'), + memberName: member.get('name'), tierName: tier.name, buyerEmail: redeemed.buyerEmail }); @@ -390,4 +410,121 @@ export class GiftService { return {expiredCount}; } + + async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> { + const now = new Date(); + const toRemind = await this.deps.giftRepository.findPendingReminder({ + now, + reminderLeadMs: GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY, + reminderFloorMs: GIFT_REMINDER_FLOOR_DAYS * MS_PER_DAY + }); + + if (toRemind.length === 0) { + return {remindedCount: 0, skippedCount: 0, failedCount: 0}; + } + + let remindedCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const gift of toRemind) { + try { + const sent = await this.sendReminderForGift(gift.token); + + if (sent) { + remindedCount += 1; + } else { + skippedCount += 1; + } + } catch (err) { + logging.error(err); + + failedCount += 1; + } + } + + return {remindedCount, skippedCount, failedCount}; + } + + private async sendReminderForGift(token: string): Promise { + const gift = await this.deps.giftRepository.getByToken(token); + + if (!gift) { + return false; + } + + const tier = await this.deps.tiersService.api.read(gift.tierId); + + if (!tier) { + throw new errors.NotFoundError({message: `Tier not found for gift: ${gift.tierId}`}); + } + + const result = await this.deps.giftRepository.transaction(async (transacting): Promise => { + const locked = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true}); + + if (!locked) { + return null; + } + + if ( + // Gift must still be active — a concurrent refund or early consume can happen + // between `findPendingReminder` and this re-read. + locked.status !== 'redeemed' + // Idempotency guard: another path (rerun, scheduler) may already have sent. + || locked.consumesSoonReminderSentAt !== null + // Narrows `redeemerMemberId` from `string | null` to `string` — always set for redeemed gifts. + || locked.redeemerMemberId === null + // Narrows `consumesAt` from `Date | null` to `Date` — always set for redeemed gifts. + || locked.consumesAt === null + ) { + return null; + } + + const member = await this.deps.memberRepository.get( + {id: locked.redeemerMemberId}, + {transacting, forUpdate: true} + ); + + // Record the reminder as sent before any skip or send below so we don't + // re-try gifts with permanently unreachable redeemers on every poll. + const reminded = locked.remind(); + + if (!reminded) { + return null; + } + + await this.deps.giftRepository.update(reminded, {transacting}); + + if (!member) { + return null; + } + + if (member.get('email_disabled')) { + return null; + } + + return { + memberEmail: member.get('email'), + memberName: member.get('name'), + cadence: locked.cadence, + duration: locked.duration, + consumesAt: locked.consumesAt + }; + }); + + if (!result) { + return false; + } + + await this.deps.giftEmailService.sendReminder({ + memberEmail: result.memberEmail, + memberName: result.memberName, + tierName: tier.name, + cadence: result.cadence, + duration: result.duration, + consumesAt: result.consumesAt + }); + + return true; + } } diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts index a70e1ee85b7..d60c4ec139c 100644 --- a/ghost/core/core/server/services/gifts/gift.ts +++ b/ghost/core/core/server/services/gifts/gift.ts @@ -28,6 +28,7 @@ interface GiftData { consumedAt: Date | null; expiredAt: Date | null; refundedAt: Date | null; + consumesSoonReminderSentAt: Date | null; } export interface GiftFromPurchaseData { @@ -63,6 +64,7 @@ export class Gift { consumedAt: Date | null; expiredAt: Date | null; refundedAt: Date | null; + consumesSoonReminderSentAt: Date | null; constructor(data: GiftData) { this.token = data.token; @@ -84,6 +86,7 @@ export class Gift { this.consumedAt = data.consumedAt; this.expiredAt = data.expiredAt; this.refundedAt = data.refundedAt; + this.consumesSoonReminderSentAt = data.consumesSoonReminderSentAt; } static fromPurchase(data: GiftFromPurchaseData) { @@ -102,7 +105,8 @@ export class Gift { redeemedAt: null, consumedAt: null, expiredAt: null, - refundedAt: null + refundedAt: null, + consumesSoonReminderSentAt: null }); } @@ -199,4 +203,15 @@ export class Gift { expiredAt: new Date() }); } + + remind(): Gift | null { + if (this.consumesSoonReminderSentAt !== null) { + return null; + } + + return new Gift({ + ...this, + consumesSoonReminderSentAt: new Date() + }); + } } diff --git a/ghost/core/core/server/services/members/jobs/index.js b/ghost/core/core/server/services/members/jobs/index.js index 551369bdc96..804f3070633 100644 --- a/ghost/core/core/server/services/members/jobs/index.js +++ b/ghost/core/core/server/services/members/jobs/index.js @@ -3,7 +3,8 @@ const jobsService = require('../../jobs'); let hasScheduled = { expiredComped: false, - gifts: false, + giftCleanup: false, + giftReminders: false, tokens: false }; @@ -33,7 +34,11 @@ module.exports = { }, async scheduleGiftCleanupJob() { - return scheduleJob('gifts', 'clean-gifts', 'clean-gifts.js'); + return scheduleJob('giftCleanup', 'clean-gifts', 'clean-gifts.js'); + }, + + async scheduleGiftReminderJob() { + return scheduleJob('giftReminders', 'send-gift-reminders', 'send-gift-reminders.js'); }, async scheduleTokenCleanupJob() { diff --git a/ghost/core/core/server/services/members/jobs/send-gift-reminders.js b/ghost/core/core/server/services/members/jobs/send-gift-reminders.js new file mode 100644 index 00000000000..88dcc289506 --- /dev/null +++ b/ghost/core/core/server/services/members/jobs/send-gift-reminders.js @@ -0,0 +1,58 @@ +const {parentPort} = require('node:worker_threads'); +const debug = require('@tryghost/debug')('jobs:send-gift-reminders'); + +// recurring job to send gift reminder emails. + +// Exit early when cancelled to prevent stalling shutdown. No cleanup needed +// when cancelling as everything is idempotent — any gift that hasn't yet had +// its reminder recorded will be picked up on the next run. +function cancel() { + if (parentPort) { + parentPort.postMessage('Gift reminder job cancelled before completion'); + parentPort.postMessage('cancelled'); + } else { + setTimeout(() => { + process.exit(0); + }, 1000); + } +} + +if (parentPort) { + parentPort.once('message', (message) => { + if (message === 'cancel') { + return cancel(); + } + }); +} + +(async () => { + try { + const startDate = new Date(); + debug('Starting gift reminder send'); + + const giftService = require('../../gifts'); + await giftService.init(); + + const {remindedCount, skippedCount, failedCount} = await giftService.service.processReminders(); + + const endDate = new Date(); + const message = `Sent ${remindedCount} gift reminders, skipped ${skippedCount}, failed ${failedCount} in ${endDate.valueOf() - startDate.valueOf()}ms`; + + debug(message); + + if (parentPort) { + parentPort.postMessage(message); + parentPort.postMessage('done'); + } else { + setTimeout(() => { + process.exit(0); + }, 1000); + } + } catch (error) { + if (parentPort) { + parentPort.postMessage('done'); + } + + throw error; + } +})(); diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index e0f19eaa431..c3b59098c6b 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -85,7 +85,8 @@ module.exports = class EventRepository { {type: 'login_event', action: 'getLoginEvents'}, {type: 'payment_event', action: 'getPaymentEvents'}, {type: 'email_change_event', action: 'getEmailChangeEvent'}, - {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'} + {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'}, + {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'} ); if (this._AutomatedEmailRecipient) { @@ -474,6 +475,55 @@ module.exports = class EventRepository { }; } + async getGiftRedemptionEvents(options = {}, filter) { + options = { + ...options, + withRelated: ['redeemer', 'tier'], + filter: 'redeemer_member_id:-null+custom:true', + useBasicCount: true, + mongoTransformer: chainTransformers( + // First set the filter manually + replaceCustomFilterTransformer(filter), + + // Map the used keys in that filter + ...mapKeys({ + 'data.created_at': 'redeemed_at', + 'data.member_id': 'redeemer_member_id' + }) + ) + }; + + if (options.order) { + options.order = options.order.replace(/created_at/g, 'redeemed_at'); + } + + const {data: models, meta} = await this._Gift.findPage(options); + + const data = models.map((model) => { + const json = model.toJSON(options); + + return { + type: 'gift_redemption_event', + data: { + id: json.id, + member: json.redeemer || null, + member_id: json.redeemer_member_id, + tier_name: json.tier?.name, + cadence: json.cadence, + duration: json.duration, + amount: json.amount, + currency: json.currency, + created_at: json.redeemed_at + } + }; + }); + + return { + data, + meta + }; + } + async getCommentEvents(options = {}, filter) { options = { ...options, diff --git a/ghost/core/core/server/services/members/service.js b/ghost/core/core/server/services/members/service.js index f6616accac5..473ddac641d 100644 --- a/ghost/core/core/server/services/members/service.js +++ b/ghost/core/core/server/services/members/service.js @@ -166,9 +166,11 @@ module.exports = { // Schedule daily cron job to clean expired tokens memberJobs.scheduleTokenCleanupJob(); - // Schedule daily cron job to clean consumed and expired gifts + // Schedule daily cron jobs to clean up consumed/expired gifts and to + // send gift reminder emails. if (labsService.isSet('giftSubscriptions')) { memberJobs.scheduleGiftCleanupJob(); + memberJobs.scheduleGiftReminderJob(); } }, contentGating: require('./content-gating'), diff --git a/ghost/core/core/server/web/gift-preview/app.js b/ghost/core/core/server/web/gift-preview/app.js new file mode 100644 index 00000000000..7f1469b6003 --- /dev/null +++ b/ghost/core/core/server/web/gift-preview/app.js @@ -0,0 +1,16 @@ +const express = require('../../../shared/express'); +const errorHandler = require('@tryghost/mw-error-handler'); +const sentry = require('../../../shared/sentry'); +const controller = require('./controller'); + +module.exports = function giftPreviewApp() { + const app = express('gift-preview'); + + app.get('/:token/image', controller.giftPreviewImage); + app.get('/:token', controller.giftPreview); + + app.use(errorHandler.pageNotFound); + app.use(errorHandler.handleHTMLResponse(sentry)); + + return app; +}; diff --git a/ghost/core/core/server/web/gift-preview/controller.js b/ghost/core/core/server/web/gift-preview/controller.js new file mode 100644 index 00000000000..8b884eda9b6 --- /dev/null +++ b/ghost/core/core/server/web/gift-preview/controller.js @@ -0,0 +1,141 @@ +const errors = require('@tryghost/errors'); +const logging = require('@tryghost/logging'); +const {generateGiftPreviewImage} = require('./image'); + +function getCadenceLabel(cadence, duration) { + return duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`; +} + +function escapeHtml(str) { + return str + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +async function giftPreview(req, res) { + const labs = require('../../../shared/labs'); + const giftService = require('../../services/gifts').service; + const tiersService = require('../../services/tiers'); + const urlUtils = require('../../../shared/url-utils'); + const settingsCache = require('../../../shared/settings-cache'); + + const siteUrl = urlUtils.getSiteUrl().replace(/\/$/, ''); + + if (!labs.isSet('giftSubscriptions')) { + return res.redirect(302, siteUrl + '/'); + } + + const {token} = req.params; + const siteTitle = settingsCache.get('title') || 'Ghost'; + + let gift; + let tier; + + try { + gift = await giftService.getByToken(token); + tier = await tiersService.api.read(gift.tierId); + + if (!tier) { + throw new errors.NotFoundError({message: `Tier not found: ${gift.tierId}`}); + } + } catch (err) { + logging.warn(`Gift preview: failed to load required gift data, redirecting to homepage`, err); + + return res.redirect(302, siteUrl + '/'); + } + + const cadenceLabel = getCadenceLabel(gift.cadence, gift.duration); + const ogTitle = `A gift membership to ${siteTitle}`; + const ogDescription = `${tier.name} \u00B7 ${cadenceLabel}`; + const ogImage = `${siteUrl}/gift/${encodeURIComponent(token)}/image`; + const ogUrl = `${siteUrl}/gift/${encodeURIComponent(token)}`; + const redirectUrl = `${siteUrl}/#/portal/gift/redeem/${encodeURIComponent(token)}`; + + const html = ` + + + + + ${escapeHtml(ogTitle)} + + + + + + + + + + + + + + + + + + + + + + + + +`; + + res.set('Cache-Control', 'public, max-age=3600'); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(html); +} + +async function giftPreviewImage(req, res) { + const labs = require('../../../shared/labs'); + const giftService = require('../../services/gifts').service; + const tiersService = require('../../services/tiers'); + const settingsCache = require('../../../shared/settings-cache'); + + if (!labs.isSet('giftSubscriptions')) { + return res.sendStatus(404); + } + + const token = req.params.token; + + let gift; + let tier; + + try { + gift = await giftService.getByToken(token); + tier = await tiersService.api.read(gift.tierId); + + if (!tier) { + throw new errors.NotFoundError({message: `Tier not found: ${gift.tierId}`}); + } + } catch (err) { + logging.warn('Gift preview image: failed to load required gift data', err); + + return res.sendStatus(404); + } + + const tierName = tier.name; + const cadenceLabel = getCadenceLabel(gift.cadence, gift.duration); + const accentColor = settingsCache.get('accent_color') || '#15171A'; + + try { + const png = await generateGiftPreviewImage({tierName, cadenceLabel, accentColor}); + + res.set('Content-Type', 'image/png'); + res.set('Cache-Control', 'public, max-age=86400'); + res.send(png); + } catch (err) { + logging.error('Gift OG image generation failed', err); + + res.sendStatus(404); + } +} + +module.exports = { + giftPreview, + giftPreviewImage +}; diff --git a/ghost/core/core/server/web/gift-preview/image.js b/ghost/core/core/server/web/gift-preview/image.js new file mode 100644 index 00000000000..b16ce30dad2 --- /dev/null +++ b/ghost/core/core/server/web/gift-preview/image.js @@ -0,0 +1,110 @@ +const CACHE_MAX_SIZE = 100; + +const cache = new Map(); + +function cacheResult(key, value) { + if (cache.size >= CACHE_MAX_SIZE) { + const firstKey = cache.keys().next().value; + + cache.delete(firstKey); + } + + cache.set(key, value); +} + +function escapeXml(str) { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); +} + +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + + if (!result) { + return {r: 0, g: 0, b: 0}; + } + + return { + r: Number.parseInt(result[1], 16), + g: Number.parseInt(result[2], 16), + b: Number.parseInt(result[3], 16) + }; +} + +function getBackgroundColor(accentColor) { + // Blend accent color at 6% opacity over white, matching Portal's redemption modal + const {r, g, b} = hexToRgb(accentColor); + const opacity = 0.06; + const blend = c => Math.round(255 + (c - 255) * opacity); + + return `rgb(${blend(r)}, ${blend(g)}, ${blend(b)})`; +} + +function buildSvg({tierName, cadenceLabel, accentColor}) { + const displayTier = tierName.length > 30 ? tierName.slice(0, 28) + '...' : tierName; + const bgColor = getBackgroundColor(accentColor); + + // Gift box icon from Portal (apps/portal/src/images/icons/gift.svg) + // Original viewBox 0 0 24 24, scaled 4x and centered at x=600 + const giftIcon = ` + + + + + + + + `; + + return ` + + + + + ${giftIcon} + + + GIFT MEMBERSHIP + + + You’ve been gifted a membership + + + ${escapeXml(displayTier)} · ${escapeXml(cadenceLabel)} +`; +} + +async function generateGiftPreviewImage({tierName, cadenceLabel, accentColor}) { + const cacheKey = `${tierName}:${cadenceLabel}:${accentColor}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const imageTransform = require('@tryghost/image-transform'); + const svg = buildSvg({tierName, cadenceLabel, accentColor}); + const image = await imageTransform.resizeFromBuffer(Buffer.from(svg), { + width: 1200, + format: 'png', + withoutEnlargement: false, + timeout: 10 + }); + + cacheResult(cacheKey, image); + + return image; +} + +module.exports = { + generateGiftPreviewImage +}; diff --git a/ghost/core/core/server/web/gift-preview/index.js b/ghost/core/core/server/web/gift-preview/index.js new file mode 100644 index 00000000000..a9612f49fbf --- /dev/null +++ b/ghost/core/core/server/web/gift-preview/index.js @@ -0,0 +1 @@ +module.exports = require('./app'); diff --git a/ghost/core/core/server/web/parent/frontend.js b/ghost/core/core/server/web/parent/frontend.js index 5d345e6c84e..548c199a957 100644 --- a/ghost/core/core/server/web/parent/frontend.js +++ b/ghost/core/core/server/web/parent/frontend.js @@ -19,6 +19,7 @@ module.exports = (routerConfig) => { frontendApp.lazyUse('/members', require('../members')); frontendApp.lazyUse('/webmentions', require('../webmentions')); + frontendApp.lazyUse('/gift', require('../gift-preview')); frontendApp.use('/', require('../../../frontend/web')(routerConfig)); return frontendApp; diff --git a/ghost/core/test/integration/services/members/send-gift-reminders.test.js b/ghost/core/test/integration/services/members/send-gift-reminders.test.js new file mode 100644 index 00000000000..601127dbb54 --- /dev/null +++ b/ghost/core/test/integration/services/members/send-gift-reminders.test.js @@ -0,0 +1,201 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const {agentProvider, fixtureManager, mockManager} = require('../../../utils/e2e-framework'); +const models = require('../../../../core/server/models'); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// This test exercises the gift reminder polling path end-to-end against a real +// database and the real GiftService/Repository wiring. It calls +// `giftService.service.processReminders()` directly rather than spawning the +// worker thread — the worker script is a thin wrapper and spawning it in-process +// means we can't intercept the email transport on the parent thread. + +describe('Gift reminder processing', function () { + let giftService; + let emailMockReceiver; + let paidTier; + let redeemerMember; + let giftSequence = 0; + + before(async function () { + const agent = await agentProvider.getAdminAPIAgent(); + + await fixtureManager.init('newsletters', 'members:newsletters'); + await agent.loginAsOwner(); + + giftService = require('../../../../core/server/services/gifts'); + await giftService.init(); + + paidTier = await models.Product.findOne({type: 'paid'}, {require: true}); + }); + + beforeEach(async function () { + mockManager.mockLabsEnabled('giftSubscriptions'); + emailMockReceiver = mockManager.mockMail(); + + redeemerMember = await models.Member.add({ + email: `gift-redeemer-${Date.now()}-${Math.random()}@example.com`, + name: 'Gift Redeemer', + status: 'gift', + email_disabled: false + }); + }); + + afterEach(async function () { + await models.Gift.query().del(); + + if (redeemerMember) { + await models.Member.destroy({id: redeemerMember.id}); + } + + mockManager.restore(); + sinon.restore(); + }); + + async function createRedeemedGift(options = {}) { + const { + consumesAt, + consumesSoonReminderSentAt = null, + redeemerId = redeemerMember.id, + overrides = {} + } = options; + + giftSequence += 1; + const sequence = giftSequence; + const now = new Date(); + const expiresAt = new Date(now.getTime() + 365 * MS_PER_DAY); + + return await models.Gift.add({ + token: `reminder-test-token-${sequence}-${Date.now()}`, + buyer_email: `gift-buyer-${sequence}@example.com`, + buyer_member_id: null, + redeemer_member_id: redeemerId, + tier_id: paidTier.id, + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: `cs_reminder_${sequence}_${Date.now()}`, + stripe_payment_intent_id: `pi_reminder_${sequence}_${Date.now()}`, + consumes_at: consumesAt, + expires_at: expiresAt, + status: 'redeemed', + purchased_at: now, + redeemed_at: now, + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: consumesSoonReminderSentAt, + ...overrides + }); + } + + it('sends a reminder email for gifts with consumes_at in (now+3d, now+7d] and records the reminder', async function () { + const now = new Date(); + const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY); + const gift = await createRedeemedGift({consumesAt: inWindow}); + + const result = await giftService.service.processReminders(); + + assert.equal(result.remindedCount, 1); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + + emailMockReceiver.assertSentEmailCount(1); + + const sent = emailMockReceiver.getSentEmail(0); + + assert.equal(sent.to, redeemerMember.get('email')); + assert.match(sent.subject, /ending soon/); + + const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true}); + + assert.ok(reloaded.get('consumes_soon_reminder_sent_at'), 'Gift should be marked as reminded'); + }); + + it('does not send a reminder for gifts that consume too soon (inside the floor)', async function () { + const now = new Date(); + const tooSoon = new Date(now.getTime() + 2 * MS_PER_DAY); + const gift = await createRedeemedGift({consumesAt: tooSoon}); + + const result = await giftService.service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + + emailMockReceiver.assertSentEmailCount(0); + + const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true}); + + assert.equal(reloaded.get('consumes_soon_reminder_sent_at'), null); + }); + + it('does not send a reminder for gifts that consume too far in the future', async function () { + const now = new Date(); + const tooFar = new Date(now.getTime() + 30 * MS_PER_DAY); + const gift = await createRedeemedGift({consumesAt: tooFar}); + + const result = await giftService.service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + + emailMockReceiver.assertSentEmailCount(0); + + const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true}); + + assert.equal(reloaded.get('consumes_soon_reminder_sent_at'), null); + }); + + it('does not re-send a reminder for gifts that have already been reminded', async function () { + const now = new Date(); + const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY); + const alreadyStamped = new Date(now.getTime() - MS_PER_DAY); + + await createRedeemedGift({consumesAt: inWindow, consumesSoonReminderSentAt: alreadyStamped}); + + const result = await giftService.service.processReminders(); + + // findPendingReminder's NQL filter excludes gifts that have already been + // reminded, so the gift never enters the per-gift loop — counts stay at + // zero. + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + emailMockReceiver.assertSentEmailCount(0); + }); + + it('marks the gift as reminded but does not email when the redeemer has email_disabled', async function () { + await models.Member.edit({email_disabled: true}, {id: redeemerMember.id}); + + const now = new Date(); + const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY); + const gift = await createRedeemedGift({consumesAt: inWindow}); + + const result = await giftService.service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 1); + assert.equal(result.failedCount, 0); + emailMockReceiver.assertSentEmailCount(0); + + const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true}); + + assert.ok(reloaded.get('consumes_soon_reminder_sent_at'), 'Gift should still be marked as reminded to prevent retries'); + }); + + it('only sends one email across consecutive runs', async function () { + const now = new Date(); + const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY); + + await createRedeemedGift({consumesAt: inWindow}); + + await giftService.service.processReminders(); + await giftService.service.processReminders(); + + emailMockReceiver.assertSentEmailCount(1); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts index c0a6fd3350f..3beefcf7765 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts @@ -137,7 +137,8 @@ describe('GiftBookshelfRepository', function () { redeemedAt: new Date('2030-01-01T00:00:00.000Z'), consumedAt: null, expiredAt: null, - refundedAt: null + refundedAt: null, + consumesSoonReminderSentAt: null }); await repository.update(gift, {transacting: 'trx'}); @@ -180,7 +181,8 @@ describe('GiftBookshelfRepository', function () { redeemedAt: null, consumedAt: null, expiredAt: null, - refundedAt: null + refundedAt: null, + consumesSoonReminderSentAt: null }); await assert.rejects( @@ -270,6 +272,184 @@ describe('GiftBookshelfRepository', function () { assert.ok(filterDate <= after); }); + it('finds gifts pending reminders within the configured window that have not yet received a reminder', async function () { + const GiftModel = { + add: sinon.stub(), + transaction: sinon.stub(), + findOne: sinon.stub(), + findAll: sinon.stub().resolves({ + models: [{ + toJSON() { + return { + token: 'gift-token', + buyer_email: 'buyer@example.com', + buyer_member_id: 'buyer_member_1', + redeemer_member_id: 'member_2', + tier_id: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: 'cs_123', + stripe_payment_intent_id: 'pi_456', + consumes_at: new Date('2026-04-20T00:00:00.000Z'), + expires_at: new Date('2030-01-01T00:00:00.000Z'), + status: 'redeemed', + purchased_at: new Date('2025-01-01T00:00:00.000Z'), + redeemed_at: new Date('2025-04-20T00:00:00.000Z'), + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: null + }; + } + }] + }) + }; + const repository = new GiftBookshelfRepository({GiftModel}); + + const now = new Date('2026-04-16T00:00:00.000Z'); + const reminderLeadMs = 7 * 24 * 60 * 60 * 1000; + const reminderFloorMs = 3 * 24 * 60 * 60 * 1000; + + const gifts = await repository.findPendingReminder({ + now, + reminderLeadMs, + reminderFloorMs, + transacting: 'trx' + }); + + assert.equal(gifts.length, 1); + assert.equal(gifts[0].token, 'gift-token'); + assert.equal(gifts[0].consumesSoonReminderSentAt, null); + + sinon.assert.calledOnce(GiftModel.findAll); + + const callArgs = GiftModel.findAll.getCall(0).args[0]; + const filterArg: string = callArgs.filter; + const upperIso = new Date(now.getTime() + reminderLeadMs).toISOString(); + const lowerIso = new Date(now.getTime() + reminderFloorMs).toISOString(); + + assert.equal(callArgs.transacting, 'trx'); + assert.ok(filterArg.startsWith('status:redeemed')); + assert.ok(filterArg.includes(`consumes_at:<='${upperIso}'`)); + assert.ok(filterArg.includes(`consumes_at:>'${lowerIso}'`)); + assert.ok(filterArg.includes('consumes_soon_reminder_sent_at:null')); + }); + + it('reads consumes_soon_reminder_sent_at into the domain gift', async function () { + const reminderSentAt = new Date('2026-04-13T00:00:00.000Z'); + + const GiftModel = { + add: sinon.stub(), + transaction: sinon.stub(), + findOne: sinon.stub().resolves({ + save: sinon.stub(), + set: sinon.stub(), + toJSON() { + return { + token: 'gift-token', + buyer_email: 'buyer@example.com', + buyer_member_id: 'buyer_member_1', + redeemer_member_id: 'member_2', + tier_id: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: 'cs_123', + stripe_payment_intent_id: 'pi_456', + consumes_at: new Date('2026-04-20T00:00:00.000Z'), + expires_at: new Date('2030-01-01T00:00:00.000Z'), + status: 'redeemed', + purchased_at: new Date('2025-01-01T00:00:00.000Z'), + redeemed_at: new Date('2025-04-20T00:00:00.000Z'), + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: reminderSentAt + }; + } + }), + findAll: sinon.stub() + }; + const repository = new GiftBookshelfRepository({GiftModel}); + + const gift = await repository.getByToken('gift-token'); + + assert.ok(gift); + assert.equal(gift.consumesSoonReminderSentAt?.toISOString(), reminderSentAt.toISOString()); + }); + + it('writes consumes_soon_reminder_sent_at through update', async function () { + const existing = { + save: sinon.stub().resolves(undefined), + set: sinon.stub(), + toJSON() { + return { + token: 'gift-token', + buyer_email: 'buyer@example.com', + buyer_member_id: 'buyer_member_1', + redeemer_member_id: 'member_2', + tier_id: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: 'cs_123', + stripe_payment_intent_id: 'pi_456', + consumes_at: new Date('2026-04-20T00:00:00.000Z'), + expires_at: new Date('2030-01-01T00:00:00.000Z'), + status: 'redeemed', + purchased_at: new Date('2025-01-01T00:00:00.000Z'), + redeemed_at: new Date('2025-04-20T00:00:00.000Z'), + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: null + }; + } + }; + const GiftModel = { + add: sinon.stub(), + transaction: sinon.stub(), + findOne: sinon.stub().resolves(existing), + findAll: sinon.stub() + }; + const repository = new GiftBookshelfRepository({GiftModel}); + const reminderSentAt = new Date('2026-04-13T00:00:00.000Z'); + const gift = new Gift({ + token: 'gift-token', + buyerEmail: 'buyer@example.com', + buyerMemberId: 'buyer_member_1', + redeemerMemberId: 'member_2', + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_456', + consumesAt: new Date('2026-04-20T00:00:00.000Z'), + expiresAt: new Date('2030-01-01T00:00:00.000Z'), + status: 'redeemed', + purchasedAt: new Date('2025-01-01T00:00:00.000Z'), + redeemedAt: new Date('2025-04-20T00:00:00.000Z'), + consumedAt: null, + expiredAt: null, + refundedAt: null, + consumesSoonReminderSentAt: reminderSentAt + }); + + await repository.update(gift, {transacting: 'trx'}); + + sinon.assert.calledOnce(existing.save); + + const savedRow = existing.save.firstCall.args[0]; + + assert.equal(savedRow.consumes_soon_reminder_sent_at, reminderSentAt); + }); + it('finds gifts pending expiration using current time', async function () { const before = new Date(); diff --git a/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts b/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts index a69e07b6d59..72bc434f468 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts @@ -39,6 +39,7 @@ describe('GiftController', function () { consumedAt: null, expiredAt: null, refundedAt: null, + consumesSoonReminderSentAt: null, ...overrides }); } diff --git a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js index 09cb8f3c19d..c16e57e40ed 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js +++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js @@ -130,4 +130,62 @@ describe('GiftEmailService', function () { sinon.assert.calledWith(mailer.send, sinon.match.has('text', sinon.match('gift subscription on example.com'))); }); + + describe('sendReminder', function () { + const reminderData = { + memberEmail: 'member@example.com', + memberName: 'Member Name', + tierName: 'Gold', + cadence: 'year', + duration: 1, + consumesAt: new Date('2026-04-23T00:00:00.000Z') + }; + + it('sends to the redeemer with a site-scoped subject and from address', async function () { + await service.sendReminder(reminderData); + + sinon.assert.calledOnce(mailer.send); + sinon.assert.calledWith(mailer.send, sinon.match({ + to: 'member@example.com', + subject: 'Your gift subscription to Test Site is ending soon', + from: 'Test Site ' + })); + }); + + it('includes tier name, cadence, consumesAt, and manage subscription url in both HTML and text', async function () { + await service.sendReminder(reminderData); + + const msg = mailer.send.getCall(0).args[0]; + + for (const field of ['html', 'text']) { + sinon.assert.match(msg[field], sinon.match('Gold')); + sinon.assert.match(msg[field], sinon.match('1 year')); + sinon.assert.match(msg[field], sinon.match('23 Apr 2026')); + sinon.assert.match(msg[field], sinon.match('https://example.com/#/portal/account')); + } + }); + + it('uses a generic greeting when the member has no name', async function () { + await service.sendReminder({...reminderData, memberName: null}); + + const msg = mailer.send.getCall(0).args[0]; + + sinon.assert.match(msg.text, sinon.match(/^Hi,/)); + }); + + it('includes the member name when provided', async function () { + await service.sendReminder(reminderData); + + const msg = mailer.send.getCall(0).args[0]; + + sinon.assert.match(msg.text, sinon.match('Hi Member Name,')); + sinon.assert.match(msg.html, sinon.match('Hi Member Name,')); + }); + + it('formats month cadence correctly', async function () { + await service.sendReminder({...reminderData, cadence: 'month', duration: 3}); + + sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('3 months'))); + }); + }); }); 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 c51f8016b9f..2724919ccdd 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 @@ -3,9 +3,30 @@ import errors from '@tryghost/errors'; import sinon from 'sinon'; import {GiftService, type GiftPurchaseData} from '../../../../../core/server/services/gifts/gift-service'; import {Gift} from '../../../../../core/server/services/gifts/gift'; -import type {GiftRepository} from '../../../../../core/server/services/gifts/gift-repository'; +import type {FindPendingReminderOptions, GiftRepository} from '../../../../../core/server/services/gifts/gift-repository'; import {buildGift} from './utils'; +function buildRedeemedGift(overrides: Parameters[0] = {}) { + return buildGift({ + token: 'gift-token', + status: 'redeemed', + redeemerMemberId: 'member_1', + redeemedAt: new Date('2025-04-01T00:00:00.000Z'), + consumesAt: new Date('2026-04-16T00:00:00.000Z'), + ...overrides + }); +} + +function buildRedeemer(id: string = 'member_1') { + const memberGet = sinon.stub(); + + memberGet.withArgs('email').returns(`${id}@example.com`); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email_disabled').returns(false); + + return {id, get: memberGet}; +} + describe('GiftService', function () { type GiftRepositoryStub = { existsByCheckoutSessionId: sinon.SinonStub<[string], Promise>; @@ -13,6 +34,7 @@ describe('GiftService', function () { getByPaymentIntentId: sinon.SinonStub<[string], Promise>; findPendingConsumption: sinon.SinonStub<[], Promise>; findPendingExpiration: sinon.SinonStub<[], Promise>; + findPendingReminder: sinon.SinonStub<[FindPendingReminderOptions], Promise>; create: sinon.SinonStub; update: sinon.SinonStub; transaction: sinon.SinonStub, Promise>; @@ -29,6 +51,7 @@ describe('GiftService', function () { }; let giftEmailService: { sendPurchaseConfirmation: sinon.SinonStub; + sendReminder: sinon.SinonStub; }; let tiersService: { api: { @@ -55,6 +78,7 @@ describe('GiftService', function () { getByPaymentIntentId: sinon.stub<[string], Promise>().resolves(null), findPendingConsumption: sinon.stub<[], Promise>().resolves([]), findPendingExpiration: sinon.stub<[], Promise>().resolves([]), + findPendingReminder: sinon.stub<[FindPendingReminderOptions], Promise>().resolves([]), create: sinon.stub(), update: sinon.stub(), transaction: sinon.stub, Promise>().callsFake(async (callback) => { @@ -70,7 +94,8 @@ describe('GiftService', function () { notifyGiftSubscriptionStarted: sinon.stub() }; giftEmailService = { - sendPurchaseConfirmation: sinon.stub() + sendPurchaseConfirmation: sinon.stub().resolves(undefined), + sendReminder: sinon.stub().resolves(undefined) }; tiersService = { api: { @@ -295,18 +320,14 @@ describe('GiftService', function () { assert.equal(result, expectedGift); }); - it('throws NotFoundError when the token does not exist', async function () { + it('returns null when the token does not exist', async function () { giftRepository.getByToken.resolves(null); const service = createService(); - await assert.rejects( - () => service.getByToken('missing-token'), - (err: any) => { - assert.equal(err.errorType, 'NotFoundError'); - assert.equal(err.message, 'This gift does not exist.'); - return true; - } - ); + const result = await service.getByToken('missing-token'); + + sinon.assert.calledOnceWithExactly(giftRepository.getByToken, 'missing-token'); + assert.equal(result, null); }); }); @@ -661,6 +682,271 @@ describe('GiftService', function () { }); }); + describe('processReminders', function () { + const MS_PER_DAY = 24 * 60 * 60 * 1000; + + it('returns zero counts when no gifts are pending reminders', async function () { + giftRepository.findPendingReminder.resolves([]); + + const service = createService(); + const result = await service.processReminders(); + + assert.deepEqual(result, {remindedCount: 0, skippedCount: 0, failedCount: 0}); + sinon.assert.notCalled(giftEmailService.sendReminder); + sinon.assert.notCalled(giftRepository.update); + }); + + it('queries the repository with the 7d/3d window', async function () { + giftRepository.findPendingReminder.resolves([]); + + const before = Date.now(); + const service = createService(); + await service.processReminders(); + const after = Date.now(); + + sinon.assert.calledOnce(giftRepository.findPendingReminder); + + const args = giftRepository.findPendingReminder.getCall(0).args[0]; + + assert.equal(args.reminderLeadMs, 7 * MS_PER_DAY); + assert.equal(args.reminderFloorMs, 3 * MS_PER_DAY); + assert.ok(args.now.getTime() >= before); + assert.ok(args.now.getTime() <= after); + }); + + it('sends the reminder, marks the gift as reminded, and returns counts', async function () { + const gift = buildRedeemedGift(); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves(buildRedeemer()); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 1); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + + sinon.assert.calledOnce(giftRepository.transaction); + + // getByToken is called twice: once unlocked (before the tier check) and + // once locked (inside the transaction). + assert.equal(giftRepository.getByToken.callCount, 2); + sinon.assert.calledWithExactly(giftRepository.getByToken.firstCall, gift.token); + sinon.assert.calledWithExactly(giftRepository.getByToken.secondCall, gift.token, {transacting: 'trx', forUpdate: true}); + + sinon.assert.calledOnceWithExactly(memberRepository.get, {id: 'member_1'}, {transacting: 'trx', forUpdate: true}); + + sinon.assert.calledOnce(giftEmailService.sendReminder); + + const emailArgs = giftEmailService.sendReminder.getCall(0).args[0]; + + assert.equal(emailArgs.memberEmail, 'member_1@example.com'); + assert.equal(emailArgs.memberName, 'Member Name'); + assert.equal(emailArgs.tierName, 'Bronze'); + assert.equal(emailArgs.cadence, gift.cadence); + assert.equal(emailArgs.duration, gift.duration); + assert.equal(emailArgs.consumesAt, gift.consumesAt); + + sinon.assert.calledOnce(giftRepository.update); + + const savedGift = giftRepository.update.getCall(0).args[0]; + + assert.notEqual(savedGift.consumesSoonReminderSentAt, null); + }); + + it('skips gifts no longer in redeemed status when re-loaded', async function () { + const gift = buildRedeemedGift(); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(buildGift({ + status: 'refunded', + refundedAt: new Date() + })); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 1); + sinon.assert.notCalled(giftEmailService.sendReminder); + sinon.assert.notCalled(giftRepository.update); + }); + + it('skips gifts that have already been reminded', async function () { + const gift = buildRedeemedGift({ + consumesSoonReminderSentAt: new Date('2026-04-10T00:00:00.000Z') + }); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 1); + sinon.assert.notCalled(giftEmailService.sendReminder); + sinon.assert.notCalled(giftRepository.update); + }); + + it('marks the gift as reminded but does not send when the redeemer has email_disabled', async function () { + const gift = buildRedeemedGift(); + const memberGet = sinon.stub(); + + memberGet.withArgs('email').returns('member@example.com'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email_disabled').returns(true); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 1); + sinon.assert.notCalled(giftEmailService.sendReminder); + sinon.assert.calledOnce(giftRepository.update); + + const savedGift = giftRepository.update.getCall(0).args[0]; + + assert.notEqual(savedGift.consumesSoonReminderSentAt, null); + }); + + it('marks the gift as reminded but does not send when the redeemer no longer exists', async function () { + const gift = buildRedeemedGift(); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves(null); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 1); + sinon.assert.notCalled(giftEmailService.sendReminder); + sinon.assert.calledOnce(giftRepository.update); + + const savedGift = giftRepository.update.getCall(0).args[0]; + + assert.notEqual(savedGift.consumesSoonReminderSentAt, null); + }); + + it('marks the gift as reminded before sending so a failed email does not cause a duplicate send on retry', async function () { + // Mark-before-send trade: we accept the risk of a missed reminder on + // email failure in exchange for the guarantee that no gift is ever + // reminded twice. The failure is caught by processReminders' + // per-gift try/catch and counted as a failure rather than propagated. + const gift = buildRedeemedGift(); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves(buildRedeemer()); + giftEmailService.sendReminder.rejects(new Error('SMTP error')); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 1); + + // The reminder-sent marker was committed before the email was attempted. + sinon.assert.calledOnce(giftRepository.update); + + const marked = giftRepository.update.getCall(0).args[0]; + assert.notEqual(marked.consumesSoonReminderSentAt, null); + + // And the update call finished before sendReminder was invoked. + sinon.assert.callOrder(giftRepository.update, giftEmailService.sendReminder); + }); + + it('does not mark the gift as reminded when the tier is missing so an admin fix recovers the reminder', async function () { + const gift = buildRedeemedGift(); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + tiersService.api.read.resolves(null); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 1); + + // Tier is read up front, but the transaction never runs, so the gift + // is neither locked nor marked as reminded. A follow-up run after the + // tier is restored will pick the gift up again. + sinon.assert.notCalled(giftRepository.update); + sinon.assert.notCalled(giftEmailService.sendReminder); + }); + + it('continues processing the batch when one gift fails', async function () { + // Gift 1 will fail at the email stage; gift 2 should still be processed. + const gift1 = buildRedeemedGift({token: 'gift-1', redeemerMemberId: 'member_1'}); + const gift2 = buildRedeemedGift({token: 'gift-2', redeemerMemberId: 'member_2'}); + + giftRepository.findPendingReminder.resolves([gift1, gift2]); + + // getByToken resolves regardless of whether the lock options are passed. + giftRepository.getByToken + .withArgs('gift-1').resolves(gift1) + .withArgs('gift-1', sinon.match.any).resolves(gift1) + .withArgs('gift-2').resolves(gift2) + .withArgs('gift-2', sinon.match.any).resolves(gift2); + + memberRepository.get + .withArgs({id: 'member_1'}, sinon.match.any).resolves(buildRedeemer('member_1')) + .withArgs({id: 'member_2'}, sinon.match.any).resolves(buildRedeemer('member_2')); + + giftEmailService.sendReminder + .onFirstCall().rejects(new Error('Transient SMTP error')) + .onSecondCall().resolves(undefined); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 1); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 1); + + // Both gifts were claimed (marked as reminded inside their transactions), + // and both emails were attempted. + assert.equal(giftRepository.update.callCount, 2); + assert.equal(giftEmailService.sendReminder.callCount, 2); + }); + + it('handles multiple gifts independently', async function () { + const gift1 = buildRedeemedGift({token: 'gift-1', redeemerMemberId: 'member_1'}); + const gift2 = buildRedeemedGift({token: 'gift-2', redeemerMemberId: 'member_2'}); + + giftRepository.findPendingReminder.resolves([gift1, gift2]); + giftRepository.getByToken + .withArgs('gift-1').resolves(gift1) + .withArgs('gift-1', sinon.match.any).resolves(gift1) + .withArgs('gift-2').resolves(gift2) + .withArgs('gift-2', sinon.match.any).resolves(gift2); + + memberRepository.get + .withArgs({id: 'member_1'}, sinon.match.any).resolves(buildRedeemer('member_1')) + .withArgs({id: 'member_2'}, sinon.match.any).resolves(buildRedeemer('member_2')); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 2); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 0); + assert.equal(giftEmailService.sendReminder.callCount, 2); + assert.equal(giftRepository.update.callCount, 2); + }); + }); + describe('redeem', function () { it('redeems the gift, saves it, and grants gift access to the member', async function () { const gift = buildGift(); diff --git a/ghost/core/test/unit/server/services/gifts/gift.test.ts b/ghost/core/test/unit/server/services/gifts/gift.test.ts index 4ece9c11e3a..1725257519d 100644 --- a/ghost/core/test/unit/server/services/gifts/gift.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts @@ -51,6 +51,7 @@ describe('Gift', function () { assert.equal(gift.consumedAt, null); assert.equal(gift.expiredAt, null); assert.equal(gift.refundedAt, null); + assert.equal(gift.consumesSoonReminderSentAt, null); }); it('passes through purchase data', function () { @@ -310,4 +311,41 @@ describe('Gift', function () { assert.equal(result, null); }); }); + + describe('remind', function () { + it('returns a gift with consumesSoonReminderSentAt set without mutating the original', function () { + const gift = buildGift({ + status: 'redeemed', + redeemerMemberId: 'member_2', + redeemedAt: new Date('2026-04-11T12:00:00.000Z'), + consumesAt: new Date('2027-04-11T12:00:00.000Z') + }); + const before = new Date(); + + const reminded = gift.remind(); + + const after = new Date(); + + assert.ok(reminded); + assert.notEqual(reminded, gift); + assert.equal(gift.consumesSoonReminderSentAt, null); + assert.ok(reminded.consumesSoonReminderSentAt); + assert.ok(reminded.consumesSoonReminderSentAt >= before); + assert.ok(reminded.consumesSoonReminderSentAt <= after); + }); + + it('returns null if already reminded', function () { + const gift = buildGift({ + status: 'redeemed', + redeemerMemberId: 'member_2', + redeemedAt: new Date('2026-04-11T12:00:00.000Z'), + consumesAt: new Date('2027-04-11T12:00:00.000Z'), + consumesSoonReminderSentAt: new Date('2027-04-01T12:00:00.000Z') + }); + + const result = gift.remind(); + + assert.equal(result, null); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/gifts/utils.ts b/ghost/core/test/unit/server/services/gifts/utils.ts index de8e333ffba..d45d0866dfe 100644 --- a/ghost/core/test/unit/server/services/gifts/utils.ts +++ b/ghost/core/test/unit/server/services/gifts/utils.ts @@ -21,6 +21,7 @@ export function buildGift(overrides: Partial[ consumedAt: null, expiredAt: null, refundedAt: null, + consumesSoonReminderSentAt: null, ...overrides }); } diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 47bcf4f7606..6242378d910 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -535,4 +535,127 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, null); }); }); + + describe('getGiftRedemptionEvents', function () { + let eventRepository; + let fake; + + before(function () { + fake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'gift123', + redeemer_member_id: 'member789', + redeemer: {id: 'member789', name: 'Test Redeemer', email: 'redeemer@example.com'}, + tier: {name: 'Gold'}, + amount: 5000, + currency: 'usd', + cadence: 'year', + duration: 1, + redeemed_at: '2024-08-20T09:30:00.000Z', + token: 'secret-token', + stripe_checkout_session_id: 'cs_123', + stripe_payment_intent_id: 'pi_123', + status: 'redeemed' + }) + }]}); + eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: null, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null, + Gift: { + findPage: fake + } + }); + }); + + afterEach(function () { + fake.resetHistory(); + }); + + it('queries with correct options', async function () { + await eventRepository.getGiftRedemptionEvents({ + filter: 'not used', + order: 'created_at desc, id desc' + }, { + type: 'unused' + }); + + sinon.assert.calledOnceWithMatch(fake, { + withRelated: ['redeemer', 'tier'], + filter: 'redeemer_member_id:-null+custom:true', + order: 'redeemed_at desc, id desc' + }); + }); + + it('returns correctly formatted gift_redemption_event', async function () { + const result = await eventRepository.getGiftRedemptionEvents({ + order: 'created_at desc, id desc' + }, {}); + + assert.equal(result.data.length, 1); + + const event = result.data[0]; + + assert.equal(event.type, 'gift_redemption_event'); + assert.equal(event.data.id, 'gift123'); + assert.equal(event.data.amount, 5000); + assert.equal(event.data.currency, 'usd'); + assert.equal(event.data.tier_name, 'Gold'); + assert.equal(event.data.cadence, 'year'); + assert.equal(event.data.duration, 1); + assert.equal(event.data.member_id, 'member789'); + assert.equal(event.data.created_at, '2024-08-20T09:30:00.000Z'); + assert.deepEqual(event.data.member, { + id: 'member789', + name: 'Test Redeemer', + email: 'redeemer@example.com' + }); + }); + + it('excludes internal fields from event data', async function () { + const result = await eventRepository.getGiftRedemptionEvents({}, {}); + + const event = result.data[0]; + + assert.equal(event.data.token, undefined); + assert.equal(event.data.stripe_checkout_session_id, undefined); + assert.equal(event.data.stripe_payment_intent_id, undefined); + assert.equal(event.data.status, undefined); + }); + + it('sets member to null when redeemer is not present', async function () { + const nullRedeemerFake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'gift999', + redeemer_member_id: null, + redeemer: null, + amount: 3000, + currency: 'eur', + redeemed_at: '2024-09-01T12:00:00.000Z' + }) + }]}); + const repo = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: null, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null, + Gift: { + findPage: nullRedeemerFake + } + }); + + const result = await repo.getGiftRedemptionEvents({}, {}); + const event = result.data[0]; + + assert.equal(event.data.member, null); + assert.equal(event.data.member_id, null); + }); + }); }); diff --git a/ghost/core/test/unit/server/web/gift-preview/controller.test.js b/ghost/core/test/unit/server/web/gift-preview/controller.test.js new file mode 100644 index 00000000000..883cc7dc510 --- /dev/null +++ b/ghost/core/test/unit/server/web/gift-preview/controller.test.js @@ -0,0 +1,220 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); + +const labs = require('../../../../../core/shared/labs'); +const urlUtils = require('../../../../../core/shared/url-utils'); +const settingsCache = require('../../../../../core/shared/settings-cache'); +const giftServiceWrapper = require('../../../../../core/server/services/gifts'); +const tiersService = require('../../../../../core/server/services/tiers'); + +const controller = require('../../../../../core/server/web/gift-preview/controller'); + +describe('Gift Preview Controller', function () { + let req; + let res; + let originalTiersServiceAPI; + let originalGiftService; + + beforeEach(function () { + req = { + params: { + token: 'test-token-123' + } + }; + res = { + redirect: sinon.stub(), + send: sinon.stub(), + sendStatus: sinon.stub(), + set: sinon.stub() + }; + originalTiersServiceAPI = tiersService.api; + originalGiftService = giftServiceWrapper.service; + + sinon.stub(urlUtils, 'getSiteUrl').returns('https://example.com/'); + sinon.stub(settingsCache, 'get'); + settingsCache.get.withArgs('title').returns('Test Blog'); + settingsCache.get.withArgs('accent_color').returns('#FF5733'); + }); + + afterEach(function () { + tiersService.api = originalTiersServiceAPI; + giftServiceWrapper.service = originalGiftService; + + sinon.restore(); + }); + + describe('giftPreview', function () { + it('redirects to homepage when lab flag is disabled', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(false); + + await controller.giftPreview(req, res); + + sinon.assert.calledOnce(res.redirect); + sinon.assert.calledWith(res.redirect, 302, 'https://example.com/'); + }); + + it('redirects to homepage when gift token is invalid', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().rejects(new Error('Not found')) + }; + + await controller.giftPreview(req, res); + + sinon.assert.calledOnce(res.redirect); + sinon.assert.calledWith(res.redirect, 302, 'https://example.com/'); + }); + + it('redirects to homepage when tier lookup fails', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'year', + duration: 1 + }) + }; + tiersService.api = { + read: sinon.stub().rejects(new Error('Tier not found')) + }; + + await controller.giftPreview(req, res); + + sinon.assert.calledOnce(res.redirect); + sinon.assert.calledWith(res.redirect, 302, 'https://example.com/'); + }); + + it('returns HTML with OG tags for a valid gift', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'year', + duration: 1 + }) + }; + tiersService.api = { + read: sinon.stub().resolves({name: 'Gold'}) + }; + + await controller.giftPreview(req, res); + + sinon.assert.calledWith(res.set, 'Cache-Control', 'public, max-age=3600'); + sinon.assert.calledWith(res.set, 'Content-Type', 'text/html; charset=utf-8'); + sinon.assert.calledOnce(res.send); + + const html = res.send.firstCall.args[0]; + + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('content="0;url=https://example.com/#/portal/gift/redeem/test-token-123"')); + }); + + it('escapes HTML in site title', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + settingsCache.get.withArgs('title').returns('Blog '); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'month', + duration: 3 + }) + }; + tiersService.api = { + read: sinon.stub().resolves({name: 'Silver'}) + }; + + await controller.giftPreview(req, res); + + const html = res.send.firstCall.args[0]; + + assert.ok(!html.includes('')); + assert.ok(html.includes('<script>alert("xss")</script>')); + }); + + it('uses monthly cadence label', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'month', + duration: 3 + }) + }; + tiersService.api = { + read: sinon.stub().resolves({name: 'Bronze'}) + }; + + await controller.giftPreview(req, res); + + const html = res.send.firstCall.args[0]; + + assert.ok(html.includes('Bronze \u00B7 3 months')); + }); + + it('defaults site title to Ghost', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + settingsCache.get.withArgs('title').returns(null); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'year', + duration: 1 + }) + }; + tiersService.api = { + read: sinon.stub().resolves({name: 'Premium'}) + }; + + await controller.giftPreview(req, res); + + const html = res.send.firstCall.args[0]; + + assert.ok(html.includes('A gift membership to Ghost')); + }); + }); + + describe('giftPreviewImage', function () { + it('returns 404 when lab flag is disabled', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(false); + + await controller.giftPreviewImage(req, res); + + sinon.assert.calledOnce(res.sendStatus); + sinon.assert.calledWith(res.sendStatus, 404); + }); + + it('returns 404 when gift token is invalid', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().rejects(new Error('Not found')) + }; + + await controller.giftPreviewImage(req, res); + + sinon.assert.calledOnce(res.sendStatus); + sinon.assert.calledWith(res.sendStatus, 404); + }); + + it('returns 404 when tier lookup fails', async function () { + sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true); + giftServiceWrapper.service = { + getByToken: sinon.stub().resolves({ + tierId: 'tier_1', + cadence: 'year', + duration: 1 + }) + }; + tiersService.api = { + read: sinon.stub().rejects(new Error('Tier not found')) + }; + + await controller.giftPreviewImage(req, res); + + sinon.assert.calledOnce(res.sendStatus); + sinon.assert.calledWith(res.sendStatus, 404); + }); + }); +}); diff --git a/ghost/core/test/unit/server/web/gift-preview/image.test.js b/ghost/core/test/unit/server/web/gift-preview/image.test.js new file mode 100644 index 00000000000..bd83fe5ecab --- /dev/null +++ b/ghost/core/test/unit/server/web/gift-preview/image.test.js @@ -0,0 +1,57 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const imageModule = require('../../../../../core/server/web/gift-preview/image'); + +describe('Gift Preview Image', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('generateGiftPreviewImage', function () { + it('generates a PNG buffer', async function () { + const result = await imageModule.generateGiftPreviewImage({ + tierName: 'Gold', + cadenceLabel: '1 year', + accentColor: '#FF5733' + }); + + assert.ok(Buffer.isBuffer(result)); + assert.ok(result.length > 0); + + // PNG magic bytes + assert.equal(result[0], 0x89); + assert.equal(result[1], 0x50); // P + assert.equal(result[2], 0x4E); // N + assert.equal(result[3], 0x47); // G + }); + + it('returns cached result on second call with same params', async function () { + const params = { + tierName: 'CacheTest', + cadenceLabel: '1 year', + accentColor: '#000000' + }; + + const first = await imageModule.generateGiftPreviewImage(params); + const second = await imageModule.generateGiftPreviewImage(params); + + assert.equal(first, second, 'Should return the exact same buffer reference from cache'); + }); + + it('returns different results for different params', async function () { + const result1 = await imageModule.generateGiftPreviewImage({ + tierName: 'Gold', + cadenceLabel: '1 year', + accentColor: '#FF5733' + }); + + const result2 = await imageModule.generateGiftPreviewImage({ + tierName: 'Silver', + cadenceLabel: '3 months', + accentColor: '#333333' + }); + + assert.notEqual(result1, result2); + }); + }); +}); diff --git a/ghost/parse-email-address/package.json b/ghost/parse-email-address/package.json index 6ad4b8c23f9..5f3a717b353 100644 --- a/ghost/parse-email-address/package.json +++ b/ghost/parse-email-address/package.json @@ -35,5 +35,12 @@ }, "dependencies": { "parse-email-address": "0.0.2" + }, + "nx": { + "targets": { + "build": { + "outputs": ["{projectRoot}/build"] + } + } } }