From c6ae72da0385cffbd576082e6edd1ea8c8410115 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 9 Apr 2026 11:24:42 -0500 Subject: [PATCH 1/7] Further highlight utility functions in database migration skill (#27253) ref 25e1433a622ca46484bfc26d0e694c90f7321e53 I found that my agent wouldn't use these utility functions by default. I tweaked the instructions to better highlight that. --- .agents/skills/create-database-migration/examples.md | 1 + .agents/skills/create-database-migration/rules.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/create-database-migration/examples.md b/.agents/skills/create-database-migration/examples.md index 5678ed9d6f0..63eb3d1822e 100644 --- a/.agents/skills/create-database-migration/examples.md +++ b/.agents/skills/create-database-migration/examples.md @@ -13,4 +13,5 @@ See [add source columns to emails table](../../../ghost/core/core/server/data/mi See [add member track source setting](../../../ghost/core/core/server/data/migrations/versions/5.21/2022-10-27-09-50-add-member-track-source-setting.js) ## Manipulate data + See [update newsletter subscriptions](../../../ghost/core/core/server/data/migrations/versions/5.31/2022-12-05-09-56-update-newsletter-subscriptions.js). diff --git a/.agents/skills/create-database-migration/rules.md b/.agents/skills/create-database-migration/rules.md index cefaf451b15..b41b5f0ed4e 100644 --- a/.agents/skills/create-database-migration/rules.md +++ b/.agents/skills/create-database-migration/rules.md @@ -14,7 +14,7 @@ Once migrations are on the `main` branch, they're final. If you need to make fur ## Use utility functions -Wherever possible, use the utility functions in `ghost/core/core/server/data/migrations/utils`. These util functions have been tested and already include protections for idempotency, as well as log statements where appropriate to make migrations easier to debug. +Wherever possible, use the utility functions in `ghost/core/core/server/data/migrations/utils`, such as `addTable`, `createTransactionalMigration`, and `addSetting`. These util functions have been tested and already include protections for idempotency, as well as log statements where appropriate to make migrations easier to debug. ## Migration PRs should be as minimal as possible From de754914183723e2805d2b487ffeb82aad17d458 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Thu, 9 Apr 2026 17:27:08 +0100 Subject: [PATCH 2/7] Added gift subscription notification columns to users table (#27304) ref https://linear.app/ghost/issue/BER-3523 Added gift subscription notification columns to the `users` table so a user can configure if they want to receive notifications for gift subscription related actions (purchase, redemption) --- .../utils/serializers/output/utils/clean.js | 2 + ...bscription-purchase-notification-column.js | 7 ++ ...cription-redemption-notification-column.js | 7 ++ ghost/core/core/server/data/schema/schema.js | 2 + ghost/core/core/server/models/user.js | 4 +- .../admin/__snapshots__/members.test.js.snap | 2 +- .../admin/__snapshots__/pages.test.js.snap | 12 +-- .../admin/__snapshots__/posts.test.js.snap | 28 +++--- .../admin/__snapshots__/users.test.js.snap | 92 ++++++++++++++++--- .../__snapshots__/pages.test.js.snap | 42 +++++++++ .../__snapshots__/posts.test.js.snap | 42 +++++++++ .../__snapshots__/authentication.test.js.snap | 12 ++- .../unit/server/data/schema/integrity.test.js | 2 +- 13 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-03-add-gift-subscription-purchase-notification-column.js create mode 100644 ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-08-add-gift-subscription-redemption-notification-column.js diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js index 406a0763d67..c1843694dd6 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js @@ -39,6 +39,8 @@ const author = (attrs, frame) => { delete attrs.recommendation_notifications; delete attrs.milestone_notifications; delete attrs.donation_notifications; + delete attrs.gift_subscription_purchase_notification; + delete attrs.gift_subscription_redemption_notification; // @NOTE: used for night shift delete attrs.accessibility; diff --git a/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-03-add-gift-subscription-purchase-notification-column.js b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-03-add-gift-subscription-purchase-notification-column.js new file mode 100644 index 00000000000..d25ac380667 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-03-add-gift-subscription-purchase-notification-column.js @@ -0,0 +1,7 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('users', 'gift_subscription_purchase_notification', { + type: 'boolean', + nullable: false, + defaultTo: true +}); diff --git a/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-08-add-gift-subscription-redemption-notification-column.js b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-08-add-gift-subscription-redemption-notification-column.js new file mode 100644 index 00000000000..184845db4af --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-15-50-08-add-gift-subscription-redemption-notification-column.js @@ -0,0 +1,7 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('users', 'gift_subscription_redemption_notification', { + type: 'boolean', + nullable: false, + defaultTo: true +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 2618d0c83f7..d82eaa118a2 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -182,6 +182,8 @@ module.exports = { recommendation_notifications: {type: 'boolean', nullable: false, defaultTo: true}, milestone_notifications: {type: 'boolean', nullable: false, defaultTo: true}, donation_notifications: {type: 'boolean', nullable: false, defaultTo: true}, + gift_subscription_purchase_notification: {type: 'boolean', nullable: false, defaultTo: true}, + gift_subscription_redemption_notification: {type: 'boolean', nullable: false, defaultTo: true}, created_at: {type: 'dateTime', nullable: false}, updated_at: {type: 'dateTime', nullable: true} }, diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index daeecd9c39f..7b1fd7df8b4 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -71,7 +71,9 @@ User = ghostBookshelf.Model.extend({ mention_notifications: true, recommendation_notifications: true, milestone_notifications: true, - donation_notifications: true + donation_notifications: true, + gift_subscription_purchase_notification: true, + gift_subscription_redemption_notification: true }; }, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index bdc36c48720..5bea87e7477 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -416,7 +416,7 @@ exports[`Members API - member attribution Returns sign up attributions of all ty Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8685", + "content-length": "8781", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap index 0e0e7cd1936..d4019f92df1 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap @@ -647,7 +647,7 @@ exports[`Pages API Convert can convert a mobiledoc page to lexical 2: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4325", + "content-length": "4517", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -753,7 +753,7 @@ exports[`Pages API Convert can convert a mobiledoc page to lexical 4: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4325", + "content-length": "4517", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -947,7 +947,7 @@ exports[`Pages API Copy Can copy a page 3: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4119", + "content-length": "4311", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1053,7 +1053,7 @@ exports[`Pages API Create Can create a page with html 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4349", + "content-length": "4541", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1333,7 +1333,7 @@ exports[`Pages API Update Access Visibility is set to tiers Saves only paid tier Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3754", + "content-length": "3946", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1438,7 +1438,7 @@ exports[`Pages API Update Can modify show_title_and_feature_image property 2: [h Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4120", + "content-length": "4312", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index 4185fbaa6af..a133a8dbc70 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -197,7 +197,7 @@ exports[`Posts API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11256", + "content-length": "11640", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -766,7 +766,7 @@ exports[`Posts API Can browse with formats 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14054", + "content-length": "14438", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -874,7 +874,7 @@ exports[`Posts API Convert can convert a mobiledoc post to lexical 2: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4362", + "content-length": "4554", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -983,7 +983,7 @@ exports[`Posts API Convert can convert a mobiledoc post to lexical 4: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4362", + "content-length": "4554", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1089,7 +1089,7 @@ exports[`Posts API Copy Can copy a post 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4154", + "content-length": "4346", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1198,7 +1198,7 @@ exports[`Posts API Create Can create a post with html 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4384", + "content-length": "4576", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1307,7 +1307,7 @@ exports[`Posts API Create Can create a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4396", + "content-length": "4588", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1416,7 +1416,7 @@ exports[`Posts API Create Can create a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4212", + "content-length": "4404", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1524,7 +1524,7 @@ exports[`Posts API Create invalidates preview cache when updating a draft post 1 Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4182", + "content-length": "4374", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2104,7 +2104,7 @@ exports[`Posts API Update Access Visibility is set to tiers Saves only paid tier Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3787", + "content-length": "3979", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2213,7 +2213,7 @@ exports[`Posts API Update Can update a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4347", + "content-length": "4539", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2322,7 +2322,7 @@ exports[`Posts API Update Can update a post with lexical 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4344", + "content-length": "4536", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2431,7 +2431,7 @@ exports[`Posts API Update Can update a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4157", + "content-length": "4349", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2540,7 +2540,7 @@ exports[`Posts API Update Can update a post with mobiledoc 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4154", + "content-length": "4346", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap index 5e469484070..8009014c797 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap @@ -37,6 +37,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -74,7 +76,7 @@ exports[`User API Can edit user roles by name 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1052", + "content-length": "1148", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -97,6 +99,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -134,7 +138,7 @@ exports[`User API Can edit user with empty roles data and does not change the ro Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1115", + "content-length": "1211", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -167,6 +171,8 @@ Object { "email": "inactive@test.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -205,6 +211,8 @@ Object { "email": "supersuper@ghost.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -243,6 +251,8 @@ Object { "email": "contributor@ghost.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -281,6 +291,8 @@ Object { "email": "smcectoplasm@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -319,6 +331,8 @@ Object { "email": "jbOgendAth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -357,6 +371,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -395,6 +411,8 @@ Object { "email": "ghost-author@example.com", "facebook": "ghost", "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -433,6 +451,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -468,7 +488,7 @@ exports[`User API Can include user roles 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8483", + "content-length": "9251", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -501,6 +521,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -538,6 +560,8 @@ Object { "email": "ghost-author@example.com", "facebook": "ghost", "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -575,6 +599,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -609,7 +635,7 @@ exports[`User API Can paginate users 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2856", + "content-length": "3144", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -702,6 +728,8 @@ Object { "email": "inactive@test.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -739,6 +767,8 @@ Object { "email": "supersuper@ghost.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -776,6 +806,8 @@ Object { "email": "contributor@ghost.org", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -813,6 +845,8 @@ Object { "email": "smcectoplasm@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -850,6 +884,8 @@ Object { "email": "jbOgendAth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -887,6 +923,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -924,6 +962,8 @@ Object { "email": "ghost-author@example.com", "facebook": "ghost", "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -961,6 +1001,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -995,7 +1037,7 @@ exports[`User API Can request all users ordered by id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "7124", + "content-length": "7892", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1018,6 +1060,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1052,7 +1096,7 @@ exports[`User API Can retrieve a user by email 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "881", + "content-length": "977", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1078,6 +1122,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1113,7 +1159,7 @@ exports[`User API Can retrieve a user by id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1128", + "content-length": "1224", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1136,6 +1182,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1170,7 +1218,7 @@ exports[`User API Can retrieve a user by slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "881", + "content-length": "977", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1193,6 +1241,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": Nullable, @@ -1240,6 +1290,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "6193c65ee792de832cd08130", "instagram": null, "last_seen": Nullable, @@ -1325,6 +1377,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1359,7 +1413,7 @@ exports[`User API Does not trigger cache invalidation when a private attribute o Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "950", + "content-length": "1046", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1382,6 +1436,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1416,7 +1472,7 @@ exports[`User API Does not trigger cache invalidation when no attribute on a use Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "982", + "content-length": "1078", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1439,6 +1495,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1473,7 +1531,7 @@ exports[`User API Does trigger cache invalidation when a social link on a user h Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "982", + "content-length": "1078", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1497,6 +1555,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1531,7 +1591,7 @@ exports[`User API can edit a user 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "949", + "content-length": "1045", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1555,6 +1615,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1590,7 +1652,7 @@ exports[`User API can edit a user fetched from the API 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1059", + "content-length": "1155", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1613,6 +1675,8 @@ Object { "email": "swellingsworth@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "instagram": null, "last_seen": Nullable, @@ -1648,7 +1712,7 @@ exports[`User API can edit a user fetched from the API 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1052", + "content-length": "1148", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap index 3c9d06908b8..8e760d128c8 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap @@ -26,6 +26,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -85,6 +87,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -196,6 +200,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -282,6 +288,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -356,6 +364,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -517,6 +527,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -591,6 +603,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -752,6 +766,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -826,6 +842,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -986,6 +1004,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1060,6 +1080,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1220,6 +1242,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1294,6 +1318,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1455,6 +1481,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1529,6 +1557,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1734,6 +1764,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1808,6 +1840,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1953,6 +1987,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2027,6 +2063,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2187,6 +2225,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2261,6 +2301,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap index afd4f8d642a..cc22d399f3b 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap @@ -26,6 +26,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -88,6 +90,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -198,6 +202,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -283,6 +289,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -359,6 +367,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -518,6 +528,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -615,6 +627,8 @@ Header Level 3 "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -776,6 +790,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -852,6 +868,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1011,6 +1029,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1087,6 +1107,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1246,6 +1268,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1322,6 +1346,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1482,6 +1508,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1579,6 +1607,8 @@ Header Level 3 "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1784,6 +1814,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -1881,6 +1913,8 @@ Header Level 3 "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2026,6 +2060,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2123,6 +2159,8 @@ Header Level 3 "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2283,6 +2321,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -2359,6 +2399,8 @@ Object { "email": "jbloggs@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, diff --git a/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap b/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap index bdd3684bfcf..026e0fe8e2f 100644 --- a/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap +++ b/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap @@ -14,6 +14,8 @@ Object { "email": "test@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": null, @@ -48,7 +50,7 @@ exports[`Authentication API Blog setup complete setup 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "853", + "content-length": "949", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -303,6 +305,8 @@ Object { "email": "test@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": null, @@ -337,7 +341,7 @@ exports[`Authentication API Blog setup complete setup with default theme 2: [hea Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "853", + "content-length": "949", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -607,6 +611,8 @@ Object { "email": "test-edit@example.com", "facebook": null, "free_member_signup_notification": true, + "gift_subscription_purchase_notification": true, + "gift_subscription_redemption_notification": true, "id": "5951f5fc0000000000000000", "instagram": null, "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -641,7 +647,7 @@ exports[`Authentication API Blog setup update setup 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "924", + "content-length": "1020", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index d164a9eaad7..6660288a49c 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '1aedfe928922388669e594aa6b762871'; + const currentSchemaHash = 'f209300a0c528851f89f2e6302410c85'; const currentFixturesHash = '2f86ab1e3820e86465f9ad738dd0ee93'; const currentSettingsHash = 'a102b80d2ab0cd92325ed007c94d7da6'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; From 01985615e676cfbbbd0b960af1182084b8b41516 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 9 Apr 2026 11:53:02 -0700 Subject: [PATCH 3/7] Added support for publication icon in the welcome emails renderer (#27268) closes https://linear.app/ghost/issue/NY-1214/ This wires up the welcome email renderer to conditionally include the site's publication icon in the header. If the publication icon exists, and the `show_header_icon` email design setting is enabled, it will render the publication icon in the welcome emails. This PR originally focused on updates to the renderer; for easier shipping, another PR was merged into it to add the toggle to the customize modal. --- .../src/api/automated-email-design.ts | 1 + .../unit/api/automated-email-design.test.tsx | 1 + .../settings/email-design/email-preview.tsx | 34 ++++--- .../welcome-email-customize-modal.tsx | 28 +++++- .../membership/member-welcome-emails.test.ts | 90 ++++++++++++++++++- .../unit/email-design/design-payload.test.ts | 6 ++ .../member-welcome-email-renderer.js | 6 +- .../services/member-welcome-emails/service.js | 7 +- .../member-welcome-email-renderer.test.js | 66 +++++++++++++- 9 files changed, 221 insertions(+), 18 deletions(-) diff --git a/apps/admin-x-framework/src/api/automated-email-design.ts b/apps/admin-x-framework/src/api/automated-email-design.ts index 34b10ee6b53..b1005759a82 100644 --- a/apps/admin-x-framework/src/api/automated-email-design.ts +++ b/apps/admin-x-framework/src/api/automated-email-design.ts @@ -6,6 +6,7 @@ export type AutomatedEmailDesign = { background_color: string; header_background_color: string; header_image: string | null; + show_header_icon: boolean; show_header_title: boolean; footer_content: string | null; button_color: string | null; diff --git a/apps/admin-x-framework/test/unit/api/automated-email-design.test.tsx b/apps/admin-x-framework/test/unit/api/automated-email-design.test.tsx index c2f1456a028..be4a12f366f 100644 --- a/apps/admin-x-framework/test/unit/api/automated-email-design.test.tsx +++ b/apps/admin-x-framework/test/unit/api/automated-email-design.test.tsx @@ -16,6 +16,7 @@ describe('automated-email-design api', () => { background_color: 'light', header_background_color: 'transparent', header_image: null, + show_header_icon: true, show_header_title: true, footer_content: null, button_color: null, diff --git a/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx b/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx index f22673d074a..a4471b514d1 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx @@ -15,6 +15,7 @@ interface EmailPreviewProps { showRecipientLine?: boolean; showSubjectLine?: boolean; headerImage?: string; + showPublicationIcon?: boolean; showPublicationTitle?: boolean; showBadge?: boolean; emailFooter?: string; @@ -64,12 +65,14 @@ const EnvelopeHeader: React.FC<{ }; const PublicationHeader: React.FC<{ + iconUrl?: string | null; + showIcon: boolean; showTitle: boolean; siteTitle?: string; backgroundColor?: string; textColor: string; -}> = ({showTitle, siteTitle, backgroundColor, textColor}) => { - if (!showTitle || !siteTitle) { +}> = ({iconUrl, showIcon, showTitle, siteTitle, backgroundColor, textColor}) => { + if (!showIcon && (!showTitle || !siteTitle)) { return null; } @@ -78,12 +81,21 @@ const PublicationHeader: React.FC<{ className="px-[7rem] py-3 text-center" style={{backgroundColor: backgroundColor === 'transparent' ? undefined : backgroundColor}} > -

- {siteTitle} -

+ {showIcon && iconUrl && ( + {siteTitle + )} + {showTitle && siteTitle && ( +

+ {siteTitle} +

+ )} ); }; @@ -115,9 +127,9 @@ const Footer: React.FC<{siteTitle?: string; footerLinkText?: string; emailFooter // --- Main component --- -const EmailPreview: React.FC = ({settings, senderName, senderEmail, replyToEmail, subject, showRecipientLine = true, showSubjectLine = true, headerImage, showPublicationTitle = true, showBadge = true, emailFooter, footerLinkText, children}) => { +const EmailPreview: React.FC = ({settings, senderName, senderEmail, replyToEmail, subject, showRecipientLine = true, showSubjectLine = true, headerImage, showPublicationIcon = false, showPublicationTitle = true, showBadge = true, emailFooter, footerLinkText, children}) => { const {settings: globalSettings, siteData} = useGlobalData(); - const [siteTitle] = getSettingValues(globalSettings, ['title']); + const [siteTitle, icon] = getSettingValues(globalSettings, ['title', 'icon']); const accentColor = siteData.accent_color; const colors = resolveAllColors(settings, accentColor); @@ -140,6 +152,8 @@ const EmailPreview: React.FC = ({settings, senderName, sender ) => void; + showPublicationIconToggle: boolean; senderNamePlaceholder: string; senderEmailPlaceholder: string; replyToEmailPlaceholder: string; @@ -79,6 +82,7 @@ interface GeneralTabProps { const GeneralTab: React.FC = ({ generalSettings, onGeneralChange, + showPublicationIconToggle, senderNamePlaceholder, senderEmailPlaceholder, replyToEmailPlaceholder, @@ -135,6 +139,16 @@ const GeneralTab: React.FC = ({ value={generalSettings.headerImage} onChange={url => onGeneralChange({headerImage: url})} /> + {showPublicationIconToggle && ( +
+ Publication icon + onGeneralChange({showPublicationIcon: checked})} + /> +
+ )}
Publication title ( interface SidebarProps { generalSettings: GeneralSettings; onGeneralChange: (updates: Partial) => void; + showPublicationIconToggle: boolean; senderNamePlaceholder: string; senderEmailPlaceholder: string; replyToEmailPlaceholder: string; @@ -218,6 +233,7 @@ interface SidebarProps { const Sidebar: React.FC = ({ generalSettings, onGeneralChange, + showPublicationIconToggle, senderNamePlaceholder, senderEmailPlaceholder, replyToEmailPlaceholder, @@ -252,6 +268,7 @@ const Sidebar: React.FC = ({ senderEmailPlaceholder={senderEmailPlaceholder} senderNameError={senderNameError} senderNamePlaceholder={senderNamePlaceholder} + showPublicationIconToggle={showPublicationIconToggle} showSenderEmailInput={showSenderEmailInput} onGeneralChange={onGeneralChange} /> @@ -268,12 +285,12 @@ const Sidebar: React.FC = ({ * Maps API response fields to the frontend GeneralSettings shape. * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint. * - * @param {Pick} apiData - Subset of design fields used for general settings + * @param {Pick} apiData - Subset of design fields used for general settings * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API * @returns {GeneralSettings} General settings populated from the API response */ function mapApiToGeneralSettings( - apiData: Pick, + apiData: Pick, defaults: GeneralSettings ): GeneralSettings { return { @@ -281,6 +298,7 @@ function mapApiToGeneralSettings( senderEmail: defaults.senderEmail, replyToEmail: defaults.replyToEmail, headerImage: apiData.header_image || '', + showPublicationIcon: apiData.show_header_icon, showPublicationTitle: apiData.show_header_title, showBadge: apiData.show_badge, emailFooter: apiData.footer_content || '' @@ -316,6 +334,7 @@ export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFor return { ...persistedDesign, header_image: state.generalSettings.headerImage || null, + show_header_icon: state.generalSettings.showPublicationIcon, show_header_title: state.generalSettings.showPublicationTitle, show_badge: state.generalSettings.showBadge, footer_content: state.generalSettings.emailFooter || null @@ -336,7 +355,7 @@ const normalizeSenderValue = (value: string | null | undefined) => { const WelcomeEmailCustomizeModal = NiceModal.create(() => { const modal = useModal(); const {siteData, settings: globalSettings} = useGlobalData(); - const [siteTitle, defaultEmailAddress] = getSettingValues(globalSettings, ['title', 'default_email_address']); + const [siteTitle, defaultEmailAddress, icon] = getSettingValues(globalSettings, ['title', 'default_email_address', 'icon']); const handleError = useHandleError(); const {data: designData, isLoading, isError} = useReadAutomatedEmailDesign(); @@ -364,6 +383,7 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { senderEmail: senderEmailInput, replyToEmail: replyToEmailInput, headerImage: '', + showPublicationIcon: true, showPublicationTitle: true, showBadge: true, emailFooter: '' @@ -532,6 +552,7 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { senderName={generalSettings.senderName || senderNamePlaceholder || siteTitle || 'Your site'} settings={designSettings} showBadge={generalSettings.showBadge} + showPublicationIcon={generalSettings.showPublicationIcon && Boolean(icon)} showPublicationTitle={generalSettings.showPublicationTitle} showRecipientLine={false} showSubjectLine={false} @@ -551,6 +572,7 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { senderEmailPlaceholder={senderEmailPlaceholder} senderNameError={errors.senderName} senderNamePlaceholder={senderNamePlaceholder} + showPublicationIconToggle={Boolean(icon)} showSenderEmailInput={showSenderEmailInput} onGeneralChange={handleGeneralChange} /> diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts index b3507c14911..88e034dc86b 100644 --- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts @@ -1,5 +1,5 @@ import {expect, test} from '@playwright/test'; -import {globalDataRequests, mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance'; +import {globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '@tryghost/admin-x-framework/test/acceptance'; import type {Page} from '@playwright/test'; /** @@ -76,6 +76,7 @@ const automatedEmailDesignFixture = { background_color: 'light', header_background_color: 'transparent', header_image: null, + show_header_icon: true, show_header_title: true, footer_content: null, button_color: null, @@ -95,6 +96,10 @@ const automatedEmailDesignFixture = { }] }; +const settingsWithPublicationIcon = updatedSettingsResponse([ + {key: 'icon', value: 'https://example.com/content/images/icon.png'} +]); + const pasteText = async (page: Page, content: string) => { await page.evaluate((text: string) => { const dataTransfer = new DataTransfer(); @@ -630,6 +635,89 @@ test.describe('Member emails settings', async () => { }); test.describe('Welcome email customize modal sender fields', async () => { + test('shows publication icon toggle, updates preview, and saves show_header_icon when an icon exists', async ({page}) => { + const addPaidResponse = { + automated_emails: [{ + id: 'paid-welcome-email-id', + status: 'inactive', + name: 'Welcome Email (Paid)', + slug: 'member-welcome-email-paid', + subject: 'Welcome to your paid subscription', + lexical: '{"root":{"children":[]}}', + sender_name: null, + sender_email: null, + sender_reply_to: null, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: null + }] + }; + + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + browseSettings: {...globalDataRequests.browseSettings, response: settingsWithPublicationIcon}, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture}, + editAutomatedEmailDesign: {method: 'PUT', path: '/automated_emails/design/', response: automatedEmailDesignFixture}, + addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: addPaidResponse}, + editAutomatedEmailSenders: { + method: 'PUT', + path: /^\/automated_emails\/senders\/?$/, + response: {automated_emails: automatedEmailsFixture.automated_emails} + } + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + const publicationIconSwitch = modal.getByText('Publication icon').locator('..').getByRole('switch'); + await expect(publicationIconSwitch).toBeVisible(); + await expect(modal.locator('img[alt="Test Site"]').first()).toBeVisible(); + + await publicationIconSwitch.click(); + + await expect(modal.locator('img[alt="Test Site"]')).toHaveCount(0); + + await modal.getByRole('button', {name: 'Save'}).click(); + + await expect.poll(() => lastApiRequests.editAutomatedEmailDesign?.body).toMatchObject({ + automated_email_design: [{ + show_header_icon: false + }] + }); + }); + + test('hides publication icon toggle when no publication icon is set', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + await expect(modal.getByText('Publication icon')).toHaveCount(0); + await expect(modal.locator('img[alt="Test Site"]')).toHaveCount(0); + }); + test('uses placeholders when no automated sender overrides exist', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, diff --git a/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts b/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts index b48856e4592..2a6ea10305d 100644 --- a/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts +++ b/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts @@ -10,6 +10,7 @@ describe('Welcome email design payload helpers', function () { created_at: '2026-04-02T00:00:00.000Z', updated_at: '2026-04-02T00:00:00.000Z', header_image: null, + show_header_icon: true, show_header_title: true, show_badge: true, footer_content: null, @@ -23,6 +24,7 @@ describe('Welcome email design payload helpers', function () { assert.equal('created_at' in result, false); assert.equal('updated_at' in result, false); assert.equal('header_image' in result, false); + assert.equal('show_header_icon' in result, false); assert.equal('show_header_title' in result, false); assert.equal('show_badge' in result, false); assert.equal('footer_content' in result, false); @@ -59,8 +61,10 @@ describe('Welcome email design payload helpers', function () { }, generalSettings: { senderName: 'Ghost', + senderEmail: 'hello@example.com', replyToEmail: 'support@example.com', headerImage: '', + showPublicationIcon: true, showPublicationTitle: true, showBadge: false, emailFooter: '' @@ -69,6 +73,7 @@ describe('Welcome email design payload helpers', function () { const payload = buildAutomatedEmailDesignPayload(state as never) as typeof state.designSettings & { header_image: string | null; + show_header_icon: boolean; show_header_title: boolean; show_badge: boolean; footer_content: string | null; @@ -81,5 +86,6 @@ describe('Welcome email design payload helpers', function () { assert.equal('updated_at' in payload, false); assert.equal('post_title_color' in payload, false); assert.equal('title_alignment' in payload, false); + assert.equal(payload.show_header_icon, true); }); }); diff --git a/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js b/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js index 413fbabd355..840c8f76ce8 100644 --- a/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +++ b/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js @@ -202,6 +202,7 @@ class MemberWelcomeEmailRenderer { const managePreferencesUrl = new URL('#/portal/account/newsletters', siteSettings.url).href; const year = new Date().getFullYear(); const headerImage = useDesignCustomization ? (designSettings.header_image || null) : null; + const showHeaderIcon = useDesignCustomization ? designSettings.show_header_icon !== false && Boolean(siteSettings.iconUrl) : false; const showHeaderTitle = useDesignCustomization ? designSettings.show_header_title !== false : false; const showBadge = useDesignCustomization ? designSettings.show_badge !== false : false; const bodyFontCategory = designSettings.body_font_category === 'serif' ? 'serif' : 'sans_serif'; @@ -211,13 +212,14 @@ class MemberWelcomeEmailRenderer { emailTitle: subjectWithReplacements, subject: subjectWithReplacements, footerContent: useDesignCustomization ? designSettings.footer_content : null, - hasHeaderContent: Boolean(headerImage || showHeaderTitle), + hasHeaderContent: Boolean(headerImage || showHeaderIcon || showHeaderTitle), headerImage, showBadge, - showHeaderIcon: false, + showHeaderIcon, showHeaderName: false, showHeaderTitle, site: { + iconUrl: siteSettings.iconUrl || null, title: siteSettings.title, url: siteSettings.url }, diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js index f9c86b726e0..f1009b59475 100644 --- a/ghost/core/core/server/services/member-welcome-emails/service.js +++ b/ghost/core/core/server/services/member-welcome-emails/service.js @@ -79,10 +79,15 @@ class MemberWelcomeEmailService { } #getSiteSettings() { + const icon = settingsCache.get('icon'); + return { title: settingsCache.get('title') || 'Ghost', url: urlUtils.urlFor('home', true), - accentColor: settingsCache.get('accent_color') || '#15212A' + accentColor: settingsCache.get('accent_color') || '#15212A', + iconUrl: icon ? urlUtils.urlFor('image', { + image: icon + }, true) : null }; } diff --git a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js index 34d10ec9f98..287b98e2c79 100644 --- a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js +++ b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js @@ -12,7 +12,8 @@ describe('MemberWelcomeEmailRenderer', function () { const defaultSiteSettings = { title: 'Test Site', url: 'https://example.com', - accentColor: '#ff0000' + accentColor: '#ff0000', + iconUrl: 'https://example.com/content/images/icon.png' }; beforeEach(function () { @@ -577,6 +578,69 @@ describe('MemberWelcomeEmailRenderer', function () { assert(result.html.includes('https://ghost.org/?via=pbg-newsletter')); }); + it('renders the publication icon when enabled and a site icon exists', async function () { + lexicalRenderStub.resolves('

Content

'); + const renderer = new MemberWelcomeEmailRenderer({t: key => key}); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Test Subject', + designSettings: { + show_header_icon: true, + show_header_title: false + }, + member: {name: 'John', email: 'john@example.com'}, + siteSettings: defaultSiteSettings + }); + + assert(result.html.includes('class="header"')); + assert(result.html.includes('class="site-icon"')); + assert(result.html.includes('src="https://example.com/content/images/icon.png"')); + }); + + it('does not render the publication icon when disabled', async function () { + lexicalRenderStub.resolves('

Content

'); + const renderer = new MemberWelcomeEmailRenderer({t: key => key}); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Test Subject', + designSettings: { + show_header_icon: false, + show_header_title: false + }, + member: {name: 'John', email: 'john@example.com'}, + siteSettings: defaultSiteSettings + }); + + assert(!result.html.includes('class="header"')); + assert(!result.html.includes('class="site-icon"')); + assert(!result.html.includes('src="https://example.com/content/images/icon.png"')); + }); + + it('does not render the publication icon when the site icon is missing', async function () { + lexicalRenderStub.resolves('

Content

'); + const renderer = new MemberWelcomeEmailRenderer({t: key => key}); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Test Subject', + designSettings: { + show_header_icon: true, + show_header_title: false + }, + member: {name: 'John', email: 'john@example.com'}, + siteSettings: { + ...defaultSiteSettings, + iconUrl: null + } + }); + + assert(!result.html.includes('class="header"')); + assert(!result.html.includes('class="site-icon"')); + assert(!result.html.includes('content/images/icon.png')); + }); + it('uses the sans-serif content-shell class by default when design customization is enabled', async function () { lexicalRenderStub.resolves('

Content

'); const renderer = new MemberWelcomeEmailRenderer({t: key => key}); From 7e569952a8694073c4aa78717d1f3c0350a13b31 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 9 Apr 2026 11:53:29 -0700 Subject: [PATCH 4/7] Changed welcome email defaults for existing sites (#27270) closes https://linear.app/ghost/issue/NY-1210/ - enabling design customization at GA would otherwise add publication title and icon to existing welcome emails without user action - limiting the data backfill to sites that already have welcome email automations preserves current output for existing installs - leaving new and not-yet-enabled sites on the default values keeps the intended first-run behavior intact --- ...ader-fields-for-existing-welcome-emails.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-02-30-06-disable-default-header-fields-for-existing-welcome-emails.js diff --git a/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-02-30-06-disable-default-header-fields-for-existing-welcome-emails.js b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-02-30-06-disable-default-header-fields-for-existing-welcome-emails.js new file mode 100644 index 00000000000..a4926e2c514 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.28/2026-04-09-02-30-06-disable-default-header-fields-for-existing-welcome-emails.js @@ -0,0 +1,39 @@ +const logging = require('@tryghost/logging'); +const {MigrationError} = require('@tryghost/errors'); +const {createTransactionalMigration} = require('../../utils'); + +const DEFAULT_SLUG = 'default-automated-email'; + +module.exports = createTransactionalMigration( + async function up(knex) { + const existingWelcomeEmail = await knex('welcome_email_automations') + .first('id'); + + if (!existingWelcomeEmail) { + logging.info('No welcome email automations found, leaving default header fields enabled'); + return; + } + + const defaultEmailDesignSetting = await knex('email_design_settings') + .where({slug: DEFAULT_SLUG}) + .first(); + + if (!defaultEmailDesignSetting) { + throw new MigrationError({ + message: `Missing default email_design_settings row for slug: ${DEFAULT_SLUG}` + }); + } + + logging.info('Disabling default welcome email publication title and icon for existing sites'); + + await knex('email_design_settings') + .where({id: defaultEmailDesignSetting.id}) + .update({ + show_header_title: false, + show_header_icon: false + }); + }, + async function down() { + // no-op: we don't want to re-enable these fields and overwrite later user choices + } +); From 37cd0df3bb14f5d411980a14d2d7a58d33fa07bc Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 9 Apr 2026 13:04:42 -0700 Subject: [PATCH 5/7] Fixed welcome email customization closing without a save warning (#27315) closes https://linear.app/ghost/issue/NY-1218/ ## What this changes - fixed the welcome email customization modal so `Escape` no longer bypasses the dirty-state flow - kept Escape handling local to the welcome email UI instead of changing shared Shade overlay behavior globally - fixed nested color picker popovers so the first `Escape` closes the picker without also opening the unsaved-changes dialog - fixed nested font select menus so the first `Escape` closes the menu without also opening the unsaved-changes dialog - kept the confirmation dialog scoped so its own `Escape` handling does not cascade back into the parent modal ## Why The original bug was not limited to the top-level customize modal. The same Escape propagation issue also showed up in nested portalled controls inside the modal, which could still skip straight to the save warning flow. This PR now covers the full interaction stack so `Escape` always closes the topmost UI first. ## Testing - added E2E coverage for the customize modal dirty-state Escape flow - added E2E coverage for the color picker Escape flow - added E2E coverage for the font select Escape flow - ran `PLAYWRIGHT_HTML_OPEN=never yarn workspace @tryghost/e2e test e2e/tests/admin/settings/member-welcome-emails.test.ts` - ran `PLAYWRIGHT_HTML_OPEN=never yarn workspace @tryghost/e2e test:single "Escape closes welcome email"` --- .../email-design/color-picker-field.tsx | 8 +- .../design-fields/body-font-field.tsx | 6 +- .../design-fields/heading-font-field.tsx | 6 +- .../design-fields/heading-weight-field.tsx | 6 +- .../email-design/email-design-modal.tsx | 11 ++- .../sections/member-welcome-emails-section.ts | 12 +++ .../settings/member-welcome-emails.test.ts | 87 +++++++++++++++++++ 7 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx index 65eb18d86f4..222e1d10b56 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx @@ -114,7 +114,13 @@ const ColorPickerField: React.FC = ({title, value, onChan
- + { + event.stopPropagation(); + }} + >
{ allowPickerChanges.current = true; diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-fields/body-font-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/design-fields/body-font-field.tsx index 41a59a4ffcf..2249c8be3e8 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/design-fields/body-font-field.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/design-fields/body-font-field.tsx @@ -11,7 +11,11 @@ export const BodyFontField = () => { - + { + event.stopPropagation(); + }} + > {FONT_OPTIONS.map(opt => ( {opt.label} ))} diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-font-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-font-field.tsx index e279d898121..5c8f9f44dd2 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-font-field.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-font-field.tsx @@ -15,7 +15,11 @@ export const HeadingFontField = () => { - + { + event.stopPropagation(); + }} + > {FONT_OPTIONS.map(opt => ( {opt.label} ))} diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-weight-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-weight-field.tsx index 0bda97c019d..9cbe64c184c 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-weight-field.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/design-fields/heading-weight-field.tsx @@ -14,7 +14,11 @@ export const HeadingWeightField = () => { - + { + event.stopPropagation(); + }} + > {weightOptions.map(opt => ( {opt.label} ))} diff --git a/apps/admin-x-settings/src/components/settings/email-design/email-design-modal.tsx b/apps/admin-x-settings/src/components/settings/email-design/email-design-modal.tsx index d7d5d3fdaf7..ebb178e6545 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/email-design-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/email-design-modal.tsx @@ -73,6 +73,11 @@ const EmailDesignModal: React.FC = ({ 'top-[50%] left-[50%] h-[calc(100vh-8vmin)] w-[calc(100vw-8vmin)] max-w-none translate-x-[-50%] translate-y-[-50%] gap-0 overflow-hidden p-0' )} data-testid={testId} + onEscapeKeyDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleClose(); + }} >
{/* Left: Preview */} @@ -104,7 +109,11 @@ const EmailDesignModal: React.FC = ({ - + { + event.stopPropagation(); + }} + > Are you sure you want to leave this page? diff --git a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts index abdbb824137..e892c115eba 100644 --- a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts @@ -13,6 +13,7 @@ export class MemberWelcomeEmailsSection extends BasePage { readonly customizeModal: Locator; readonly customizeModalSaveButton: Locator; readonly customizeModalCloseButton: Locator; + readonly customizeModalUnsavedChangesDialog: Locator; readonly customizeModalGeneralTab: Locator; readonly customizeModalDesignTab: Locator; @@ -26,6 +27,11 @@ export class MemberWelcomeEmailsSection extends BasePage { readonly customizeModalButtonStyleFill: Locator; readonly customizeModalButtonStyleOutline: Locator; readonly customizeModalBodyFontSelect: Locator; + readonly customizeModalBodyFontSerifOption: Locator; + readonly customizeModalButtonColorField: Locator; + readonly customizeModalButtonColorPickerTrigger: Locator; + readonly customizeModalButtonColorAccentSwatch: Locator; + readonly customizeModalColorPickerPopover: Locator; // Modal locators readonly welcomeEmailModal: Locator; @@ -48,6 +54,7 @@ export class MemberWelcomeEmailsSection extends BasePage { this.customizeModal = page.getByTestId('welcome-email-customize-modal'); this.customizeModalSaveButton = this.customizeModal.getByRole('button', {name: 'Save'}); this.customizeModalCloseButton = this.customizeModal.getByRole('button', {name: 'Close'}); + this.customizeModalUnsavedChangesDialog = page.getByRole('alertdialog', {name: 'Are you sure you want to leave this page?'}); this.customizeModalGeneralTab = this.customizeModal.getByRole('tab', {name: 'General'}); this.customizeModalDesignTab = this.customizeModal.getByRole('tab', {name: 'Design'}); @@ -61,6 +68,11 @@ export class MemberWelcomeEmailsSection extends BasePage { this.customizeModalButtonStyleFill = this.customizeModal.getByLabel('Fill'); this.customizeModalButtonStyleOutline = this.customizeModal.getByLabel('Outline'); this.customizeModalBodyFontSelect = this.customizeModal.getByText('Body font').locator('..').getByRole('combobox'); + this.customizeModalBodyFontSerifOption = page.getByRole('option', {name: 'Elegant serif', exact: true}); + this.customizeModalButtonColorField = this.customizeModal.getByText('Button color').locator('..'); + this.customizeModalButtonColorPickerTrigger = this.customizeModalButtonColorField.getByRole('button', {name: 'Pick color'}); + this.customizeModalButtonColorAccentSwatch = this.customizeModal.getByRole('button', {name: 'Accent'}); + this.customizeModalColorPickerPopover = page.locator('[data-radix-popper-content-wrapper]'); // Modal locators this.welcomeEmailModal = page.getByTestId('welcome-email-modal'); diff --git a/e2e/tests/admin/settings/member-welcome-emails.test.ts b/e2e/tests/admin/settings/member-welcome-emails.test.ts index a5e0d59fc67..1a19965e0a6 100644 --- a/e2e/tests/admin/settings/member-welcome-emails.test.ts +++ b/e2e/tests/admin/settings/member-welcome-emails.test.ts @@ -239,6 +239,93 @@ test.describe('Ghost Admin - Welcome Email Customize Button - flag enabled', () await expect(welcomeEmailsSection.customizeModalFooterTextarea).toHaveValue('Persisted footer'); }); + test('Escape shows unsaved changes confirmation for welcome email customization', async ({page}) => { + const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); + + await welcomeEmailsSection.goto(); + await welcomeEmailsSection.openCustomizeModal(); + + await welcomeEmailsSection.customizeModalFooterTextarea.fill('Unsaved footer change'); + await expect(welcomeEmailsSection.customizeModalFooterTextarea).toHaveValue('Unsaved footer change'); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + }); + + test('Escape closes welcome email customization confirmation without closing the customize modal', async ({page}) => { + const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); + + await welcomeEmailsSection.goto(); + await welcomeEmailsSection.openCustomizeModal(); + + await welcomeEmailsSection.customizeModalFooterTextarea.fill('Unsaved footer change'); + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeHidden(); + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + }); + + test('Escape closes welcome email color picker without bypassing unsaved changes confirmation', async ({page}) => { + const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); + + await welcomeEmailsSection.goto(); + await welcomeEmailsSection.openCustomizeModal(); + await welcomeEmailsSection.switchToDesignTab(); + + await welcomeEmailsSection.customizeModalButtonColorPickerTrigger.click(); + await expect(welcomeEmailsSection.customizeModalButtonColorAccentSwatch).toBeVisible(); + await welcomeEmailsSection.customizeModalButtonColorAccentSwatch.click(); + + await welcomeEmailsSection.customizeModalButtonColorPickerTrigger.click(); + await expect(welcomeEmailsSection.customizeModalButtonColorAccentSwatch).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalButtonColorAccentSwatch).toBeHidden(); + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeHidden(); + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeVisible(); + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + }); + + test('Escape closes welcome email font select without bypassing unsaved changes confirmation', async ({page}) => { + const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); + + await welcomeEmailsSection.goto(); + await welcomeEmailsSection.openCustomizeModal(); + await welcomeEmailsSection.customizeModalFooterTextarea.fill('Unsaved footer change'); + await welcomeEmailsSection.switchToDesignTab(); + + await welcomeEmailsSection.customizeModalBodyFontSelect.click(); + await expect(welcomeEmailsSection.customizeModalBodyFontSerifOption).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalBodyFontSerifOption).toBeHidden(); + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeHidden(); + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + + await page.keyboard.press('Escape'); + + await expect(welcomeEmailsSection.customizeModalUnsavedChangesDialog).toBeVisible(); + await expect(welcomeEmailsSection.customizeModal).toBeVisible(); + await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); + }); + test('customized design is applied to the free member welcome email', async ({page, browser, baseURL}) => { const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); const emailClient = new MailPit(); From 75ec35f8ae360a00ac5989a652d339b75c416a45 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 9 Apr 2026 22:32:20 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20deep-linking=20to=20?= =?UTF-8?q?Admin=20screens=20whilst=20logged=20out=20(#27316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/ONC-1623/ When deep-linking to an admin route (e.g. `/ghost/#/tags`) while logged out, the page rendered blank instead of showing the signin screen. This was caused by the `react-fallback` catch-all route lacking the authentication check that was previously on the now-removed Ember route files. - Added `react-fallback` route extending `AuthenticatedRoute` to enforce signin for all React-rendered routes - Persisted intended URL in `sessionStorage` before the auth redirect so the user is returned to their original destination after signing in (works for both React and Ember routes) - Made `LoginPage.logout()` wait for the signin page to fully load before resolving to avoid race conditions with subsequent navigations in e2e tests - Added e2e tests covering signin redirect for both React (`/tags`) and Ember (`/posts`) routes --- e2e/helpers/pages/admin/login-page.ts | 1 + e2e/helpers/pages/admin/posts/posts-page.ts | 5 +++ e2e/tests/admin/signin.test.ts | 38 +++++++++++++++++++ ghost/admin/app/routes/authenticated.js | 9 +++++ ghost/admin/app/routes/react-fallback.js | 3 ++ ghost/admin/app/services/session.js | 7 ++++ .../tests/acceptance/authentication-test.js | 4 +- 7 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/admin/signin.test.ts create mode 100644 ghost/admin/app/routes/react-fallback.js diff --git a/e2e/helpers/pages/admin/login-page.ts b/e2e/helpers/pages/admin/login-page.ts index 982bd15abde..dff931af3b3 100644 --- a/e2e/helpers/pages/admin/login-page.ts +++ b/e2e/helpers/pages/admin/login-page.ts @@ -33,6 +33,7 @@ export class LoginPage extends AdminPage { async logout() { await this.page.goto('/ghost/#/signout'); + await this.signInButton.waitFor({state: 'visible'}); } async waitForLoginPageAfterUserCreated(): Promise { diff --git a/e2e/helpers/pages/admin/posts/posts-page.ts b/e2e/helpers/pages/admin/posts/posts-page.ts index cac588a4309..14a5a4dc1b2 100644 --- a/e2e/helpers/pages/admin/posts/posts-page.ts +++ b/e2e/helpers/pages/admin/posts/posts-page.ts @@ -44,6 +44,11 @@ export class PostsPage extends AdminPage { return this.postsListItem.filter({has: this.page.getByRole('heading', {name: title, exact: true, level: 3})}); } + async waitForPageToFullyLoad() { + await this.page.waitForURL(this.pageUrl); + await this.postsList.waitFor({state: 'visible'}); + } + async refreshData() { await this.page.reload(); } diff --git a/e2e/tests/admin/signin.test.ts b/e2e/tests/admin/signin.test.ts new file mode 100644 index 00000000000..0233c08524a --- /dev/null +++ b/e2e/tests/admin/signin.test.ts @@ -0,0 +1,38 @@ +import {LoginPage, PostsPage, TagsPage} from '@/admin-pages'; +import {Page} from '@playwright/test'; +import {expect, test} from '@/helpers/playwright'; + +test.describe('Ghost Admin - Signin Redirect', () => { + async function logout(page: Page) { + const loginPage = new LoginPage(page); + await loginPage.logout(); + } + + test('deep-linking to a React route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => { + await logout(page); + + const tagsPage = new TagsPage(page); + await tagsPage.goto(); + + const loginPage = new LoginPage(page); + await expect(loginPage.signInButton).toBeVisible(); + + await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password); + + await tagsPage.waitForPageToFullyLoad(); + }); + + test('deep-linking to an Ember route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => { + await logout(page); + + const postsPage = new PostsPage(page); + await postsPage.goto(); + + const loginPage = new LoginPage(page); + await expect(loginPage.signInButton).toBeVisible(); + + await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password); + + await postsPage.waitForPageToFullyLoad(); + }); +}); diff --git a/ghost/admin/app/routes/authenticated.js b/ghost/admin/app/routes/authenticated.js index 10bcc3a5186..4a02bb186d5 100644 --- a/ghost/admin/app/routes/authenticated.js +++ b/ghost/admin/app/routes/authenticated.js @@ -7,6 +7,15 @@ export default class AuthenticatedRoute extends Route { @service session; async beforeModel(transition) { + if (!this.session.isAuthenticated) { + const url = transition.intent?.url; + if (url) { + window.sessionStorage.setItem('ghost-signin-redirect', url); + } + } else { + window.sessionStorage.removeItem('ghost-signin-redirect'); + } + this.session.requireAuthentication(transition, () => { windowProxy.replaceLocation(AuthConfiguration.rootURL); }); diff --git a/ghost/admin/app/routes/react-fallback.js b/ghost/admin/app/routes/react-fallback.js new file mode 100644 index 00000000000..449876bb1f5 --- /dev/null +++ b/ghost/admin/app/routes/react-fallback.js @@ -0,0 +1,3 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; + +export default class ReactFallbackRoute extends AuthenticatedRoute {} diff --git a/ghost/admin/app/services/session.js b/ghost/admin/app/services/session.js index 7fc771a7ed8..5ff594b8a65 100644 --- a/ghost/admin/app/services/session.js +++ b/ghost/admin/app/services/session.js @@ -88,6 +88,13 @@ export default class SessionService extends ESASessionService { return; } + const redirectUrl = window.sessionStorage.getItem('ghost-signin-redirect'); + window.sessionStorage.removeItem('ghost-signin-redirect'); + if (redirectUrl && !redirectUrl.startsWith('/signin') && !redirectUrl.startsWith('/signup') && !redirectUrl.startsWith('/setup')) { + this.router.transitionTo(redirectUrl); + return; + } + super.handleAuthentication('home'); }); } diff --git a/ghost/admin/tests/acceptance/authentication-test.js b/ghost/admin/tests/acceptance/authentication-test.js index 6498d6cecc5..b63d4fb3b9b 100644 --- a/ghost/admin/tests/acceptance/authentication-test.js +++ b/ghost/admin/tests/acceptance/authentication-test.js @@ -183,8 +183,8 @@ describe('Acceptance: Authentication', function () { await visit('/signin/invalidurl/'); - expect(currentURL(), 'url after invalid url').to.equal('/signin/invalidurl/'); - expect(currentRouteName(), 'path after invalid url').to.equal('react-fallback'); + expect(currentURL(), 'url after invalid url').to.equal('/signin'); + expect(currentRouteName(), 'path after invalid url').to.equal('signin'); expect(findAll('nav.gh-nav').length, 'nav menu presence').to.equal(0); }); From 6813bd463ac39e822df6ffe7fa974ad0cb7aa409 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 9 Apr 2026 14:55:21 -0700 Subject: [PATCH 7/7] Disabled playwright test report server auto-opening on test failures (#27319) When running e2e tests locally, the test report server runs automatically if there were any failures instead of exiting cleanly. This is especially annoying when agents run the e2e tests themselves, as they will hang indefinitely on the command, not realizing that the tests have already run. This disables this behavior, so the e2e tests will exit cleanly even if tests fail. The html report is still generated and can be opened manually. --- e2e/playwright.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs index bb239b143ab..ae7bf1d7d73 100644 --- a/e2e/playwright.config.mjs +++ b/e2e/playwright.config.mjs @@ -25,7 +25,7 @@ const config = { maxFailures: process.argv.includes('--ui') ? 0 : 1, workers: parseInt(process.env.TEST_WORKERS_COUNT, 10) || getWorkerCount(), fullyParallel: false, - reporter: process.env.CI ? [['list', {printSteps: true}], ['blob']] : [['list', {printSteps: true}], ['html']], + reporter: process.env.CI ? [['list', {printSteps: true}], ['blob']] : [['list', {printSteps: true}], ['html', {open: 'never'}]], use: { // Base URL will be set dynamically per test via fixture baseURL: process.env.GHOST_BASE_URL || 'http://localhost:2368',