Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const logging = require('@tryghost/logging');
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.

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
});
}
},

async function down(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,79 @@
const logging = require('@tryghost/logging');
const {commands} = require('../../../schema');
const {createTransactionalMigration} = require('../../utils');

module.exports = createTransactionalMigration(
async function up(knex) {
const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients');
if (!recipientsTableExists) {
logging.warn('Skipping foreign key migration - automated_email_recipients table does not exist');
return;
}

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

const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails');
if (!newTableExists) {
logging.warn('Skipping foreign key migration - welcome_email_automated_emails table does not exist');
return;
}

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) {
const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients');
if (!recipientsTableExists) {
logging.warn('Skipping foreign key rollback - automated_email_recipients table does not exist');
return;
}

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

const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails');
if (!newTableExists) {
logging.warn('Skipping foreign key rollback - welcome_email_automated_emails table does not exist');
return;
}

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
});
}
);
2 changes: 1 addition & 1 deletion ghost/core/core/server/data/schema/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,7 @@ module.exports = {
},
automated_email_recipients: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'automated_emails.id'},
automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'welcome_email_automated_emails.id'},
member_id: {type: 'string', maxlength: 24, nullable: false, index: true},
member_uuid: {type: 'string', maxlength: 36, nullable: false},
member_email: {type: 'string', maxlength: 191, nullable: false},
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/core/server/models/automated-email-recipient.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const AutomatedEmailRecipient = ghostBookshelf.Model.extend({
hasTimestamps: true,

automatedEmail() {
return this.belongsTo('AutomatedEmail', 'automated_email_id');
return this.belongsTo('WelcomeEmailAutomatedEmail', 'automated_email_id');
},
member() {
return this.belongsTo('Member', 'member_id');
Expand Down
Loading
Loading