Skip to content

Commit fe9dfb3

Browse files
authored
Added simplified edit endpoint for automations (TryGhost#27853)
closes https://linear.app/ghost/issue/NY-1281/create-endpoint-for-editing-automations-status ## Summary This adds a `PUT automations/:id` endpoint for editing automations. Right now this endpoint can _only_ edit the status of the automation — any other fields or relations passed are ignored. We'll soon extend this endpoint to allow editing other fields on the automation (e.g. name) and the automation's actions/edges.
1 parent 823d0a7 commit fe9dfb3

7 files changed

Lines changed: 335 additions & 1 deletion

File tree

ghost/core/core/server/api/endpoints/automations.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
const errors = require('@tryghost/errors');
12
const automationsApi = require('../../services/automations/automations-api');
23

4+
const VALID_AUTOMATION_STATUSES = ['active', 'inactive'];
5+
6+
const messages = {
7+
invalidAutomationStatus: 'Automation status must be one of: active, inactive.',
8+
invalidAutomationStatusHelp: 'Use "active" or "inactive" for automation status.'
9+
};
10+
311
/** @type {import('@tryghost/api-framework').Controller} */
412
const controller = {
513
docName: 'automations',
@@ -27,6 +35,31 @@ const controller = {
2735
}
2836
},
2937

38+
edit: {
39+
headers: {
40+
cacheInvalidate: false
41+
},
42+
options: [
43+
'id'
44+
],
45+
validation(frame) {
46+
const status = frame.data?.automations?.[0]?.status;
47+
48+
if (!VALID_AUTOMATION_STATUSES.includes(status)) {
49+
throw new errors.ValidationError({
50+
message: messages.invalidAutomationStatus,
51+
context: status === undefined ? undefined : `Received status "${status}".`,
52+
help: messages.invalidAutomationStatusHelp,
53+
property: 'status'
54+
});
55+
}
56+
},
57+
permissions: true,
58+
async query(frame) {
59+
return await automationsApi.edit(frame.options.id, frame.data.automations[0]);
60+
}
61+
},
62+
3063
poll: {
3164
statusCode: 204,
3265
headers: {

ghost/core/core/server/services/automations/automations-api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const messages = {
1212
automationNotFound: 'Automation not found.'
1313
};
1414

15+
interface EditAutomationData {
16+
status: string;
17+
}
18+
1519
let testDatabase: DatabaseSync | null = null;
1620

1721
const repository = createFakeDatabaseAutomationsRepository({
@@ -40,12 +44,28 @@ async function read(automationId: string) {
4044
return automation;
4145
}
4246

47+
async function edit(automationId: string, data: EditAutomationData) {
48+
// TODO (NY-1229): Allow updating other fields and actions/edges.
49+
const automation = await repository.edit(automationId, {
50+
status: data.status
51+
});
52+
53+
if (!automation) {
54+
throw new errors.NotFoundError({
55+
message: tpl(messages.automationNotFound)
56+
});
57+
}
58+
59+
return automation;
60+
}
61+
4362
function requestPoll() {
4463
domainEvents.dispatch(StartAutomationsPollEvent.create());
4564
}
4665

4766
module.exports = {
4867
browse,
68+
edit,
4969
read,
5070
requestPoll
5171
};

ghost/core/core/server/services/automations/automations-repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,5 @@ export interface Automation extends AutomationSummary {
5959
export interface AutomationsRepository {
6060
browse(): Promise<Page<AutomationSummary>>;
6161
getById(id: string): Promise<Automation | null>;
62+
edit(id: string, data: Pick<AutomationSummary, 'status'>): Promise<Automation | null>;
6263
}

ghost/core/core/server/services/automations/fake-database-automations-repository.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ export function createFakeDatabaseAutomationsRepository({
7272

7373
return buildAutomation(database, automation);
7474
});
75+
},
76+
77+
async edit(id: string, data: Pick<AutomationSummary, 'status'>): Promise<Automation | null> {
78+
const database = getDatabase();
79+
80+
return withTransaction(database, () => {
81+
const automation = loadAutomation(database, id);
82+
83+
if (!automation) {
84+
return null;
85+
}
86+
87+
// TODO (NY-1229): Allow updating other fields and actions/edges.
88+
const updatedAutomation = updateAutomation(database, {
89+
...automation,
90+
status: data.status,
91+
updated_at: new Date().toISOString()
92+
});
93+
94+
return buildAutomation(database, updatedAutomation);
95+
});
7596
}
7697
};
7798
}
@@ -107,6 +128,31 @@ function loadAutomations(database: DatabaseSync): AutomationRow[] {
107128
`).all() as unknown as AutomationRow[];
108129
}
109130

131+
function updateAutomation(database: DatabaseSync, automation: AutomationRow): AutomationRow {
132+
database.prepare(`
133+
UPDATE automations
134+
SET status = :status,
135+
updated_at = :updated_at
136+
WHERE id = :id
137+
`).run({
138+
id: automation.id,
139+
status: automation.status,
140+
updated_at: automation.updated_at
141+
});
142+
143+
return requireAutomation(loadAutomation(database, automation.id), automation.id);
144+
}
145+
146+
function requireAutomation(automation: AutomationRow | null, id: string): AutomationRow {
147+
if (!automation) {
148+
throw new errors.InternalServerError({
149+
message: `Updated automation "${id}" could not be loaded.`
150+
});
151+
}
152+
153+
return automation;
154+
}
155+
110156
function buildAutomation(database: DatabaseSync, automation: AutomationRow): Automation {
111157
return {
112158
...buildAutomationSummary(automation),

ghost/core/core/server/web/api/endpoints/admin/routes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ module.exports = function apiRoutes() {
189189
router.get('/automations', mw.authAdminApi, http(api.automations.browse));
190190
router.get('/automations/:id', mw.authAdminApi, http(api.automations.read));
191191
router.put('/automations/poll', mw.authAdminApiWithUrl, http(api.automations.poll));
192+
router.put('/automations/:id', mw.authAdminApi, http(api.automations.edit));
192193

193194
// ## Automated Emails
194195
router.get('/automated_emails', mw.authAdminApi, http(api.automatedEmails.browse));

ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,150 @@ Object {
4646
}
4747
`;
4848
49+
exports[`Automations API edit edits automation status, ignoring name, actions and edges 1: [body] 1`] = `
50+
Object {
51+
"automations": Array [
52+
Object {
53+
"actions": Array [
54+
Object {
55+
"data": Object {
56+
"wait_hours": 48,
57+
},
58+
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
59+
"type": "wait",
60+
},
61+
Object {
62+
"data": Object {
63+
"email_design_setting_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
64+
"email_lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"paragraph\\",\\"children\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Lorem ipsum.\\"}]}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}",
65+
"email_sender_email": null,
66+
"email_sender_name": null,
67+
"email_sender_reply_to": null,
68+
"email_subject": "Welcome!",
69+
},
70+
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
71+
"type": "send_email",
72+
},
73+
Object {
74+
"data": Object {
75+
"wait_hours": 72,
76+
},
77+
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
78+
"type": "wait",
79+
},
80+
Object {
81+
"data": Object {
82+
"email_design_setting_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
83+
"email_lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"paragraph\\",\\"children\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Lorem ipsum.\\"}]}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}",
84+
"email_sender_email": null,
85+
"email_sender_name": null,
86+
"email_sender_reply_to": null,
87+
"email_subject": "Follow up",
88+
},
89+
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
90+
"type": "send_email",
91+
},
92+
],
93+
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
94+
"edges": Array [
95+
Object {
96+
"source_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
97+
"target_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
98+
},
99+
Object {
100+
"source_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
101+
"target_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
102+
},
103+
Object {
104+
"source_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
105+
"target_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
106+
},
107+
],
108+
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
109+
"name": "Welcome Email (Free)",
110+
"slug": "member-welcome-email-free",
111+
"status": "inactive",
112+
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
113+
},
114+
],
115+
}
116+
`;
117+
118+
exports[`Automations API edit edits automation status, ignoring name, actions and edges 2: [headers] 1`] = `
119+
Object {
120+
"access-control-allow-origin": "http://127.0.0.1:2369",
121+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
122+
"content-length": StringMatching /\\\\d\\+/,
123+
"content-type": "application/json; charset=utf-8",
124+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
125+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
126+
"vary": "Accept-Version, Origin, Accept-Encoding",
127+
"x-powered-by": "Express",
128+
}
129+
`;
130+
131+
exports[`Automations API edit rejects a missing automation status 1: [body] 1`] = `
132+
Object {
133+
"errors": Array [
134+
Object {
135+
"code": null,
136+
"context": "Automation status must be one of: active, inactive.",
137+
"details": null,
138+
"ghostErrorCode": null,
139+
"help": "Use \\"active\\" or \\"inactive\\" for automation status.",
140+
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
141+
"message": "Validation error, cannot edit automation.",
142+
"property": "status",
143+
"type": "ValidationError",
144+
},
145+
],
146+
}
147+
`;
148+
149+
exports[`Automations API edit rejects a missing automation status 2: [headers] 1`] = `
150+
Object {
151+
"access-control-allow-origin": "http://127.0.0.1:2369",
152+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
153+
"content-length": "333",
154+
"content-type": "application/json; charset=utf-8",
155+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
156+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
157+
"vary": "Accept-Version, Origin, Accept-Encoding",
158+
"x-powered-by": "Express",
159+
}
160+
`;
161+
162+
exports[`Automations API edit rejects an invalid automation status 1: [body] 1`] = `
163+
Object {
164+
"errors": Array [
165+
Object {
166+
"code": null,
167+
"context": "Automation status must be one of: active, inactive. Received status \\"paused\\".",
168+
"details": null,
169+
"ghostErrorCode": null,
170+
"help": "Use \\"active\\" or \\"inactive\\" for automation status.",
171+
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
172+
"message": "Validation error, cannot edit automation.",
173+
"property": "status",
174+
"type": "ValidationError",
175+
},
176+
],
177+
}
178+
`;
179+
180+
exports[`Automations API edit rejects an invalid automation status 2: [headers] 1`] = `
181+
Object {
182+
"access-control-allow-origin": "http://127.0.0.1:2369",
183+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
184+
"content-length": "361",
185+
"content-type": "application/json; charset=utf-8",
186+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
187+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
188+
"vary": "Accept-Version, Origin, Accept-Encoding",
189+
"x-powered-by": "Express",
190+
}
191+
`;
192+
49193
exports[`Automations API poll does not poll when request lacks a token 1: [body] 1`] = `
50194
Object {
51195
"errors": Array [

0 commit comments

Comments
 (0)