Skip to content

Commit ce08414

Browse files
authored
Added "last run created at" stat to automation browse endpoint (TryGhost#29951)
closes https://linear.app/ghost/issue/NY-1523 *I recommend reviewing this one commit at a time.* This adds `stats.last_run_created_at` to each automation, which we'll display in the UI soon. In addition to unit and E2E tests, I also verified that the data appeared by checking the Network tab of the Firefox devtools: ![devtools screenshot](https://github.com/user-attachments/assets/61db3645-ef0d-48f5-be8c-5336af2021dc)
1 parent 022293f commit ce08414

5 files changed

Lines changed: 111 additions & 13 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export interface AutomationSummary {
6565
updated_at: string;
6666
}
6767

68+
export interface AutomationBrowseResult extends AutomationSummary {
69+
stats: {
70+
last_run_created_at: Date | null;
71+
};
72+
}
73+
6874
export interface Automation extends AutomationSummary {
6975
actions: AutomationAction[];
7076
edges: AutomationEdge[];
@@ -136,7 +142,7 @@ export type AutomationStepTerminalStatus =
136142
| 'member unsubscribed';
137143

138144
export interface AutomationsRepository {
139-
browse(): Promise<Page<AutomationSummary>>;
145+
browse(): Promise<Page<AutomationBrowseResult>>;
140146
getById(id: string): Promise<Automation | null>;
141147
getAutomationActionLinks(automationId: string, actionId: string): Promise<AutomationActionLink[] | null>;
142148
edit(id: string, data: EditAutomationData): Promise<Automation | null>;

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG, MEMBER_WELCOME_EMAIL_SLUGS} from '../
1111
import type {
1212
AutomatedEmailEvents,
1313
Automation,
14+
AutomationBrowseResult,
1415
AutomationAction,
1516
AutomationEdge,
1617
AutomationEmailStats,
@@ -52,6 +53,9 @@ interface AutomationRow {
5253
updated_at: DatabaseDate;
5354
}
5455

56+
interface AutomationBrowseRow extends AutomationRow {
57+
last_run_created_at: DatabaseDate | null;
58+
}
5559

5660
interface ActionRow {
5761
id: string;
@@ -145,12 +149,12 @@ export function createDatabaseAutomationsRepository({
145149
fakeWaitHoursMultiplier: number | null;
146150
}): AutomationsRepository {
147151
return {
148-
async browse(): Promise<Page<AutomationSummary>> {
152+
async browse(): Promise<Page<AutomationBrowseResult>> {
149153
return await knex.transaction(async (trx) => {
150154
await ensureDefaultAutomations(trx);
151155
const rows = await loadAutomations(trx);
152156
return {
153-
data: rows.map(row => buildAutomationSummary(row)),
157+
data: rows.map(row => buildAutomationBrowseResult(row)),
154158
meta: {
155159
pagination: buildPagination(rows.length)
156160
}
@@ -1013,10 +1017,24 @@ async function loadAutomationBySlug(trx: Knex.Transaction, slug: string): Promis
10131017
return row ?? null;
10141018
}
10151019

1016-
async function loadAutomations(trx: Knex.Transaction): Promise<AutomationRow[]> {
1020+
async function loadAutomations(trx: Knex.Transaction): Promise<AutomationBrowseRow[]> {
1021+
const latestRunDates = trx('automation_runs')
1022+
.select('automation_id')
1023+
.max({last_run_created_at: 'created_at'})
1024+
.groupBy('automation_id')
1025+
.as('latest_run_dates');
10171026
return await trx('automations')
1018-
.select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
1019-
.orderBy('name');
1027+
.select(
1028+
'automations.id',
1029+
'automations.slug',
1030+
'automations.name',
1031+
'automations.status',
1032+
'automations.created_at',
1033+
'automations.updated_at',
1034+
'latest_run_dates.last_run_created_at'
1035+
)
1036+
.leftJoin(latestRunDates, 'automations.id', 'latest_run_dates.automation_id')
1037+
.orderBy('automations.name');
10201038
}
10211039

10221040
async function updateAutomation(trx: Knex.Transaction, automation: AutomationRow): Promise<AutomationRow> {
@@ -1355,6 +1373,15 @@ function buildAutomationSummary(automation: AutomationRow): AutomationSummary {
13551373
};
13561374
}
13571375

1376+
function buildAutomationBrowseResult(automation: AutomationBrowseRow): AutomationBrowseResult {
1377+
return {
1378+
...buildAutomationSummary(automation),
1379+
stats: {
1380+
last_run_created_at: automation.last_run_created_at ? fromDatabaseDate(automation.last_run_created_at) : null
1381+
}
1382+
};
1383+
}
1384+
13581385
function serializeDate(date: DatabaseDate) {
13591386
const normalizedDate = fromDatabaseDate(date);
13601387
normalizedDate.setMilliseconds(0);

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Object {
88
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
99
"name": "Free member welcome flow",
1010
"slug": "member-welcome-email-free",
11+
"stats": Object {
12+
"last_run_created_at": null,
13+
},
1114
"status": "active",
1215
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
1316
},
@@ -16,6 +19,9 @@ Object {
1619
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
1720
"name": "Paid member welcome flow",
1821
"slug": "member-welcome-email-paid",
22+
"stats": Object {
23+
"last_run_created_at": null,
24+
},
1925
"status": "active",
2026
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
2127
},
@@ -37,7 +43,7 @@ exports[`Automations API browse returns automations sourced from the database 2:
3743
Object {
3844
"access-control-allow-origin": "http://127.0.0.1:2369",
3945
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
40-
"content-length": "510",
46+
"content-length": "584",
4147
"content-type": "application/json; charset=utf-8",
4248
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
4349
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

‎ghost/core/test/e2e-api/admin/automations.test.js‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@ const {anyContentVersion, anyEtag, anyErrorId, anyISODateTime, anyObjectId} = ma
1515
const {cacheInvalidateHeaderNotSet} = assertions;
1616
const hashRedirectDestination = url => createHash('sha256').update(url).digest();
1717

18-
const matchAutomationSummary = () => ({
18+
const matchAutomationBase = () => ({
1919
id: anyObjectId,
2020
created_at: anyISODateTime,
2121
updated_at: anyISODateTime
2222
});
2323

24+
const matchAutomationSummary = () => ({
25+
...matchAutomationBase(),
26+
stats: {
27+
last_run_created_at: null
28+
}
29+
});
30+
2431
const matchAutomation = () => ({
25-
...matchAutomationSummary(),
32+
...matchAutomationBase(),
2633
actions: [{
2734
id: anyObjectId
2835
}, {
@@ -106,6 +113,17 @@ describe('Automations API', function () {
106113
});
107114

108115
describe('browse', function () {
116+
async function createAutomationRun(automationId, createdAt) {
117+
await models.Base.knex('automation_runs').insert({
118+
id: ObjectId().toHexString(),
119+
automation_id: automationId,
120+
member_id: null,
121+
member_email: 'member@example.com',
122+
created_at: createdAt,
123+
updated_at: createdAt
124+
});
125+
}
126+
109127
async function deleteActionsForAutomationIds(automationIds) {
110128
const actionIds = await models.Base.knex('automation_actions')
111129
.whereIn('automation_id', automationIds)
@@ -171,6 +189,27 @@ describe('Automations API', function () {
171189
});
172190
});
173191

192+
it('returns null last run timestamps for automations without runs', async function () {
193+
const {body} = await agent.get('automations').expectStatus(200);
194+
195+
assert.deepEqual(body.automations.map(automation => automation.stats.last_run_created_at), [null, null]);
196+
});
197+
198+
it('returns latest automation run timestamp', async function () {
199+
const {body: beforeBody} = await agent.get('automations').expectStatus(200);
200+
const automationId = beforeBody.automations[0].id;
201+
const olderRunCreatedAt = new Date('2026-01-01T00:00:00.000Z');
202+
const latestRunCreatedAt = new Date('2026-01-02T00:00:00.000Z');
203+
204+
await createAutomationRun(automationId, olderRunCreatedAt);
205+
await createAutomationRun(automationId, latestRunCreatedAt);
206+
207+
const {body} = await agent.get('automations').expectStatus(200);
208+
const automation = body.automations.find(candidate => candidate.id === automationId);
209+
210+
assert.equal(automation.stats.last_run_created_at, latestRunCreatedAt.toISOString());
211+
});
212+
174213
it('upserts the default free and paid automations', async function () {
175214
const existingAutomations = await models.Base.knex('automations')
176215
.select('id')

‎ghost/core/test/unit/server/services/automations/automations-repository.test.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,11 @@ describe('automations repository', function () {
458458
return result;
459459
};
460460

461-
const insertRun = async (automationId: string) => {
462-
const now = toDatabaseDate(new Date());
461+
const insertRun = async (automationId: string, createdAt = new Date()) => {
463462
const run = {
464463
id: ObjectId().toHexString(),
465-
created_at: now,
466-
updated_at: now,
464+
created_at: toDatabaseDate(createdAt),
465+
updated_at: toDatabaseDate(createdAt),
467466
automation_id: automationId,
468467
member_id: ObjectId().toHexString(),
469468
member_email: 'member@example.com'
@@ -660,6 +659,27 @@ describe('automations repository', function () {
660659
]);
661660
});
662661

662+
it('returns null for "last run created at" if the automation has no runs', async function () {
663+
const result = await repo.browse();
664+
665+
assert(result.data.every(automation => automation.stats.last_run_created_at === null));
666+
});
667+
668+
it('returns the newest run creation time for the automation', async function () {
669+
const automationId = (await getAutomationBySlug('member-welcome-email-free')).id;
670+
const latestRunCreatedAt = new Date('2026-01-02T00:00:00.000Z');
671+
const olderRunCreatedAt = new Date('2026-01-01T00:00:00.000Z');
672+
673+
await insertRun(automationId, latestRunCreatedAt);
674+
await insertRun(automationId, olderRunCreatedAt);
675+
676+
const browseResult = await repo.browse();
677+
const automation = browseResult.data.find(candidate => candidate.id === automationId);
678+
assert(automation);
679+
680+
assert.deepEqual(automation.stats.last_run_created_at, latestRunCreatedAt);
681+
});
682+
663683
it('creates missing default free and paid automations', async function () {
664684
const automationIds = await knex('automations')
665685
.whereIn('slug', ['member-welcome-email-free', 'member-welcome-email-paid'])

0 commit comments

Comments
 (0)