Skip to content

Commit a597db6

Browse files
authored
Switched from automated emails table to welcome email automation tables (#27185)
towards https://linear.app/ghost/issue/NY-1188 ref #27183 ref #27184 Previous patches added new dormant tables and models. This change actually uses them. More specifically, this does a database migration to move `automated_emails` to `welcome_email_automations` and `welcome_email_automated_emails`. Then, it updates all relevant code to use those new tables. The old model is deleted, but the tables are not. (That's forthcoming.)
1 parent 7d6c296 commit a597db6

24 files changed

+679
-408
lines changed

ghost/core/core/server/api/endpoints/automated-emails.js

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const _ = require('lodash');
12
const tpl = require('@tryghost/tpl');
23
const errors = require('@tryghost/errors');
34
const models = require('../../models');
@@ -7,6 +8,32 @@ const messages = {
78
automatedEmailNotFound: 'Automated email not found.'
89
};
910

11+
// NOTE: This file is in a transitionary state. The `automated_emails` database table was split into
12+
// `welcome_email_automations` (automation metadata: status, name, slug) and
13+
// `welcome_email_automated_emails` (email content: subject, lexical, sender fields). This controller
14+
// acts as a facade that joins/splits data between those two models while preserving the original
15+
// `automated_emails` API shape externally.
16+
const AUTOMATION_FIELDS = ['status', 'name', 'slug'];
17+
const EMAIL_FIELDS = ['subject', 'lexical', 'sender_name', 'sender_email', 'sender_reply_to', 'email_design_setting_id'];
18+
19+
function flattenAutomation(automation, email = automation.related('welcomeEmailAutomatedEmail')) {
20+
const result = {
21+
id: automation.id,
22+
status: automation.get('status'),
23+
name: automation.get('name'),
24+
slug: automation.get('slug'),
25+
subject: email.get('subject'),
26+
lexical: email.get('lexical'),
27+
sender_name: email.get('sender_name'),
28+
sender_email: email.get('sender_email'),
29+
sender_reply_to: email.get('sender_reply_to'),
30+
email_design_setting_id: email.get('email_design_setting_id'),
31+
created_at: automation.get('created_at'),
32+
updated_at: automation.get('updated_at')
33+
};
34+
return result;
35+
}
36+
1037
/** @type {import('@tryghost/api-framework').Controller} */
1138
const controller = {
1239
docName: 'automated_emails',
@@ -23,8 +50,15 @@ const controller = {
2350
'page'
2451
],
2552
permissions: true,
26-
query(frame) {
27-
return models.AutomatedEmail.findPage(frame.options);
53+
async query(frame) {
54+
const result = await models.WelcomeEmailAutomation.findPage({
55+
...frame.options,
56+
withRelated: ['welcomeEmailAutomatedEmail']
57+
});
58+
return {
59+
...result,
60+
data: result.data.map(automation => flattenAutomation(automation))
61+
};
2862
}
2963
},
3064

@@ -41,14 +75,17 @@ const controller = {
4175
],
4276
permissions: true,
4377
async query(frame) {
44-
const model = await models.AutomatedEmail.findOne(frame.data, frame.options);
78+
const model = await models.WelcomeEmailAutomation.findOne(frame.data, {
79+
...frame.options,
80+
withRelated: ['welcomeEmailAutomatedEmail']
81+
});
4582
if (!model) {
4683
throw new errors.NotFoundError({
4784
message: tpl(messages.automatedEmailNotFound)
4885
});
4986
}
5087

51-
return model;
88+
return flattenAutomation(model);
5289
}
5390
},
5491

@@ -60,7 +97,22 @@ const controller = {
6097
permissions: true,
6198
async query(frame) {
6299
const data = frame.data.automated_emails[0];
63-
return models.AutomatedEmail.add(data, frame.options);
100+
101+
const emailData = _.pick(data, EMAIL_FIELDS);
102+
const automationData = _.pick(data, AUTOMATION_FIELDS);
103+
104+
return models.Base.transaction(async (transacting) => {
105+
const automation = await models.WelcomeEmailAutomation.add(automationData, {...frame.options, transacting});
106+
const email = await models.WelcomeEmailAutomatedEmail.add(
107+
{
108+
...emailData,
109+
welcome_email_automation_id: automation.id,
110+
delay_days: 0
111+
},
112+
{...frame.options, transacting}
113+
);
114+
return flattenAutomation(automation, email);
115+
});
64116
}
65117
},
66118

@@ -79,16 +131,42 @@ const controller = {
79131
}
80132
},
81133
permissions: true,
134+
// eslint-disable-next-line ghost/ghost-custom/max-api-complexity
82135
async query(frame) {
83136
const data = frame.data.automated_emails[0];
84-
const model = await models.AutomatedEmail.edit(data, frame.options);
85-
if (!model) {
86-
throw new errors.NotFoundError({
87-
message: tpl(messages.automatedEmailNotFound)
137+
138+
const emailData = _.pick(data, EMAIL_FIELDS);
139+
const automationData = _.pick(data, AUTOMATION_FIELDS);
140+
141+
return models.Base.transaction(async (transacting) => {
142+
let automation = await models.WelcomeEmailAutomation.findOne({id: frame.options.id}, {
143+
transacting,
144+
withRelated: ['welcomeEmailAutomatedEmail']
88145
});
89-
}
146+
if (!automation) {
147+
throw new errors.NotFoundError({
148+
message: tpl(messages.automatedEmailNotFound)
149+
});
150+
}
151+
let email = automation.related('welcomeEmailAutomatedEmail');
152+
153+
if (Object.keys(emailData).length > 0) {
154+
email = await models.WelcomeEmailAutomatedEmail.edit(emailData, {
155+
...frame.options,
156+
transacting,
157+
id: email.id
158+
});
159+
}
160+
161+
if (Object.keys(automationData).length > 0) {
162+
automation = await models.WelcomeEmailAutomation.edit(automationData, {
163+
...frame.options,
164+
transacting
165+
});
166+
}
90167

91-
return model;
168+
return flattenAutomation(automation, email);
169+
});
92170
}
93171
},
94172

@@ -102,12 +180,15 @@ const controller = {
102180
async query(frame) {
103181
memberWelcomeEmailService.init();
104182
const data = frame.data;
105-
106-
return memberWelcomeEmailService.api.editSharedSenderOptions({
183+
const result = await memberWelcomeEmailService.api.editSharedSenderOptions({
107184
sender_name: data.sender_name,
108185
sender_email: data.sender_email,
109186
sender_reply_to: data.sender_reply_to
110187
});
188+
return {
189+
...result,
190+
data: result.data.map(automation => flattenAutomation(automation))
191+
};
111192
}
112193
},
113194

@@ -123,7 +204,11 @@ const controller = {
123204
],
124205
async query(frame) {
125206
memberWelcomeEmailService.init();
126-
return memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token);
207+
const result = await memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token);
208+
return {
209+
...result,
210+
data: result.data.map(automation => flattenAutomation(automation))
211+
};
127212
}
128213
},
129214
sendTestEmail: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const logging = require('@tryghost/logging');
2+
const {createTransactionalMigration} = require('../../utils');
3+
const ObjectId = require('bson-objectid').default;
4+
5+
module.exports = createTransactionalMigration(
6+
async function up(knex) {
7+
// The welcome_email_automations and welcome_email_automated_emails tables
8+
// already exist from a prior dormant migration. This migration copies data
9+
// from the old automated_emails table into them.
10+
11+
const oldTableExists = await knex.schema.hasTable('automated_emails');
12+
if (!oldTableExists) {
13+
logging.warn('Skipping data migration - automated_emails table does not exist');
14+
return;
15+
}
16+
17+
const rows = await knex('automated_emails').select('*');
18+
logging.info(`Migrating ${rows.length} rows from automated_emails to new tables`);
19+
20+
// Only 2 rows exist (free + paid welcome emails), so sequential iteration is fine
21+
// eslint-disable-next-line no-restricted-syntax
22+
for (const row of rows) {
23+
// Check if already migrated (idempotency) by looking for a matching slug
24+
const existingAutomation = await knex('welcome_email_automations').where('slug', row.slug).first();
25+
if (existingAutomation) {
26+
logging.warn(`Skipping row for slug ${row.slug} - already migrated`);
27+
continue;
28+
}
29+
30+
const automationId = ObjectId().toHexString();
31+
32+
// Insert automation first (emails reference automations via FK)
33+
await knex('welcome_email_automations').insert({
34+
id: automationId,
35+
status: row.status,
36+
name: row.name,
37+
slug: row.slug,
38+
created_at: row.created_at,
39+
updated_at: row.updated_at
40+
});
41+
42+
// Reuse the original automated_email id so the existing
43+
// automated_email_recipients rows continue to reference the same id
44+
await knex('welcome_email_automated_emails').insert({
45+
id: row.id,
46+
welcome_email_automation_id: automationId,
47+
delay_days: 0,
48+
subject: row.subject,
49+
lexical: row.lexical,
50+
sender_name: row.sender_name,
51+
sender_email: row.sender_email,
52+
sender_reply_to: row.sender_reply_to,
53+
email_design_setting_id: row.email_design_setting_id,
54+
created_at: row.created_at,
55+
updated_at: row.updated_at
56+
});
57+
}
58+
},
59+
60+
async function down(knex) {
61+
// Remove migrated data from new tables
62+
logging.info('Removing migrated data from new tables');
63+
await knex('welcome_email_automated_emails').del();
64+
await knex('welcome_email_automations').del();
65+
}
66+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const logging = require('@tryghost/logging');
2+
const {commands} = require('../../../schema');
3+
const {createTransactionalMigration} = require('../../utils');
4+
5+
module.exports = createTransactionalMigration(
6+
async function up(knex) {
7+
const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients');
8+
if (!recipientsTableExists) {
9+
logging.warn('Skipping foreign key migration - automated_email_recipients table does not exist');
10+
return;
11+
}
12+
13+
const oldTableExists = await knex.schema.hasTable('automated_emails');
14+
if (!oldTableExists) {
15+
logging.warn('Skipping foreign key migration - automated_emails table does not exist');
16+
return;
17+
}
18+
19+
const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails');
20+
if (!newTableExists) {
21+
logging.warn('Skipping foreign key migration - welcome_email_automated_emails table does not exist');
22+
return;
23+
}
24+
25+
logging.info('Updating foreign key on automated_email_recipients');
26+
await commands.dropForeign({
27+
fromTable: 'automated_email_recipients',
28+
fromColumn: 'automated_email_id',
29+
toTable: 'automated_emails',
30+
toColumn: 'id',
31+
transaction: knex
32+
});
33+
34+
await commands.addForeign({
35+
fromTable: 'automated_email_recipients',
36+
fromColumn: 'automated_email_id',
37+
toTable: 'welcome_email_automated_emails',
38+
toColumn: 'id',
39+
transaction: knex
40+
});
41+
},
42+
43+
async function down(knex) {
44+
const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients');
45+
if (!recipientsTableExists) {
46+
logging.warn('Skipping foreign key rollback - automated_email_recipients table does not exist');
47+
return;
48+
}
49+
50+
const oldTableExists = await knex.schema.hasTable('automated_emails');
51+
if (!oldTableExists) {
52+
logging.warn('Skipping foreign key rollback - automated_emails table does not exist');
53+
return;
54+
}
55+
56+
const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails');
57+
if (!newTableExists) {
58+
logging.warn('Skipping foreign key rollback - welcome_email_automated_emails table does not exist');
59+
return;
60+
}
61+
62+
logging.info('Restoring foreign key on automated_email_recipients');
63+
await commands.dropForeign({
64+
fromTable: 'automated_email_recipients',
65+
fromColumn: 'automated_email_id',
66+
toTable: 'welcome_email_automated_emails',
67+
toColumn: 'id',
68+
transaction: knex
69+
});
70+
71+
await commands.addForeign({
72+
fromTable: 'automated_email_recipients',
73+
fromColumn: 'automated_email_id',
74+
toTable: 'automated_emails',
75+
toColumn: 'id',
76+
transaction: knex
77+
});
78+
}
79+
);

ghost/core/core/server/data/schema/schema.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1209,7 +1209,7 @@ module.exports = {
12091209
},
12101210
automated_email_recipients: {
12111211
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
1212-
automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'automated_emails.id'},
1212+
automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'welcome_email_automated_emails.id'},
12131213
member_id: {type: 'string', maxlength: 24, nullable: false, index: true},
12141214
member_uuid: {type: 'string', maxlength: 36, nullable: false},
12151215
member_email: {type: 'string', maxlength: 191, nullable: false},

ghost/core/core/server/models/automated-email-recipient.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const AutomatedEmailRecipient = ghostBookshelf.Model.extend({
55
hasTimestamps: true,
66

77
automatedEmail() {
8-
return this.belongsTo('AutomatedEmail', 'automated_email_id');
8+
return this.belongsTo('WelcomeEmailAutomatedEmail', 'automated_email_id');
99
},
1010
member() {
1111
return this.belongsTo('Member', 'member_id');

0 commit comments

Comments
 (0)