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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions ghost/core/core/server/services/automations/automations-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -238,6 +239,29 @@ export function requestPoll() {
domainEvents.dispatch(StartAutomationsPollEvent.create());
}

type TriggerOptions = Parameters<AutomationsRepository['trigger']>[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);
Comment thread
EvanHahn marked this conversation as resolved.

requestPoll();
}

export function _resetTestDatabase() {
if (process.env.NODE_ENV?.startsWith('testing')) {
testDatabase = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,9 @@ export interface AutomationsRepository {
browse(): Promise<Page<AutomationSummary>>;
getById(id: string): Promise<Automation | null>;
edit(id: string, data: EditAutomationData): Promise<Automation | null>;
trigger(options: {
memberEmail: string;
memberId: string;
memberStatus: 'free' | 'paid';
}): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.',
Expand Down Expand Up @@ -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
}: {
Expand Down Expand Up @@ -98,6 +109,16 @@ export function createFakeDatabaseAutomationsRepository({

return buildAutomation(database, updatedAutomation);
});
},

async trigger(options: {
memberEmail: string;
memberId: string;
memberStatus: 'free' | 'paid';
}): Promise<void> {
const database = getDatabase();

return withTransaction(database, () => trigger(database, options));
}
};
}
Expand All @@ -115,6 +136,112 @@ function withTransaction<T>(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<string> = 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<NextActionRevisionRow, 'action_id' | 'type' | 'wait_hours'>,
now: Readonly<Date>
): 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
Expand Down
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to convert this to TypeScript so I could import it from another TypeScript file. (Alternatively, I could've added temporary-fake-database.d.ts, but I think that's strictly worse.

Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -312,21 +312,14 @@ 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'
});
}
cachedDatabase ??= createTemporaryFakeAutomationsDatabase();
return cachedDatabase;
}

exports.createTemporaryFakeAutomationsDatabase = createTemporaryFakeAutomationsDatabase;
exports.getTemporaryFakeAutomationsDatabase = getTemporaryFakeAutomationsDatabase;
}
15 changes: 12 additions & 3 deletions ghost/core/core/server/services/gifts/gift-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,7 +38,12 @@ interface MemberModel {
interface MemberRepository {
get(filter: Record<string, unknown>, options?: Record<string, unknown>): Promise<MemberModel | null>;
update(data: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record<string, unknown>): Promise<unknown>;
triggerMemberSignupAutomation(
memberId: string,
memberEmail: string,
memberStatus: 'free' | 'paid',
options?: Record<string, unknown>
): Promise<unknown>;
}

type Tier = {
Expand Down Expand Up @@ -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};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -104,6 +105,7 @@ module.exports = function MembersAPI({
tokenService,
newslettersService,
productRepository,
automationsApi,
Automation,
WelcomeEmailAutomationRun,
Member,
Expand Down
Loading
Loading