diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts index 61e555cc347..22d1901cd65 100644 --- a/ghost/core/core/server/services/automations/automations-api.ts +++ b/ghost/core/core/server/services/automations/automations-api.ts @@ -6,6 +6,7 @@ import type {DatabaseSync} from 'node:sqlite'; import {z} from 'zod'; import {createFakeDatabaseAutomationsRepository} from './fake-database-automations-repository'; import type { + AutomationsRepository, EditAutomationData } from './automations-repository'; @@ -238,6 +239,29 @@ export function requestPoll() { domainEvents.dispatch(StartAutomationsPollEvent.create()); } +type TriggerOptions = Parameters[0] & { + event: 'member_sign_up'; +}; +export async function trigger(options: TriggerOptions) { + if (options.event !== 'member_sign_up') { + throw new errors.IncorrectUsageError({ + message: 'Member signup is the only supported event right now. More may be added later' + }); + } + + const shouldTrigger = ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV?.startsWith('testing') + ); + if (!shouldTrigger) { + return; + } + + await repository.trigger(options); + + requestPoll(); +} + export function _resetTestDatabase() { if (process.env.NODE_ENV?.startsWith('testing')) { testDatabase = null; diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index 6e5b16e64d4..bc9e3a184b9 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -66,4 +66,9 @@ export interface AutomationsRepository { browse(): Promise>; getById(id: string): Promise; edit(id: string, data: EditAutomationData): Promise; + trigger(options: { + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; + }): Promise; } diff --git a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts index d4b2e7dc996..9f2d9a42114 100644 --- a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts @@ -2,6 +2,7 @@ import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; import ObjectId from 'bson-objectid'; import type {DatabaseSync} from 'node:sqlite'; +import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; import type { Automation, AutomationAction, @@ -12,6 +13,8 @@ import type { Page } from './automations-repository'; +const HOUR_MS = 60 * 60 * 1000; + const messages = { invalidAutomationActionRevision: 'Automation action "{actionId}" of type "{actionType}" is missing required revision field "{field}".', conflictingAutomationActionId: 'Automation action "{actionId}" already exists and cannot be inserted.', @@ -44,6 +47,14 @@ interface EdgeRow { target_action_id: string; } +type NextActionRevisionRow = { + automation_id: string; + action_id: string; + automation_action_revision_id: string; + type: 'wait' | 'send_email'; + wait_hours: number | null; +}; + export function createFakeDatabaseAutomationsRepository({ getDatabase }: { @@ -98,6 +109,16 @@ export function createFakeDatabaseAutomationsRepository({ return buildAutomation(database, updatedAutomation); }); + }, + + async trigger(options: { + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; + }): Promise { + const database = getDatabase(); + + return withTransaction(database, () => trigger(database, options)); } }; } @@ -115,6 +136,112 @@ function withTransaction(database: DatabaseSync, operation: () => T): T { } } +function trigger(database: DatabaseSync, { + memberEmail, + memberId, + memberStatus +}: Readonly<{ + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; +}>): void { + const firstAction = findFirstActionRevision(database, memberStatus); + if (!firstAction) { + return; + } + + const now = new Date(); + const nowString = now.toISOString(); + + const readyAt = getReadyAtForAction(firstAction, now); + + const run = { + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_id: firstAction.automation_id, + member_id: memberId, + member_email: memberEmail + }; + + database.prepare(` + INSERT INTO automation_runs + (id, created_at, updated_at, automation_id, member_id, member_email) VALUES + (:id, :created_at, :updated_at, :automation_id, :member_id, :member_email) + `).run(run); + database.prepare(` + INSERT INTO automation_run_steps + (id, created_at, updated_at, automation_run_id, automation_action_revision_id, ready_at) VALUES + (:id, :created_at, :updated_at, :automation_run_id, :automation_action_revision_id, :ready_at) + `).run({ + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_run_id: run.id, + automation_action_revision_id: firstAction.automation_action_revision_id, + ready_at: readyAt.toISOString() + }); +} + +function findFirstActionRevision(database: DatabaseSync, memberStatus: 'free' | 'paid'): NextActionRevisionRow | null { + const automationSlug: NonNullable = MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]; + + const row = database.prepare(` + SELECT + automation.id AS automation_id, + actions.id AS action_id, + revisions.id AS automation_action_revision_id, + actions.type AS type, + revisions.wait_hours AS wait_hours + FROM automations automation + INNER JOIN automation_actions actions ON actions.automation_id = automation.id + INNER JOIN automation_action_revisions revisions ON revisions.action_id = actions.id + WHERE automation.slug = ? + AND automation.status = 'active' + AND actions.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM automation_action_edges edge + INNER JOIN automation_actions source_actions ON source_actions.id = edge.source_action_id + AND source_actions.deleted_at IS NULL + WHERE edge.target_action_id = actions.id + ) + AND revisions.created_at = ( + SELECT MAX(created_at) + FROM automation_action_revisions + WHERE action_id = actions.id + ) + ORDER BY actions.created_at, actions.id + LIMIT 1 + `).get(automationSlug) as NextActionRevisionRow | undefined; + + return row ?? null; +} + +function getReadyAtForAction( + action: Pick, + now: Readonly +): Date { + switch (action.type) { + case 'wait': { + const waitHours = requireValue({ + ...action, + id: action.action_id + }, 'wait_hours'); + const waitMs = waitHours * HOUR_MS; + return new Date(now.getTime() + waitMs); + } + case 'send_email': + return now; + default: { + const _exhaustive: never = action.type; + throw new errors.IncorrectUsageError({ + message: `Unexpected action type ${_exhaustive}` + }); + } + } +} + function loadAutomation(database: DatabaseSync, automationId: string): AutomationRow | null { const automation = database.prepare(` SELECT id, slug, name, status, created_at, updated_at diff --git a/ghost/core/core/server/services/automations/temporary-fake-database.js b/ghost/core/core/server/services/automations/temporary-fake-database.ts similarity index 92% rename from ghost/core/core/server/services/automations/temporary-fake-database.js rename to ghost/core/core/server/services/automations/temporary-fake-database.ts index 0d09019a6ba..b1c3f96b3c1 100644 --- a/ghost/core/core/server/services/automations/temporary-fake-database.js +++ b/ghost/core/core/server/services/automations/temporary-fake-database.ts @@ -10,16 +10,16 @@ * migration once we're sure this schema is correct. */ -const errors = require('@tryghost/errors'); -const ObjectId = require('bson-objectid').default; +import * as errors from '@tryghost/errors'; +import ObjectId from 'bson-objectid'; +import type {DatabaseSync} from 'node:sqlite'; -/** - * @returns {import('node:sqlite').DatabaseSync} - */ -function createTemporaryFakeAutomationsDatabase() { - const {DatabaseSync} = require('node:sqlite'); +export function createTemporaryFakeAutomationsDatabase(): DatabaseSync { + // We want to do this import dynamically. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sqlite = require('node:sqlite'); - const database = new DatabaseSync(':memory:'); + const database = new sqlite.DatabaseSync(':memory:'); database.exec('PRAGMA foreign_keys = ON;'); const id = () => ObjectId().toHexString(); @@ -98,10 +98,10 @@ CREATE TABLE automation_run_steps ( automation_run_id TEXT NOT NULL REFERENCES automation_runs(id), automation_action_revision_id TEXT NOT NULL REFERENCES automation_action_revisions(id), ready_at TEXT NOT NULL, - step_attempts INTEGER NOT NULL, + step_attempts INTEGER NOT NULL DEFAULT 0, started_at TEXT, finished_at TEXT, - status TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', locked_by TEXT, locked_at TEXT ) STRICT; @@ -312,13 +312,9 @@ CREATE TABLE automation_run_steps ( return database; } -/** @type {null | import('node:sqlite').DatabaseSync} */ -let cachedDatabase = null; +let cachedDatabase: DatabaseSync | null = null; -/** - * @returns {import('node:sqlite').DatabaseSync} - */ -function getTemporaryFakeAutomationsDatabase() { +export function getTemporaryFakeAutomationsDatabase(): DatabaseSync { if (process.env.NODE_ENV !== 'development') { throw new errors.IncorrectUsageError({ message: 'Fake automations database should only be used in development' @@ -326,7 +322,4 @@ function getTemporaryFakeAutomationsDatabase() { } cachedDatabase ??= createTemporaryFakeAutomationsDatabase(); return cachedDatabase; -} - -exports.createTemporaryFakeAutomationsDatabase = createTemporaryFakeAutomationsDatabase; -exports.getTemporaryFakeAutomationsDatabase = getTemporaryFakeAutomationsDatabase; +} \ No newline at end of file diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 5b616ddd3b4..33aa8441eae 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -6,7 +6,6 @@ import type {GiftRepository} from './gift-repository'; import type {GiftReminderScheduler} from './gift-reminder-scheduler'; import tpl from '@tryghost/tpl'; import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; -import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; const MS_PER_DAY = 24 * 60 * 60 * 1000; const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; @@ -39,7 +38,12 @@ interface MemberModel { interface MemberRepository { get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; - enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record): Promise; + triggerMemberSignupAutomation( + memberId: string, + memberEmail: string, + memberStatus: 'free' | 'paid', + options?: Record + ): Promise; } type Tier = { @@ -307,7 +311,12 @@ export class GiftService { await this.deps.giftRepository.update(redeemed, {transacting}); // Gift members receive the paid welcome email, as they receive access to paid content - await this.deps.memberRepository.enqueueWelcomeEmailRun(memberId, MEMBER_WELCOME_EMAIL_SLUGS.paid, {transacting}); + await this.deps.memberRepository.triggerMemberSignupAutomation( + memberId, + member.get('email'), + 'paid', + {transacting} + ); return {redeemed, member}; }; diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js index 4ab360f1625..25f663111cf 100644 --- a/ghost/core/core/server/services/members/members-api/members-api.js +++ b/ghost/core/core/server/services/members/members-api/members-api.js @@ -19,6 +19,7 @@ const WellKnownController = require('./controllers/well-known-controller'); const {EmailSuppressedEvent} = require('../../email-suppression-list/email-suppression-list'); const MagicLink = require('../../lib/magic-link/magic-link'); const DomainEvents = require('@tryghost/domain-events'); +const automationsApi = require('../../automations/automations-api'); module.exports = function MembersAPI({ tokenConfig: { @@ -104,6 +105,7 @@ module.exports = function MembersAPI({ tokenService, newslettersService, productRepository, + automationsApi, Automation, WelcomeEmailAutomationRun, Member, diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index a3dfbdfa419..bec03ddfef0 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -11,6 +11,7 @@ const crypto = require('crypto'); const hasActiveOffer = require('../utils/has-active-offer'); const StartAutomationsPollEvent = require('../../../automations/events/start-automations-poll-event'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants'); +/** @import * as automationsApi from '../../../automations/automations-api' */ const messages = { noStripeConnection: 'Cannot {action} without a Stripe Connection', @@ -66,6 +67,7 @@ module.exports = class MemberRepository { * @param {any} deps.offersAPI * @param {ITokenService} deps.tokenService * @param {any} deps.newslettersService + * @param {Pick} deps.automationsApi * @param {any} deps.Automation * @param {any} deps.WelcomeEmailAutomationRun */ @@ -87,6 +89,7 @@ module.exports = class MemberRepository { offersAPI, tokenService, newslettersService, + automationsApi, Automation, WelcomeEmailAutomationRun }) { @@ -107,6 +110,7 @@ module.exports = class MemberRepository { this._offersAPI = offersAPI; this.tokenService = tokenService; this._newslettersService = newslettersService; + this._automationsApi = automationsApi; this._Automation = Automation; this._WelcomeEmailAutomationRun = WelcomeEmailAutomationRun; @@ -173,28 +177,40 @@ module.exports = class MemberRepository { return nickname && nickname.toLowerCase() === 'complimentary'; } + /** + * @param {string} memberId + * @param {string} memberEmail + * @param {'free' | 'paid'} memberStatus + * @returns {Promise} + */ + async #triggerMemberSignupAutomation(memberId, memberEmail, memberStatus) { + // TODO(NY-1311) When moving to real tables, we should insert the new + // rows in a new transaction. + await this._automationsApi.trigger({ + event: 'member_sign_up', + memberId, + memberEmail, + memberStatus + }); + } + /** * Looks up the active welcome email automation for the given slug and enqueues a * `WelcomeEmailAutomationRun` for the member. Dispatches `StartAutomationsPollEvent` - * so the poll picks it up. Returns the created run, or null if there is no active - * automation/email for that slug. - * - * Callers are responsible for any eligibility gating (member status, source, etc.) - * before calling this — this helper just looks up + inserts + dispatches. Pass - * `options.transacting` to run the insert inside an existing transaction; the - * dispatch is automatically deferred until that transaction commits. + * so the poll picks it up. * * @param {string} memberId - * @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS - * @param {object} [options] bookshelf options (transacting, context, etc.) + * @param {'free' | 'paid'} memberStatus + * @param {object} options + * @returns {Promise} */ - async enqueueWelcomeEmailRun(memberId, slug, options = {}) { + async #triggerMemberSignupLegacyAutomation(memberId, memberStatus, options) { if (!this._Automation || !this._WelcomeEmailAutomationRun) { - return null; + return; } const automation = await this._Automation.findOne( - {slug}, + {slug: MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]}, {...options, withRelated: ['welcomeEmailAutomatedEmail']} ); const email = automation?.related('welcomeEmailAutomatedEmail'); @@ -206,10 +222,10 @@ module.exports = class MemberRepository { ); if (!isActive) { - return null; + return; } - const run = await this._WelcomeEmailAutomationRun.add({ + await this._WelcomeEmailAutomationRun.add({ welcome_email_automation_id: automation.id, member_id: memberId, next_welcome_email_automated_email_id: email.id, @@ -220,8 +236,25 @@ module.exports = class MemberRepository { }, options); this.dispatchEvent(StartAutomationsPollEvent.create(), options); + } - return run; + /** + * Trigger an automation for member signup. + * + * Callers are responsible for any eligibility gating (member status, source, etc.) + * before calling this. + * + * @param {string} memberId + * @param {string} memberEmail + * @param {'free' | 'paid'} memberStatus + * @param {object} bookshelfOptions + * @returns {Promise} + */ + async triggerMemberSignupAutomation(memberId, memberEmail, memberStatus, bookshelfOptions) { + await Promise.all([ + this.#triggerMemberSignupAutomation(memberId, memberEmail, memberStatus), + this.#triggerMemberSignupLegacyAutomation(memberId, memberStatus, bookshelfOptions) + ]); } /** @@ -437,7 +470,12 @@ module.exports = class MemberRepository { labels }, {...memberAddOptions, transacting}); - await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting}); + await this.triggerMemberSignupAutomation( + newMember.id, + newMember.get('email'), + 'free', + {transacting} + ); return newMember; }; @@ -1527,8 +1565,8 @@ module.exports = class MemberRepository { const context = options?.context || {}; const source = this._resolveContextSource(context); - // Enqueue paid welcome email if: - // 1. The source is allowed to send welcome emails + // Enqueue automation if: + // 1. The source is allowed to trigger automations // 2. The member status changed to 'paid' // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption if ( @@ -1536,7 +1574,12 @@ module.exports = class MemberRepository { updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' ) { - await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options); + await this.triggerMemberSignupAutomation( + memberModel.id, + memberModel.get('email'), + 'paid', + options + ); } } } diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts new file mode 100644 index 00000000000..1c2e5312398 --- /dev/null +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import {AutomationsRepository} from '../../../../../core/server/services/automations/automations-repository'; +import {createTemporaryFakeAutomationsDatabase} from '../../../../../core/server/services/automations/temporary-fake-database'; +import {createFakeDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/fake-database-automations-repository'; +import type {DatabaseSync, SQLInputValue} from 'node:sqlite'; + +const addHours = (dateCol: unknown, hours: number): Date => { + assert(typeof dateCol === 'string', 'Expected date column to be a string'); + const start = new Date(dateCol).valueOf(); + const delta = hours * 60 * 60 * 1000; + return new Date(start + delta); +}; + +// These tests are partly coupled to the *fake* repository. We should be able to +// modify it once we have the real repository. +describe('automations repository', function () { + let database: DatabaseSync; + let repo: AutomationsRepository; + + const getRunByMemberEmail = (email: string) => ( + database!.prepare(` + SELECT + automation_runs.*, + automations.slug AS automation_slug + FROM automation_runs + INNER JOIN automations ON automations.id = automation_runs.automation_id + WHERE automation_runs.member_email = ? + `).get(email) + ); + + const getStepByRunId = (runId: SQLInputValue) => ( + database!.prepare(` + SELECT + automation_run_steps.*, + automation_actions.id AS action_id, + automation_actions.type AS action_type, + automation_action_revisions.wait_hours AS wait_hours, + automation_action_revisions.email_subject AS email_subject + FROM automation_run_steps + INNER JOIN automation_action_revisions ON automation_action_revisions.id = automation_run_steps.automation_action_revision_id + INNER JOIN automation_actions ON automation_actions.id = automation_action_revisions.action_id + WHERE automation_run_steps.automation_run_id = ? + `).get(runId) + ); + + const getAutomationBySlug = async (slug: string) => { + const automationSummaries = await repo.browse(); + const automationSummary = automationSummaries.data.find(automation => automation.slug === slug); + assert(automationSummary); + const automation = await repo.getById(automationSummary.id); + assert(automation); + return automation; + }; + + const getRunCountByAutomationId = (automationId: SQLInputValue) => { + const result = database!.prepare(` + SELECT COUNT(*) AS count + FROM automation_runs + WHERE automation_id = ? + `).get(automationId); + return result?.count; + }; + + beforeEach(function () { + database = createTemporaryFakeAutomationsDatabase(); + repo = createFakeDatabaseAutomationsRepository({ + getDatabase: () => database + }); + }); + + afterEach(function () { + database.close(); + }); + + describe('trigger', function () { + it('can trigger an automation for a free member', async function () { + await repo.trigger({ + memberEmail: 'free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + const run = getRunByMemberEmail('free@example.com'); + assert(run); + assert.equal(run.member_email, 'free@example.com'); + assert.equal(run.member_id, 'member_123'); + assert.equal(run.automation_slug, 'member-welcome-email-free'); + assert.equal(run.created_at, run.updated_at); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.automation_run_id, run.id); + assert.equal(step.action_type, 'wait'); + assert.equal(step.wait_hours, 48); + assert.equal(step.created_at, run.created_at); + assert.equal(step.updated_at, run.updated_at); + assert.equal(step.ready_at, addHours(run.created_at, 48).toISOString()); + assert.equal(step.step_attempts, 0); + assert.equal(step.started_at, null); + assert.equal(step.finished_at, null); + assert.equal(step.status, 'pending'); + assert.equal(step.locked_by, null); + assert.equal(step.locked_at, null); + }); + + it('can trigger an automation for a paid member', async function () { + await repo.trigger({ + memberEmail: 'paid@example.com', + memberId: 'member_123', + memberStatus: 'paid' + }); + + const run = getRunByMemberEmail('paid@example.com'); + assert(run); + assert.equal(run.automation_slug, 'member-welcome-email-paid'); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.automation_run_id, run.id); + assert.equal(step.action_type, 'wait'); + }); + + it('inserts the first non-deleted step', async function () { + const automation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(automation.id, { + status: 'active', + actions: [ + { + id: 'wait-action-to-delete', + type: 'wait', + data: {wait_hours: 72} + }, + { + id: 'main-wait-action', + type: 'wait', + data: {wait_hours: 24} + } + ], + edges: [{ + source_action_id: 'wait-action-to-delete', + target_action_id: 'main-wait-action' + }] + }); + await repo.edit(automation.id, { + status: 'active', + actions: [ + { + id: 'main-wait-action', + type: 'wait', + data: {wait_hours: 24} + } + ], + edges: [] + }); + + await repo.trigger({ + memberEmail: 'free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + const run = getRunByMemberEmail('free@example.com'); + assert(run); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.action_id, 'main-wait-action'); + }); + + it('does not trigger an automation for an inactive automation', async function () { + const freeAutomation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(freeAutomation.id, { + ...freeAutomation, + status: 'inactive' + }); + + await repo.trigger({ + memberEmail: 'inactive-free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + assert.equal(getRunByMemberEmail('inactive-free@example.com'), undefined); + assert.equal(getRunCountByAutomationId(freeAutomation.id), 0); + }); + + it('does not trigger an automation for an automation with no actions', async function () { + const freeAutomation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(freeAutomation.id, { + status: 'active', + actions: [], + edges: [] + }); + + await repo.trigger({ + memberEmail: 'free-no-actions@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + assert.equal(getRunByMemberEmail('free-no-actions@example.com'), undefined); + assert.equal(getRunCountByAutomationId(freeAutomation.id), 0); + }); + }); +}); 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 fc41f3a64bf..352352bedb9 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 @@ -48,7 +48,7 @@ describe('GiftService', function () { let memberRepository: { get: sinon.SinonStub; update: sinon.SinonStub; - enqueueWelcomeEmailRun: sinon.SinonStub; + triggerMemberSignupAutomation: sinon.SinonStub; }; let staffServiceEmails: { notifyGiftPurchased: sinon.SinonStub; @@ -101,7 +101,7 @@ describe('GiftService', function () { return Promise.resolve({id: 'member_1', get: memberGet}); }), update: sinon.stub().resolves(undefined), - enqueueWelcomeEmailRun: sinon.stub().resolves(undefined) + triggerMemberSignupAutomation: sinon.stub().resolves(undefined) }; staffServiceEmails = { notifyGiftPurchased: sinon.stub(), @@ -1265,7 +1265,7 @@ describe('GiftService', function () { sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted); }); - it('enqueues the paid welcome email run for a new gift signup', async function () { + it('triggers the paid member signup automation for a new gift signup', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('gift'); @@ -1279,14 +1279,15 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1', {newMember: true}); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: 'trx'} ); }); - it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () { + it('triggers the paid member signup automation when an existing free member redeems a gift', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('free'); @@ -1300,14 +1301,15 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1'); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: 'trx'} ); }); - it('passes the external transaction through to the welcome email enqueue', async function () { + it('passes the external transaction through to the member signup automation trigger', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('free'); @@ -1322,9 +1324,10 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: externalTrx} ); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index 23832589119..96383dbaf6c 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -22,6 +22,7 @@ describe('MemberRepository', function () { let WelcomeEmailAutomationRun; let newslettersService; let offersAPI; + let automationsApi; let productRepository; let stripeAPIService; let tokenService; @@ -47,6 +48,7 @@ describe('MemberRepository', function () { WelcomeEmailAutomationRun, newslettersService, offersAPI, + automationsApi, productRepository, stripeAPIService, tokenService, @@ -164,6 +166,10 @@ describe('MemberRepository', function () { }) }; + automationsApi = { + trigger: sinon.stub().resolves() + }; + productRepository = { get: sinon.stub().resolves({ get: sinon.stub().returns(), @@ -1704,150 +1710,164 @@ describe('MemberRepository', function () { }); }); - describe('create - automation run integration', function () { - it('creates automation run for free member signup (free welcome email)', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); - + describe('create - automation integration', function () { + it('triggers an automation event for free signup', async function () { + const repo = buildRepo(); await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); - const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; - assert.equal(runCall.welcome_email_automation_id, 'automation_id_free'); - assert.equal(runCall.member_id, 'member_id_123'); - assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_free'); - assert.ok(runCall.ready_at); - assert.equal(runCall.step_started_at, null); - assert.equal(runCall.step_attempts, 0); - assert.equal(runCall.exit_reason, null); + sinon.assert.calledOnceWithExactly(automationsApi.trigger, { + event: 'member_sign_up', + memberId: 'member_id_123', + memberEmail: 'test@example.com', + memberStatus: 'free' + }); }); - it('does not create automation run for disallowed sources', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); + describe('legacy automations', function () { + it('creates automation run for free member signup (free welcome email)', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - const disallowedSources = [ - {name: 'import', context: {import: true}}, - {name: 'admin', context: {user: true}}, - {name: 'api', context: {api_key: true}} - ]; + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + + sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); + const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; + assert.equal(runCall.welcome_email_automation_id, 'automation_id_free'); + assert.equal(runCall.member_id, 'member_id_123'); + assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_free'); + assert.ok(runCall.ready_at); + assert.equal(runCall.step_started_at, null); + assert.equal(runCall.step_attempts, 0); + assert.equal(runCall.exit_reason, null); + }); + + it('does not create automation run for disallowed sources', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - for (const source of disallowedSources) { - WelcomeEmailAutomationRun.add.resetHistory(); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {context: source.context}); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - } - }); + const disallowedSources = [ + {name: 'import', context: {import: true}}, + {name: 'admin', context: {user: true}}, + {name: 'api', context: {api_key: true}} + ]; - it('passes transaction to automation run creation', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption + for (const source of disallowedSources) { + WelcomeEmailAutomationRun.add.resetHistory(); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {context: source.context}); + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + } }); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + it('passes transaction to automation run creation', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - const runOptions = WelcomeEmailAutomationRun.add.firstCall.args[1]; - assert.ok(runOptions.transacting); - }); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - it('does NOT create automation run when welcome email is inactive', async function () { - Automation.findOne.resolves({ - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - return data[key]; - }), - related: sinon.stub().callsFake((relation) => { - assert.equal(relation, 'welcomeEmailAutomatedEmail'); - return { - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - }; - }) + const runOptions = WelcomeEmailAutomationRun.add.firstCall.args[1]; + assert.ok(runOptions.transacting); }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); + it('does NOT create automation run when welcome email is inactive', async function () { + Automation.findOne.resolves({ + get: sinon.stub().callsFake((key) => { + const data = {status: 'inactive'}; + return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; + }) + }); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - }); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - it('does NOT create automation run when member is signing up for a paid subscription (stripeCustomer is present)', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); }); - // Stub linkSubscription to avoid needing all the stripe-related mocks - sinon.stub(repo, 'linkSubscription').resolves(); - sinon.stub(repo, 'upsertCustomer').resolves(); + it('does NOT create automation run when member is signing up for a paid subscription (stripeCustomer is present)', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - // Create a member with a stripeCustomer (i.e., signing up for paid subscription) - await repo.create({ - email: 'test@example.com', - name: 'Test Member', - stripeCustomer: { - id: 'cus_123', - name: 'Test Member', + // Stub linkSubscription to avoid needing all the stripe-related mocks + sinon.stub(repo, 'linkSubscription').resolves(); + sinon.stub(repo, 'upsertCustomer').resolves(); + + // Create a member with a stripeCustomer (i.e., signing up for paid subscription) + await repo.create({ email: 'test@example.com', - subscriptions: { - data: [{ - id: 'sub_123', - customer: 'cus_123', - status: 'active' - }] + name: 'Test Member', + stripeCustomer: { + id: 'cus_123', + name: 'Test Member', + email: 'test@example.com', + subscriptions: { + data: [{ + id: 'sub_123', + customer: 'cus_123', + status: 'active' + }] + } } - } - }, {}); + }, {}); - // The free welcome email should NOT be sent when stripeCustomer is present - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - sinon.assert.notCalled(Automation.findOne); - sinon.assert.notCalled(Member.transaction); + // The free welcome email should NOT be sent when stripeCustomer is present + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + sinon.assert.notCalled(Automation.findOne); + sinon.assert.notCalled(Member.transaction); + }); }); }); - describe('linkSubscription - automation run integration', function () { + describe('linkSubscription - automation integration', function () { let subscriptionData; beforeEach(function () { @@ -1998,7 +2018,7 @@ describe('MemberRepository', function () { sinon.restore(); }); - it('creates automation run when member status changes to paid', async function () { + it('triggers an automation event for paid signup', async function () { Member.edit.resolves({ attributes: {status: 'paid'}, _previousAttributes: {status: 'free'}, @@ -2008,20 +2028,7 @@ describe('MemberRepository', function () { }) }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption - }); - + const repo = buildRepo(); sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); await repo.linkSubscription({ @@ -2034,51 +2041,41 @@ describe('MemberRepository', function () { context: {} }); - sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); - const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; - assert.equal(runCall.welcome_email_automation_id, 'automation_id_paid'); - assert.equal(runCall.member_id, 'member_id_123'); - assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_paid'); - assert.ok(runCall.ready_at); - assert.equal(runCall.step_started_at, null); - assert.equal(runCall.step_attempts, 0); - assert.equal(runCall.exit_reason, null); - }); - - it('does NOT create automation run for disallowed sources', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'free'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) + sinon.assert.calledOnceWithExactly(automationsApi.trigger, { + event: 'member_sign_up', + memberId: 'member_id_123', + memberEmail: 'test@example.com', + memberStatus: 'paid' }); + }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption - }); + describe('legacy automations', function () { + it('creates automation run when member status changes to paid', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - const disallowedSources = [ - {name: 'import', context: {import: true}}, - {name: 'admin', context: {user: true}}, - {name: 'api', context: {api_key: true}} - ]; + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - for (const source of disallowedSources) { - WelcomeEmailAutomationRun.add.resetHistory(); await repo.linkSubscription({ id: 'member_id_123', subscription: subscriptionData @@ -2086,106 +2083,162 @@ describe('MemberRepository', function () { transacting: { executionPromise: Promise.resolve() }, - context: source.context + context: {} }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - } - }); - it('does NOT create automation run when paid welcome email is inactive', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'free'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) - }); + sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); + const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; + assert.equal(runCall.welcome_email_automation_id, 'automation_id_paid'); + assert.equal(runCall.member_id, 'member_id_123'); + assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_paid'); + assert.ok(runCall.ready_at); + assert.equal(runCall.step_started_at, null); + assert.equal(runCall.step_attempts, 0); + assert.equal(runCall.exit_reason, null); + }); + + it('does NOT create automation run for disallowed sources', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - Automation.findOne.resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - return data[key]; - }), - related: sinon.stub().callsFake((relation) => { - assert.equal(relation, 'welcomeEmailAutomatedEmail'); - return { - id: 'automated_email_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - }; - }) - }); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + + const disallowedSources = [ + {name: 'import', context: {import: true}}, + {name: 'admin', context: {user: true}}, + {name: 'api', context: {api_key: true}} + ]; + + for (const source of disallowedSources) { + WelcomeEmailAutomationRun.add.resetHistory(); + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: source.context + }); + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + } }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + it('does NOT create automation run when paid welcome email is inactive', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - await repo.linkSubscription({ - id: 'member_id_123', - subscription: subscriptionData - }, { - transacting: { - executionPromise: Promise.resolve() - }, - context: {} - }); + Automation.findOne.resolves({ + id: 'automation_id_paid', + get: sinon.stub().callsFake((key) => { + const data = {status: 'inactive'}; + return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + id: 'automated_email_id_paid', + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; + }) + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - }); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - it('does NOT create automation run when previous status was "gift" (already received paid welcome at redemption)', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'gift'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) - }); + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: {} + }); + + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + it('does NOT create automation run when previous status was "gift" (already received paid welcome at redemption)', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'gift'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - await repo.linkSubscription({ - id: 'member_id_123', - subscription: subscriptionData - }, { - transacting: { - executionPromise: Promise.resolve() - }, - context: {} - }); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: {} + }); + + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + }); }); });