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
101 changes: 90 additions & 11 deletions ghost/core/core/server/api/endpoints/automated-emails.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const _ = require('lodash');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
Expand All @@ -7,6 +8,32 @@ const messages = {
automatedEmailNotFound: 'Automated email not found.'
};

// NOTE: This file is in a transitionary state. The `automated_emails` database table was split into
// `welcome_email_automations` (automation metadata: status, name, slug) and
// `welcome_email_automated_emails` (email content: subject, lexical, sender fields). This controller
// acts as a facade that joins/splits data between those two models while preserving the original
// `automated_emails` API shape externally.
const AUTOMATION_FIELDS = ['status', 'name', 'slug'];
const EMAIL_FIELDS = ['subject', 'lexical', 'sender_name', 'sender_email', 'sender_reply_to', 'email_design_setting_id'];

function flattenAutomation(automation, email = automation.related('welcomeEmailAutomatedEmail')) {
const result = {
id: automation.id,
status: automation.get('status'),
name: automation.get('name'),
slug: automation.get('slug'),
subject: email.get('subject'),
lexical: email.get('lexical'),
sender_name: email.get('sender_name'),
sender_email: email.get('sender_email'),
sender_reply_to: email.get('sender_reply_to'),
email_design_setting_id: email.get('email_design_setting_id'),
created_at: automation.get('created_at'),
updated_at: automation.get('updated_at')
};
return result;
}

/** @type {import('@tryghost/api-framework').Controller} */
const controller = {
docName: 'automated_emails',
Expand All @@ -23,8 +50,15 @@ const controller = {
'page'
],
permissions: true,
query(frame) {
return models.AutomatedEmail.findPage(frame.options);
async query(frame) {
const result = await models.WelcomeEmailAutomation.findPage({
...frame.options,
withRelated: ['welcomeEmailAutomatedEmail']
});
return {
...result,
data: result.data.map(automation => flattenAutomation(automation))
};
}
},

Expand All @@ -41,14 +75,17 @@ const controller = {
],
permissions: true,
async query(frame) {
const model = await models.AutomatedEmail.findOne(frame.data, frame.options);
const model = await models.WelcomeEmailAutomation.findOne(frame.data, {
...frame.options,
withRelated: ['welcomeEmailAutomatedEmail']
});
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.automatedEmailNotFound)
});
}

return model;
return flattenAutomation(model);
}
},

Expand All @@ -60,7 +97,23 @@ const controller = {
permissions: true,
async query(frame) {
const data = frame.data.automated_emails[0];
return models.AutomatedEmail.add(data, frame.options);

const emailData = _.pick(data, EMAIL_FIELDS);
const automationData = _.pick(data, AUTOMATION_FIELDS);

return models.Base.transaction(async (transacting) => {
const automation = await models.WelcomeEmailAutomation.add(automationData, {...frame.options, transacting});
emailData.welcome_email_automation_id = automation.id;
const email = await models.WelcomeEmailAutomatedEmail.add(
{
...emailData,
welcome_email_automation_id: automation.id,
delay_days: 0
},
{...frame.options, transacting}
);
return flattenAutomation(automation, email);
});
}
},

Expand All @@ -79,16 +132,42 @@ const controller = {
}
},
permissions: true,
// eslint-disable-next-line ghost/ghost-custom/max-api-complexity
async query(frame) {
const data = frame.data.automated_emails[0];
const model = await models.AutomatedEmail.edit(data, frame.options);
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.automatedEmailNotFound)

const emailData = _.pick(data, EMAIL_FIELDS);
const automationData = _.pick(data, AUTOMATION_FIELDS);

return models.Base.transaction(async (transacting) => {
let automation = await models.WelcomeEmailAutomation.findOne({id: frame.options.id}, {
transacting,
withRelated: ['welcomeEmailAutomatedEmail']
});
}
if (!automation) {
throw new errors.NotFoundError({
message: tpl(messages.automatedEmailNotFound)
});
}
let email = automation.related('welcomeEmailAutomatedEmail');

return model;
if (Object.keys(emailData).length > 0) {
email = await models.WelcomeEmailAutomatedEmail.edit(emailData, {
...frame.options,
transacting,
id: email.id
});
}

if (Object.keys(automationData).length > 0) {
automation = await models.WelcomeEmailAutomation.edit(automationData, {
...frame.options,
transacting
});
}

return flattenAutomation(automation, email);
});
}
},

Expand Down
5 changes: 3 additions & 2 deletions ghost/core/core/server/data/exporter/table-lists.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ const BACKUP_TABLES = [
'actions',
'api_keys',
'automated_email_recipients',
'automated_emails',
'brute',
'donation_payment_events',
'email_design_settings',
Expand Down Expand Up @@ -58,7 +57,9 @@ const BACKUP_TABLES = [
'recommendation_click_events',
'recommendation_subscribe_events',
'outbox',
'gifts'
'gifts',
'welcome_email_automations',
'welcome_email_automated_emails'
];

// NOTE: exposing only tables which are going to be included in a "default" export file
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const logging = require('@tryghost/logging');
const {commands} = require('../../../schema');
const {createNonTransactionalMigration} = require('../../utils');

// The default names are too long.
const WELCOME_EMAIL_AUTOMATED_EMAILS_AUTOMATION_FK = 'weae_automation_id_foreign';
const WELCOME_EMAIL_AUTOMATED_EMAILS_NEXT_EMAIL_FK = 'weae_next_email_id_foreign';

const welcomeEmailAutomationsSpec = {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'inactive', validations: {isIn: [['active', 'inactive']]}},
name: {type: 'string', maxlength: 191, nullable: false, unique: true},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
};

const welcomeEmailAutomatedEmailsSpec = {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
welcome_email_automation_id: {type: 'string', maxlength: 24, nullable: false, references: 'welcome_email_automations.id', constraintName: WELCOME_EMAIL_AUTOMATED_EMAILS_AUTOMATION_FK, cascadeDelete: true},
next_welcome_email_automated_email_id: {type: 'string', maxlength: 24, nullable: true, references: 'welcome_email_automated_emails.id', constraintName: WELCOME_EMAIL_AUTOMATED_EMAILS_NEXT_EMAIL_FK, cascadeDelete: false},
delay_days: {type: 'integer', nullable: false},
subject: {type: 'string', maxlength: 300, nullable: false},
lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
sender_name: {type: 'string', maxlength: 191, nullable: true},
sender_email: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}},
sender_reply_to: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}},
email_design_setting_id: {type: 'string', maxlength: 24, nullable: false, references: 'email_design_settings.id'},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
};

module.exports = createNonTransactionalMigration(
async function up(knex) {
const automationsExists = await knex.schema.hasTable('welcome_email_automations');
if (automationsExists) {
logging.warn('Skipping creating table welcome_email_automations - already exists');
} else {
logging.info('Creating table: welcome_email_automations');
await commands.createTable('welcome_email_automations', knex, welcomeEmailAutomationsSpec);
}

const automatedEmailsExists = await knex.schema.hasTable('welcome_email_automated_emails');
if (automatedEmailsExists) {
logging.warn('Skipping creating table welcome_email_automated_emails - already exists');
} else {
logging.info('Creating table: welcome_email_automated_emails');
await commands.createTable('welcome_email_automated_emails', knex, welcomeEmailAutomatedEmailsSpec);
}
},

async function down(knex) {
logging.info('Dropping table: welcome_email_automated_emails');
await commands.deleteTable('welcome_email_automated_emails', knex);

logging.info('Dropping table: welcome_email_automations');
await commands.deleteTable('welcome_email_automations', knex);
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const logging = require('@tryghost/logging');
const {commands} = require('../../../schema');
const {createTransactionalMigration} = require('../../utils');
const ObjectId = require('bson-objectid').default;

module.exports = createTransactionalMigration(
async function up(knex) {
// The welcome_email_automations and welcome_email_automated_emails tables
// already exist from a prior dormant migration. This migration copies data
// from the old automated_emails table into them and re-points the FK on
// automated_email_recipients.

const oldTableExists = await knex.schema.hasTable('automated_emails');
if (!oldTableExists) {
logging.warn('Skipping data migration - automated_emails table does not exist');
return;
}

const rows = await knex('automated_emails').select('*');
logging.info(`Migrating ${rows.length} rows from automated_emails to new tables`);

// Only 2 rows exist (free + paid welcome emails), so sequential iteration is fine
// eslint-disable-next-line no-restricted-syntax
for (const row of rows) {
// Check if already migrated (idempotency) by looking for a matching slug
const existingAutomation = await knex('welcome_email_automations').where('slug', row.slug).first();
if (existingAutomation) {
logging.warn(`Skipping row for slug ${row.slug} - already migrated`);
continue;
}

const automationId = ObjectId().toHexString();

// Insert automation first (emails reference automations via FK)
await knex('welcome_email_automations').insert({
id: automationId,
status: row.status,
name: row.name,
slug: row.slug,
created_at: row.created_at,
updated_at: row.updated_at
});

// Reuse the original automated_email id so the existing
// automated_email_recipients rows continue to reference the same id
await knex('welcome_email_automated_emails').insert({
id: row.id,
welcome_email_automation_id: automationId,
delay_days: 0,
subject: row.subject,
lexical: row.lexical,
sender_name: row.sender_name,
sender_email: row.sender_email,
sender_reply_to: row.sender_reply_to,
email_design_setting_id: row.email_design_setting_id,
created_at: row.created_at,
updated_at: row.updated_at
});
}

// Update FK on automated_email_recipients: automated_emails -> welcome_email_automated_emails
logging.info('Updating foreign key on automated_email_recipients');
await commands.dropForeign({
fromTable: 'automated_email_recipients',
fromColumn: 'automated_email_id',
toTable: 'automated_emails',
toColumn: 'id',
transaction: knex
});

await commands.addForeign({
fromTable: 'automated_email_recipients',
fromColumn: 'automated_email_id',
toTable: 'welcome_email_automated_emails',
toColumn: 'id',
transaction: knex
});
},

async function down(knex) {
// Restore FK on automated_email_recipients back to automated_emails
logging.info('Restoring foreign key on automated_email_recipients');
await commands.dropForeign({
fromTable: 'automated_email_recipients',
fromColumn: 'automated_email_id',
toTable: 'welcome_email_automated_emails',
toColumn: 'id',
transaction: knex
});

await commands.addForeign({
fromTable: 'automated_email_recipients',
fromColumn: 'automated_email_id',
toTable: 'automated_emails',
toColumn: 'id',
transaction: knex
});

// Remove migrated data from new tables
logging.info('Removing migrated data from new tables');
await knex('welcome_email_automated_emails').del();
await knex('welcome_email_automations').del();
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const logging = require('@tryghost/logging');
const {commands} = require('../../../schema');
const {createNonTransactionalMigration} = require('../../utils');

const oldAutomatedEmailsSpec = {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'inactive', validations: {isIn: [['active', 'inactive']]}},
name: {type: 'string', maxlength: 191, nullable: false, unique: true},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
subject: {type: 'string', maxlength: 300, nullable: false},
lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
sender_name: {type: 'string', maxlength: 191, nullable: true},
sender_email: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}},
sender_reply_to: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}},
email_design_setting_id: {type: 'string', maxlength: 24, nullable: false, references: 'email_design_settings.id'},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true},
'@@INDEXES@@': [
['slug'],
['status']
]
};

module.exports = createNonTransactionalMigration(
async function up(knex) {
const exists = await knex.schema.hasTable('automated_emails');
if (!exists) {
logging.warn('Skipping dropping table automated_emails - does not exist');
return;
}

logging.info('Dropping table: automated_emails');
await commands.deleteTable('automated_emails', knex);
},
Comment on lines +25 to +34
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: use the dropTables migration util


async function down(knex) {
const exists = await knex.schema.hasTable('automated_emails');
if (exists) {
logging.warn('Skipping recreating table automated_emails - already exists');
return;
}

logging.info('Recreating table: automated_emails');
await commands.createTable('automated_emails', knex, oldAutomatedEmailsSpec);
}
);
Loading
Loading