From cd02dbbb7af9e17b60188d535e6ec9bc087d3226 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 17 Sep 2026 00:26:54 +0100 Subject: [PATCH 01/27] Fixed a saved members view opening unfiltered when clicked straight after leaving it ref https://linear.app/ghost/issue/BER-3958 The members list keeps its filters in the URL, and when the URL changed from outside, a link or a saved view, it wrote its previous filters back over the new URL before settling on the new ones. A navigation that landed during that write-back was lost, so leaving a saved view and clicking straight back opened the list with no filter. The browser test for reopening a country filter view hit this every time on this branch. The list now skips that one stale write, keeping the guard that protects its own pending edits. --- .../members/hooks/use-members-filter-state.ts | 12 ++++ .../members-filtering.acceptance.test.tsx | 71 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/apps/admin/src/members/hooks/use-members-filter-state.ts b/apps/admin/src/members/hooks/use-members-filter-state.ts index 51c6193768e..579556b7f2d 100644 --- a/apps/admin/src/members/hooks/use-members-filter-state.ts +++ b/apps/admin/src/members/hooks/use-members-filter-state.ts @@ -116,6 +116,12 @@ export function useMembersFilterState( ); const [searchParams, setSearchParams] = useSearchParams(); const lastWrittenQueryRef = useRef(null); + // Set when the URL changed from outside (a link, a saved view, back) and the draft is being + // replaced from it. The write effect runs in the same commit with the previous draft still + // in its closure, and would write those stale filters over the new URL; a navigation that + // arrived meanwhile could then be lost. Skipping that one write lets the next render, with + // the new draft, normalise the URL instead. + const draftReplacedFromUrlRef = useRef(false); const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]); const currentQuery = useMemo(() => searchParams.toString(), [searchParams]); @@ -134,12 +140,18 @@ export function useMembersFilterState( useEffect(() => { if (currentQuery !== lastWrittenQueryRef.current) { + draftReplacedFromUrlRef.current = lastWrittenQueryRef.current !== null; setDraftFilters(parsedFilters); lastWrittenQueryRef.current = currentQuery; } }, [currentQuery, parsedFilters]); useEffect(() => { + if (draftReplacedFromUrlRef.current) { + draftReplacedFromUrlRef.current = false; + return; + } + if (lastWrittenQueryRef.current !== null && currentQuery !== lastWrittenQueryRef.current) { return; } diff --git a/apps/admin/src/members/members-filtering.acceptance.test.tsx b/apps/admin/src/members/members-filtering.acceptance.test.tsx index 9e8ff2f6b76..a55d2b243a8 100644 --- a/apps/admin/src/members/members-filtering.acceptance.test.tsx +++ b/apps/admin/src/members/members-filtering.acceptance.test.tsx @@ -9,6 +9,7 @@ import { label, member, renderAdminApp, + settingsResponse, tier, } from '@test-utils/acceptance'; import { membersScreen } from './members.screen'; @@ -180,4 +181,74 @@ describe('Members list', () => { await expect(membersScreen.memberRows()).toHaveCount(1); await expect.element(membersScreen.link('Alice Alpha')).toBeVisible(); }); + + // A navigation that lands while the list is syncing its filters to the URL must not be + // overwritten: leaving a saved view and clicking straight back lands on the view. + it('reopens a saved view clicked straight after leaving it', async () => { + const vip = label({ name: 'VIP' }); + const viewFilter = 'label:[VIP]'; + fakeMembers(({ filter }) => + filter + ? [member({ name: 'Vip One', labels: [vip] })] + : [member({ name: 'Vip One', labels: [vip] }), member({ name: 'Plain' })], + ); + await renderAdminApp(`/members?filter=${encodeURIComponent(viewFilter)}`, { + boot: { + browseSettings: { + response: settingsResponse({ + settings: { + shared_views: JSON.stringify([ + { name: 'VIPs', route: 'members', filter: { filter: viewFilter } }, + ]), + }, + }), + }, + }, + }); + await expect(membersScreen.memberRows()).toHaveCount(1); + + const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + // The list's own "Members" link, not the heading: the sidebar has one by that href. + const membersLink = () => + document.querySelector('a[href="#/members"]') ?? undefined; + await expect.element(membersScreen.link('VIPs')).toBeVisible(); + + const onTheView = () => + currentRoute().includes('filter=') && + document.querySelectorAll('[data-slot="filter-item"]').length === 1 && + membersScreen.memberRows().elements().length === 1; + + // How quickly the second click follows the first is what exposed the race, so a few + // short gaps are tried, each starting from the view. Clicks go straight to the DOM so + // the gap between them is the one chosen. + for (const gap of [5, 10, 20, 30]) { + membersLink()!.click(); + await wait(gap); + const viewLink = membersScreen.link('VIPs').element(); + if (!(viewLink instanceof HTMLElement)) { + throw new Error('The saved view link is not a clickable element'); + } + viewLink.click(); + + // Straight after the clicks the page can still show the view it is leaving, so one look + // proves nothing. Wait until it rests on the view; a stale write leaves it for good. + let onViewSince: number | undefined; + await expect + .poll( + () => { + if (!onTheView()) { + onViewSince = undefined; + return false; + } + onViewSince ??= performance.now(); + return performance.now() - onViewSince >= 500; + }, + { message: `settles on the view after a ${gap}ms gap`, timeout: 10_000, interval: 20 }, + ) + .toBe(true); + } + }); }); From d59c66d204925af551aae92ddc42aa618cc6a0d5 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 16 Sep 2026 22:28:09 +0100 Subject: [PATCH 02/27] Moved the member avatar rule out of the member model ref https://linear.app/ghost/issue/BER-3958 A member's avatar is worked out from their email rather than stored, and only the member model knew how, so anything reading members without the model had no avatar to show. The rule now lives in one helper the model calls, which lets a query that selects only the member columns it needs still show the same avatar. --- ghost/core/core/server/models/member.js | 12 +++--------- .../server/services/members/member-avatar.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 ghost/core/core/server/services/members/member-avatar.ts diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index 60ba8bed2b3..ff0569599d3 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -2,7 +2,7 @@ const ghostBookshelf = require('./base'); const crypto = require('crypto'); const _ = require('lodash'); const { chainTransformers } = require('@tryghost/mongo-utils'); -const config = require('../../shared/config'); +const { memberAvatarImage } = require('../services/members/member-avatar'); const { MemberCommentingCodec } = require('../services/members/commenting'); const { METAFIELDS_RELATION, @@ -632,14 +632,8 @@ const Member = ghostBookshelf.Model.extend( toJSON(unfilteredOptions) { const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, unfilteredOptions); - // Inject a computed avatar url. Uses gravatar's default ?d= query param - // to serve a blank image if there is no gravatar for the member's email. - // Will not use gravatar if privacy.useGravatar is false in config - attrs.avatar_image = null; - if (attrs.email && !config.isPrivacyDisabled('useGravatar')) { - const { gravatar } = require('../lib/image'); - attrs.avatar_image = gravatar.url(attrs.email, { size: 250, default: 'blank' }); - } + // Inject a computed avatar url, by the rule every place that shows a member shares. + attrs.avatar_image = memberAvatarImage(attrs.email); // Serialize commenting domain object to API format if (attrs.commenting) { diff --git a/ghost/core/core/server/services/members/member-avatar.ts b/ghost/core/core/server/services/members/member-avatar.ts new file mode 100644 index 00000000000..3c182c3019b --- /dev/null +++ b/ghost/core/core/server/services/members/member-avatar.ts @@ -0,0 +1,16 @@ +const config: typeof import('../../../shared/config') = require('../../../shared/config'); + +/** + * The avatar shown for a member: their Gravatar, served blank when they have none, unless + * the site has turned Gravatar off. Nothing is stored, so every place that shows a member + * works it out from their email the same way. + */ +export function memberAvatarImage(email: string | null | undefined): string | null { + if (!email || config.isPrivacyDisabled('useGravatar')) { + return null; + } + // Required on first use, as the member model did. The image library is untyped JS, so + // `gravatar` is not checked here. + const { gravatar } = require('../../lib/image'); + return gravatar.url(email, { size: 250, default: 'blank' }); +} From fd4ca5925c7c4f912becdb8ac088b7bc4b12e9e3 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 16 Sep 2026 16:59:09 +0100 Subject: [PATCH 03/27] Added a table recording which metafields a member changed ref https://linear.app/ghost/issue/BER-3958 A member updating their own fields in Portal leaves no trace on their activity feed, so a publisher looking at a value has no way to tell who changed it or when. This is the storage for that entry, on its own so the schema change can be reviewed apart from the code that writes and reads it. It records which fields changed rather than their values, so an address a member has since replaced does not live on in a history table. --- .../core/server/data/exporter/table-lists.js | 1 + ...-26-add-members-metafield-change-events.js | 17 ++++++++++++ ghost/core/core/server/data/schema/schema.js | 26 +++++++++++++++++++ ghost/core/package.json | 2 +- .../integration/exporter/exporter.test.js | 2 ++ .../unit/server/data/schema/integrity.test.js | 2 +- 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.65/2026-09-16-15-35-26-add-members-metafield-change-events.js diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js index 631b463f7c7..c3477f3cf54 100644 --- a/ghost/core/core/server/data/exporter/table-lists.js +++ b/ghost/core/core/server/data/exporter/table-lists.js @@ -34,6 +34,7 @@ const BACKUP_TABLES = [ 'members_payment_events', 'members_login_events', 'members_email_change_events', + 'members_metafield_change_events', 'members_status_events', 'members_paid_subscription_events', 'members_subscribe_events', diff --git a/ghost/core/core/server/data/migrations/versions/6.65/2026-09-16-15-35-26-add-members-metafield-change-events.js b/ghost/core/core/server/data/migrations/versions/6.65/2026-09-16-15-35-26-add-members-metafield-change-events.js new file mode 100644 index 00000000000..337c3a000df --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.65/2026-09-16-15-35-26-add-members-metafield-change-events.js @@ -0,0 +1,17 @@ +const { addTable } = require('../../utils'); + +module.exports = addTable('members_metafield_change_events', { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + written_by_type: { type: 'string', maxlength: 50, nullable: false }, + written_by_id: { type: 'string', maxlength: 24, nullable: true }, + source: { type: 'string', maxlength: 50, nullable: false }, + metafields: { type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 84b8ee7c96a..237aac1f551 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -933,6 +933,32 @@ module.exports = { }, created_at: { type: 'dateTime', nullable: false }, }, + // A member's metafields changing, as an entry in their activity feed. Which fields, + // not what they now hold: the values are on the member already, and an old address + // has no business outliving the member's change of it in a history table. + members_metafield_change_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + // Who made the change, in the vocabulary `members_metafield_values` uses for who + // wrote a value, so an entry can name any writer that table can. + written_by_type: { type: 'string', maxlength: 50, nullable: false }, + written_by_id: { type: 'string', maxlength: 24, nullable: true }, + // Where the change was made, such as `portal`. Separate from who made it: a member + // can supply their own values somewhere other than their account. + source: { type: 'string', maxlength: 50, nullable: false }, + // The fields changed, as a JSON list of each field's namespace, key and the name it had at the + // time. Names are copied rather than joined so an entry still reads after a field is + // renamed or deleted. MEDIUMTEXT because a write can name every field a site + // defines, and that many names can outgrow TEXT's 65,535 bytes. + metafields: { type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, members_status_events: { id: { type: 'string', maxlength: 24, nullable: false, primary: true }, member_id: { diff --git a/ghost/core/package.json b/ghost/core/package.json index a84f7ce2670..18b8d962746 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.64.1-rc.0", + "version": "6.65.0-rc.0", "description": "The professional publishing platform", "keywords": [ "blog", diff --git a/ghost/core/test/integration/exporter/exporter.test.js b/ghost/core/test/integration/exporter/exporter.test.js index 2fb880677e9..e20d66b1d10 100644 --- a/ghost/core/test/integration/exporter/exporter.test.js +++ b/ghost/core/test/integration/exporter/exporter.test.js @@ -64,6 +64,7 @@ describe('Exporter', function () { 'members_metafield_values', 'members_metafields', 'members_email_change_events', + 'members_metafield_change_events', 'members_feedback', 'members_labels', 'members_login_events', @@ -149,6 +150,7 @@ describe('Exporter', function () { 'members_payment_events', 'members_login_events', 'members_email_change_events', + 'members_metafield_change_events', 'members_status_events', 'members_paid_subscription_events', 'members_subscribe_events', 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 475d7fc7824..15dd8acca19 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -37,7 +37,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '5309c65de16da6833fc799828233fa7e'; + const currentSchemaHash = 'b3467bb26d2c4ef862382718ca6ed6e8'; const currentFixturesHash = '5718e0d4eb037f159c312369e949829a'; const currentSettingsHash = '6ea42a00cca61a1ba87f66eb6e25a78a'; const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1'; From 97ff2cb3c37a854dcdf87940e938597b8ea17e42 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 16 Sep 2026 16:59:25 +0100 Subject: [PATCH 04/27] Added member custom field changes to the activity feed API ref https://linear.app/ghost/issue/BER-3958 Once members can edit their own fields, a value has more than one author, and a publisher looking at one needs to see who changed it, where and when. Every write to a member's custom fields now leaves an entry on their activity feed naming the fields it touched, whether it came from staff in Admin, an integration, an import, checkout or the member in Portal. The entry is written by the value write itself, in the same transaction, so the feed cannot disagree with what is stored, and each write has to name who made it and where, with pairs that cannot happen refused by the compiler. The entries belong to the metafields domain, which reads them with knex for the members events endpoint rather than through a Bookshelf model, and each field's name is copied so an entry still reads after the field is renamed or deleted. --- .../services/members-metafields/actions.ts | 34 ++++- .../members-metafields/bindings-service.ts | 21 +-- .../members-metafields/definitions-service.ts | 12 +- .../services/members-metafields/index.ts | 4 +- .../services/members-metafields/queries.ts | 11 ++ .../services/members-metafields/schema.ts | 103 ++++++++++++++- .../members-metafields/values-service.ts | 125 +++++++++++++++++- .../services/members/account-service.ts | 17 +-- .../services/members/import-export/index.ts | 5 +- .../members/members-api/members-api.js | 4 +- .../repositories/event-repository.js | 35 +++++ .../services/member-bread-service.js | 11 +- .../admin/member-custom-fields.test.ts | 66 +++++++++ .../members-import-custom-fields.test.ts | 26 ++++ .../e2e-api/members/custom-fields.test.ts | 125 ++++++++++++++++++ .../test/e2e-api/members/webhooks.test.js | 23 ++++ packages/metafield-types/src/index.ts | 41 ++++++ packages/metafield-types/test/index.test.ts | 10 ++ 18 files changed, 620 insertions(+), 53 deletions(-) diff --git a/ghost/core/core/server/services/members-metafields/actions.ts b/ghost/core/core/server/services/members-metafields/actions.ts index 86af2115a2f..87b642fc50e 100644 --- a/ghost/core/core/server/services/members-metafields/actions.ts +++ b/ghost/core/core/server/services/members-metafields/actions.ts @@ -1,9 +1,12 @@ import logging from '@tryghost/logging'; import type { MemberAccess } from './access'; +import type { WriteOrigin } from './schema'; export interface Actor { id: string; type: 'user' | 'integration'; + /** An API key rather than a signed-in session, which a user's staff token also is. */ + viaApiKey: boolean; } export interface RequestContext { @@ -19,16 +22,41 @@ export interface RequestContext { * has none, and the history says so rather than guessing. */ export function actingContext(context: unknown): RequestContext { - const frame = (context ?? {}) as { user?: string; integration?: { id: string } }; + const frame = (context ?? {}) as { + user?: string; + integration?: { id: string }; + api_key?: unknown; + }; + const viaApiKey = Boolean(frame.api_key); if (frame.integration) { - return { actor: { id: frame.integration.id, type: 'integration' } }; + return { actor: { id: frame.integration.id, type: 'integration', viaApiKey } }; } if (frame.user) { - return { actor: { id: frame.user, type: 'user' } }; + return { actor: { id: frame.user, type: 'user', viaApiKey } }; } return { actor: null }; } +/** + * Who made a write through the Admin API and where, read off the same context as + * `actingContext`, so the values a request writes and the history it records name the + * same writer. Null when nobody is acting, which no Admin API write should be. + */ +export function adminWriteOrigin(context: unknown): WriteOrigin | null { + const { actor } = actingContext(context); + if (!actor) { + return null; + } + if (actor.type === 'integration') { + return { writtenBy: { type: 'integration', id: actor.id }, source: 'admin_api' }; + } + // A staff token authenticates as its user but is a call to the API, not a visit to Admin. + return { + writtenBy: { type: 'user', id: actor.id }, + source: actor.viaApiKey ? 'admin_api' : 'admin', + }; +} + export interface ActionRecorder { add(data: Record, options: { autoRefresh: boolean }): Promise; } diff --git a/ghost/core/core/server/services/members-metafields/bindings-service.ts b/ghost/core/core/server/services/members-metafields/bindings-service.ts index 5af144ce6c8..7b1fc0a6106 100644 --- a/ghost/core/core/server/services/members-metafields/bindings-service.ts +++ b/ghost/core/core/server/services/members-metafields/bindings-service.ts @@ -3,7 +3,7 @@ import logging from '@tryghost/logging'; import type { Knex } from 'knex'; import type { FieldType } from '@tryghost/metafield-types'; import { INTERNAL } from './access'; -import { DbBoundField, FIELD_STATUS, type WrittenBy } from './schema'; +import { DbBoundField, FIELD_STATUS, type WriteOrigin } from './schema'; import type { MetafieldValuesService, PlannedWrite } from './values-service'; const FIELDS_TABLE = 'members_metafields'; @@ -17,8 +17,13 @@ export interface BoundField { type: FieldType; } -/** What a value's provenance is, once the port it came through has been resolved. */ -type Attribution = (binding: BoundField) => WrittenBy; +/** + * What a value's provenance is, once the port it came through has been resolved. + * + * An intersection rather than `Extract`: a member writes from more than one place, so only + * narrowing each pairing to checkout keeps the member among the writers here. + */ +type Attribution = (binding: BoundField) => WriteOrigin & { source: 'checkout' }; /** * Where a source sends what it collected: a `port` is the name that source uses for a @@ -81,8 +86,8 @@ export class MetafieldBindingsService { collected: Array<{ port: string; value: unknown }>, ): Promise { return this.writeThrough(memberId, productId, collected, (binding) => ({ - type: 'binding', - id: binding.bindingId, + writtenBy: { type: 'binding', id: binding.bindingId }, + source: 'checkout', })); } @@ -99,8 +104,8 @@ export class MetafieldBindingsService { supplied: Array<{ port: string; value: unknown }>, ): Promise { return this.writeThrough(memberId, productId, supplied, () => ({ - type: 'member', - id: memberId, + writtenBy: { type: 'member', id: memberId }, + source: 'checkout', })); } @@ -158,7 +163,7 @@ export class MetafieldBindingsService { INTERNAL, ); - await this.values.applyWrite(memberId, planned, { writtenBy: attribute(into) }); + await this.values.applyWrite(memberId, planned, attribute(into)); } private async resolve(productId: string, port: string): Promise { diff --git a/ghost/core/core/server/services/members-metafields/definitions-service.ts b/ghost/core/core/server/services/members-metafields/definitions-service.ts index b70bbd718f6..2d9cd8728da 100644 --- a/ghost/core/core/server/services/members-metafields/definitions-service.ts +++ b/ghost/core/core/server/services/members-metafields/definitions-service.ts @@ -20,21 +20,13 @@ import { ANY_STATUS, definitions, inFieldOrder, + knexify, + nql, type DefinitionQuery, } from './queries'; import { KEY_CHARACTERS, mintableKey } from './key'; import { type RecordMetafieldAction, type RequestContext } from './actions'; -// The same NQL -> knex bridge Bookshelf's filter plugin uses, applied directly to -// our raw-knex query: nql parses the `filter` string to a Mongo query, mongo-knex -// turns that into parametrised WHERE clauses. Neither needs a Bookshelf model. -const nql = require('@tryghost/nql') as (filter: string) => { toJSON(): object }; -const knexify = require('@tryghost/mongo-knex') as ( - qb: T, - mongoQuery: object, - config: { tableName: string }, -) => T; - const TABLE = 'members_metafields'; // Column limits come from the canonical schema — the same source the migration and diff --git a/ghost/core/core/server/services/members-metafields/index.ts b/ghost/core/core/server/services/members-metafields/index.ts index 25974dc21d7..836d6102496 100644 --- a/ghost/core/core/server/services/members-metafields/index.ts +++ b/ghost/core/core/server/services/members-metafields/index.ts @@ -6,9 +6,9 @@ import { resolveMaxDefinitions } from './config'; export type { Metafield } from './models'; export type { RequestContext } from './actions'; -export { actingContext } from './actions'; +export { actingContext, adminWriteOrigin } from './actions'; export type { BoundField } from './bindings-service'; -export type { WrittenBy } from './schema'; +export type { MetafieldChangeEvent, WriteOrigin, WrittenBy } from './schema'; // Which door a request came through, which is what decides how much of a member's // answers it may see or change. Required wherever that is asked, so a new caller diff --git a/ghost/core/core/server/services/members-metafields/queries.ts b/ghost/core/core/server/services/members-metafields/queries.ts index 9da2d1efb4d..090a9d71ad5 100644 --- a/ghost/core/core/server/services/members-metafields/queries.ts +++ b/ghost/core/core/server/services/members-metafields/queries.ts @@ -4,6 +4,17 @@ import { FIELD_STATUS } from './schema'; const FIELDS_TABLE = 'members_metafields'; +// The same NQL -> knex bridge Bookshelf's filter plugin uses, applied directly to our +// raw-knex queries: nql parses a `filter` string to a Mongo query, mongo-knex turns that +// into parametrised WHERE clauses. Neither needs a Bookshelf model. Typed once here, since +// neither package ships types. +export const nql = require('@tryghost/nql') as (filter: string) => { toJSON(): object }; +export const knexify = require('@tryghost/mongo-knex') as ( + qb: T, + mongoQuery: object, + config: { tableName: string }, +) => T; + // Nothing in the database keeps an archived or hidden field out of a read: no // constraint stops a value row referencing one. Both filters are therefore applied in // code, and a query that forgets either is a silent bug. diff --git a/ghost/core/core/server/services/members-metafields/schema.ts b/ghost/core/core/server/services/members-metafields/schema.ts index 7f0eb279ec6..44a85d87cc4 100644 --- a/ghost/core/core/server/services/members-metafields/schema.ts +++ b/ghost/core/core/server/services/members-metafields/schema.ts @@ -1,6 +1,11 @@ import { z } from 'zod'; import type { Knex } from 'knex'; -import { FieldTypeSchema } from '@tryghost/metafield-types'; +import { + FieldTypeSchema, + MetafieldChangeEventFieldSchema, + type MetafieldChangeEntry, + type MetafieldChangeSource, +} from '@tryghost/metafield-types'; import { DbDate } from '../../lib/db-types/date'; import { MemberAccessSchema } from './access'; @@ -99,6 +104,96 @@ export const DbBoundField = z.object({ type: FieldTypeSchema, }); +/** + * Where a write was made (`MetafieldChangeSource`, shared with Admin): the entry point, + * where `WrittenBy` is who answers for it. + * + * Mostly the writer settles it: an integration writes through the Admin API, an import + * from a file and a binding at checkout. Staff and members are the exceptions: a member of + * staff writes in Admin or, with a staff token, through the Admin API, and a member writes + * from their account or at checkout. That is why the two are kept apart rather than one + * derived from the other. + */ +type WriterOf = Extract; + +/** + * Who made a write and where, as the pairs that can happen. Every write names both, so the + * activity feed can say where a change came from, and a pair that cannot happen, such as + * an import made in Portal, does not compile. + */ +export type WriteOrigin = + | { writtenBy: WriterOf<'user'>; source: Extract } + | { writtenBy: WriterOf<'integration'>; source: Extract } + | { writtenBy: WriterOf<'import'>; source: Extract } + | { writtenBy: WriterOf<'binding'>; source: Extract } + | { + writtenBy: WriterOf<'member'>; + source: Extract; + }; + +/** The fields an entry names. */ +export const MetafieldChangeEventFields = z.array(MetafieldChangeEventFieldSchema); + +/** + * The field list as the table holds it, JSON text, against the list itself. A codec rather + * than a parse on the way out, so the write stores what the read accepts. Zod validates + * JSON-compatible values (`z.json()`) but has no built-in for parsing JSON text, so decoding + * the text is this codec's job. + */ +export const StoredFieldList = z.codec(z.string(), MetafieldChangeEventFields, { + decode: (text, ctx) => { + try { + return JSON.parse(text); + } catch { + ctx.issues.push({ + code: 'custom', + message: 'The stored field list is not JSON.', + input: text, + }); + return z.NEVER; + } + }, + encode: (fields) => JSON.stringify(fields), +}); + +/** An entry as the table holds it. Writer and source are plain strings, as on the values table. */ +export const DbMetafieldChangeEvent = z.object({ + id: z.string(), + member_id: z.string(), + written_by_type: z.string(), + written_by_id: z.string().nullable(), + source: z.string(), + metafields: StoredFieldList, + created_at: DbDate, +}); + +/** + * An entry as the table holds it, derived from the schema that reads it. The pairing of + * writer and source is held by `WriteOrigin` at the write rather than by this row type. + */ +export type MetafieldChangeEventRow = z.input; + +/** The member columns an entry is shown with: only what a feed row needs. */ +export const DbChangeEventMember = z.object({ + id: z.string(), + uuid: z.string(), + name: z.string().nullable(), + email: z.string(), +}); + +/** An activity feed entry with its member, as the metafields domain reads it. */ +export type MetafieldChangeEvent = MetafieldChangeEntry & { + member: z.output; +}; + +/** + * An entry and its member read back together. Parses and nothing else: what to do with an + * entry that doesn't parse is the reading service's decision. + */ +export const DbMetafieldChangeEventWithMember = z + .object({ event: DbMetafieldChangeEvent, member: DbChangeEventMember }) + .transform(({ event, member }): MetafieldChangeEvent => ({ ...event, member })); + declare module 'knex/types/tables' { interface Tables { members_metafields: Knex.CompositeTableType< @@ -113,6 +208,12 @@ declare module 'knex/types/tables' { Omit, 'updated_at'>, Partial >; + // Read as the schema reads it; written with the date already in the string form SQLite + // orders against the feed's time filters, which a `Date` would silently break. + members_metafield_change_events: Knex.CompositeTableType< + MetafieldChangeEventRow, + Omit & { created_at: string } + >; members_metafield_bindings: Knex.CompositeTableType< MetafieldBindingRow, // `updated_at` is set on insert as well as update: a binding is a setting, and diff --git a/ghost/core/core/server/services/members-metafields/values-service.ts b/ghost/core/core/server/services/members-metafields/values-service.ts index 4916319f0ad..a9596e70289 100644 --- a/ghost/core/core/server/services/members-metafields/values-service.ts +++ b/ghost/core/core/server/services/members-metafields/values-service.ts @@ -3,20 +3,36 @@ import errors from '@tryghost/errors'; import logging from '@tryghost/logging'; import type { Knex } from 'knex'; import { z } from 'zod'; -import { FIELD_TYPES, subFieldsOf, type FieldType } from '@tryghost/metafield-types'; +import { + FIELD_TYPES, + subFieldsOf, + type FieldType, + type MetafieldChangeEventField, +} from '@tryghost/metafield-types'; import { CUSTOM_NAMESPACE, QUALIFIER, formatIdentity, parseIdentity, } from '@tryghost/metafield-types/identity'; -import { DbMetafieldLeaf, DbMetafieldValue, FIELD_STATUS, type WrittenBy } from './schema'; -import { ACTIVE_ONLY, definitions, readableBy } from './queries'; +import { + DbMetafieldChangeEventWithMember, + DbMetafieldLeaf, + DbMetafieldValue, + FIELD_STATUS, + StoredFieldList, + type MetafieldChangeEvent, + type WriteOrigin, +} from './schema'; +import { toDatabaseDate } from '../../lib/db-types/date'; +import { ACTIVE_ONLY, definitions, knexify, readableBy } from './queries'; import { canWrite, type Audience, type MemberAccess } from './access'; import { leavesToWrite, valuesFromLeaves, type StoredLeaf } from './storage'; const FIELDS_TABLE = 'members_metafields'; const VALUES_TABLE = 'members_metafield_values'; +const CHANGE_EVENTS_TABLE = 'members_metafield_change_events'; +const MEMBERS_TABLE = 'members'; /** * From the canonical schema, the same source definitions-service reads, so no key a site @@ -321,16 +337,22 @@ export class MetafieldValuesService { * them, and saying nothing about a path leaves it alone. There is no whole-value * replace, so a caller that does not know about a field cannot erase it. * - * `writtenBy` is required and has no default: every writer has to name itself, so a new - * one cannot quietly inherit the identity of whichever was written first. + * `writtenBy` and `source` are required and have no default: every writer has to name + * itself and where it wrote, so a new one cannot quietly inherit the identity of + * whichever was written first. * * Always transactional. Given an executor it joins that transaction, so the importer's * failed value write takes its member with it; given none it opens its own. + * + * Every write also puts an entry on the member's activity feed, naming the fields it + * touched and who wrote them where, in the same transaction, so the feed cannot name a + * change that was not stored or miss one that was. A value has more than one author, + * and the feed is where a publisher finds out which of them changed it. */ async applyWrite( memberId: string, writes: PlannedWrite[], - { writtenBy, executor = this.knex }: { writtenBy: WrittenBy; executor?: Knex }, + { writtenBy, source, executor = this.knex }: WriteOrigin & { executor?: Knex }, ): Promise { if (writes.length === 0) { return; @@ -405,6 +427,23 @@ export class MetafieldValuesService { // it currently holds rather than who wrote its first value. .merge(['value_text', 'written_by_type', 'written_by_id', 'updated_at']); } + + const fields: MetafieldChangeEventField[] = writes.map(({ field }) => ({ + namespace: field.namespace, + key: field.key, + name: field.name, + })); + await trx(CHANGE_EVENTS_TABLE).insert({ + id: new ObjectID().toHexString(), + member_id: memberId, + written_by_type: writtenBy.type, + written_by_id: writtenBy.id, + source, + metafields: StoredFieldList.encode(fields), + // A string rather than the Date the value rows take: SQLite stores a bound Date + // as a number, which sorts before every string the feed pages through time with. + created_at: toDatabaseDate(now), + }); }; // knex's marker for a transactor: join it rather than nesting a savepoint under it. @@ -414,4 +453,78 @@ export class MetafieldValuesService { await executor.transaction(apply); } } + + /** + * Entries from members' activity feeds, newest first, each with its member, as one page + * of the members events endpoint reads them. + * + * `filter` is a parsed NQL filter over this table's own columns: mapping the feed's + * names onto them is the feed's business, and applying them is this table's. + */ + async browseChangeEvents({ + limit, + filter, + }: { + /** Absent for every entry, as the feed asks for when paging is off. */ + limit?: number; + filter?: object; + }): Promise<{ events: MetafieldChangeEvent[]; total: number }> { + const filtered = () => { + const query = this.knex(CHANGE_EVENTS_TABLE); + return filter ? knexify(query, filter, { tableName: CHANGE_EVENTS_TABLE }) : query; + }; + + // Joined rather than looked up afterwards: an entry cannot outlive its member, whose + // delete cascades to it, so every entry has one to join. + const newestFirst = filtered() + .join(MEMBERS_TABLE, `${MEMBERS_TABLE}.id`, `${CHANGE_EVENTS_TABLE}.member_id`) + .orderBy([ + { column: `${CHANGE_EVENTS_TABLE}.created_at`, order: 'desc' }, + { column: `${CHANGE_EVENTS_TABLE}.id`, order: 'desc' }, + ]); + const page = limit === undefined ? newestFirst : newestFirst.limit(limit); + + const [rows, counted] = await Promise.all([ + page.select( + `${CHANGE_EVENTS_TABLE}.id`, + `${CHANGE_EVENTS_TABLE}.member_id`, + `${CHANGE_EVENTS_TABLE}.written_by_type`, + `${CHANGE_EVENTS_TABLE}.written_by_id`, + `${CHANGE_EVENTS_TABLE}.source`, + `${CHANGE_EVENTS_TABLE}.metafields`, + `${CHANGE_EVENTS_TABLE}.created_at`, + { member_uuid: `${MEMBERS_TABLE}.uuid` }, + { member_name: `${MEMBERS_TABLE}.name` }, + { member_email: `${MEMBERS_TABLE}.email` }, + ), + filtered().count({ total: '*' }).first(), + ]); + + // What to do with an entry whose field list can't be read is decided here, not in the + // parser: it is still shown, naming no fields. The feed pages through time a page per + // event type, so dropping an entry would push a valid one off the end of its page for + // good. Anything else a row can't be read for, the table's constraints rule out, so it + // throws rather than being hidden. + const events = rows.map( + ({ member_uuid: uuid, member_name: name, member_email: email, ...event }) => { + const fields = StoredFieldList.safeParse(event.metafields); + if (!fields.success) { + logging.warn( + { + event: { name: 'members.metafields.change_event_unreadable' }, + err: fields.error, + changeEventId: event.id, + }, + 'Reading an unreadable metafield change entry as naming no fields', + ); + } + return DbMetafieldChangeEventWithMember.parse({ + event: fields.success ? event : { ...event, metafields: StoredFieldList.encode([]) }, + member: { id: event.member_id, uuid, name, email }, + }); + }, + ); + + return { events, total: Number(counted?.total ?? 0) }; + } } diff --git a/ghost/core/core/server/services/members/account-service.ts b/ghost/core/core/server/services/members/account-service.ts index b5002a64431..f959e587dc2 100644 --- a/ghost/core/core/server/services/members/account-service.ts +++ b/ghost/core/core/server/services/members/account-service.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { MEMBERS } from '../members-metafields'; +import type { MetafieldValuesService } from '../members-metafields/values-service'; /** * A member's own account: what they are shown about themselves, and what they may @@ -54,15 +55,7 @@ interface EmailSuppressionList { removeEmail(email: string): Promise; } -interface MetafieldValues { - unwrapWire(input: unknown): unknown; - planWrite(values: unknown, audience: unknown): Promise; - applyWrite( - memberId: string, - writes: unknown[], - options: { writtenBy: { type: string; id: string } }, - ): Promise; -} +type MetafieldValues = Pick; export interface MemberAccountServiceDeps { memberBREADService: MemberBreadService; @@ -116,11 +109,11 @@ export class MemberAccountService { }); if (plannedMetafields) { - // A member is recorded as the author of their own answers, and is the one - // writer whose changes leave nothing in the staff action log: that log - // records what staff did. + // A member is recorded as the author of their own answers, made from their + // account, which is Portal's. await this.#metafieldValues.applyWrite(memberId, plannedMetafields, { writtenBy: { type: 'member', id: memberId }, + source: 'portal', }); } diff --git a/ghost/core/core/server/services/members/import-export/index.ts b/ghost/core/core/server/services/members/import-export/index.ts index a0eb8c26fe4..a6c7f405bf1 100644 --- a/ghost/core/core/server/services/members/import-export/index.ts +++ b/ghost/core/core/server/services/members/import-export/index.ts @@ -1,6 +1,6 @@ import type { Knex } from 'knex'; import type { CsvField } from '@tryghost/metafield-types/csv'; -import { INTERNAL, type Audience, type WrittenBy } from '../../members-metafields'; +import { INTERNAL, type Audience, type WriteOrigin } from '../../members-metafields'; import MembersCSVImporter, { type MembersRepository, type GiftService, @@ -55,7 +55,7 @@ interface ImporterServices { applyWrite( memberId: string, plan: unknown[], - options: { writtenBy: WrittenBy; executor?: Knex }, + options: WriteOrigin & { executor?: Knex }, ): Promise; }; }; @@ -131,6 +131,7 @@ export function makeImporter(deps: ImporterServices) { applyWrite: (memberId, plan, executor) => deps.metafields.values.applyWrite(memberId, plan, { writtenBy: { type: 'import', id: null }, + source: 'import', executor, }), }; diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js index d6ff1e1e890..feb151ae169 100644 --- a/ghost/core/core/server/services/members/members-api/members-api.js +++ b/ghost/core/core/server/services/members/members-api/members-api.js @@ -7,7 +7,6 @@ const PaymentsService = require('./services/payments-service'); const TokenService = require('./services/token-service'); const GeolocationService = require('./services/geolocation-service'); const MemberBREADService = require('./services/member-bread-service'); -const metafields = require('../../members-metafields'); const { MemberAccountService } = require('../account-service'); const MemberRepository = require('./repositories/member-repository'); const NextPaymentCalculator = require('./services/next-payment-calculator'); @@ -128,6 +127,7 @@ module.exports = function MembersAPI({ labsService, memberAttributionService, MemberEmailChangeEvent, + metafieldValues, AutomatedEmailRecipient, giftSubscriptions: giftService, }); @@ -365,7 +365,7 @@ module.exports = function MembersAPI({ memberBREADService, members: users, emailSuppressionList, - metafieldValues: metafields.values, + metafieldValues, }); async function getMemberIdentity(transientId) { diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index e6ad5c6da4b..6f4e8ce33d7 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -12,6 +12,7 @@ const { } = require('@tryghost/mongo-utils'); const { default: ObjectID } = require('bson-objectid'); const db = require('../../../../data/db'); +const { memberAvatarImage } = require('../../member-avatar'); /** * This mongo transformer ignores the provided filter option and replaces the filter with a custom filter that was provided to the transformer. Allowing us to set a mongo filter instead of a string based NQL filter. @@ -42,6 +43,7 @@ module.exports = class EventRepository { memberAttributionService, urlService, MemberEmailChangeEvent, + metafieldValues, AutomatedEmailRecipient, giftSubscriptions, }) { @@ -60,6 +62,7 @@ module.exports = class EventRepository { this._EmailSpamComplaintEvent = EmailSpamComplaintEvent; this._memberAttributionService = memberAttributionService; this._MemberEmailChangeEvent = MemberEmailChangeEvent; + this._metafieldValues = metafieldValues; this._AutomatedEmailRecipient = AutomatedEmailRecipient; this._giftSubscriptions = giftSubscriptions; this._knex = db.knex; @@ -99,6 +102,7 @@ module.exports = class EventRepository { { type: 'login_event', action: 'getLoginEvents' }, { type: 'payment_event', action: 'getPaymentEvents' }, { type: 'email_change_event', action: 'getEmailChangeEvent' }, + { type: 'metafield_change_event', action: 'getMetafieldChangeEvents' }, { type: 'gift_purchase_event', action: 'getGiftPurchaseEvents' }, { type: 'gift_redemption_event', action: 'getGiftRedemptionEvents' }, { type: 'gift_ended_event', action: 'getGiftEndedEvents' }, @@ -1012,6 +1016,37 @@ module.exports = class EventRepository { }; } + async getMetafieldChangeEvents(options = {}, filter) { + // The entries belong to the metafields domain, which reads them without a model; the + // feed maps its own filter names onto the table's columns and adds each member's avatar. + const columnFilter = filter + ? chainTransformers( + ...mapKeys({ + 'data.created_at': 'created_at', + 'data.member_id': 'member_id', + }), + )(filter) + : undefined; + + const { events, total } = await this._metafieldValues.browseChangeEvents({ + // The feed's limit arrives as the request sent it, which can be a numeric string + // or `all`; the service takes a number, or nothing for every entry. + limit: options.limit === 'all' ? undefined : Number(options.limit), + filter: columnFilter, + }); + + return { + data: events.map((event) => ({ + type: 'metafield_change_event', + data: { + ...event, + member: { ...event.member, avatar_image: memberAvatarImage(event.member.email) }, + }, + })), + meta: { pagination: { total } }, + }; + } + async getAutomatedEmailSentEvents(options = {}, filter) { options = { ...options, diff --git a/ghost/core/core/server/services/members/members-api/services/member-bread-service.js b/ghost/core/core/server/services/members/members-api/services/member-bread-service.js index 0b899b4246f..804ae94be56 100644 --- a/ghost/core/core/server/services/members/members-api/services/member-bread-service.js +++ b/ghost/core/core/server/services/members/members-api/services/member-bread-service.js @@ -1,5 +1,5 @@ const errors = require('@tryghost/errors'); -const { ADMIN } = require('../../../members-metafields'); +const { ADMIN, adminWriteOrigin } = require('../../../members-metafields'); const logging = require('@tryghost/logging'); const tpl = require('@tryghost/tpl'); const moment = require('moment'); @@ -641,16 +641,13 @@ module.exports = class MemberBREADService { // The only route to this branch is the authenticated Admin API, so an anonymous // request is a mistake somewhere upstream rather than a writer to invent a name // for. Refusing keeps every stored writer resolvable. - const context = options.context || {}; - if (!context.integration && !context.user) { + const origin = adminWriteOrigin(options.context); + if (!origin) { throw new errors.IncorrectUsageError({ message: tpl(messages.metafieldsWithoutWriter), }); } - const writtenBy = context.integration - ? { type: 'integration', id: context.integration.id } - : { type: 'user', id: context.user }; - await this.metafieldValues.applyWrite(model.id, plannedMetafields, { writtenBy }); + await this.metafieldValues.applyWrite(model.id, plannedMetafields, origin); // Metafields aren't a member column or relation, so an edit touching // only them leaves `model._changed` empty and the save fires nothing. diff --git a/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts b/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts index 1aaf346296a..a5d4f3ceedd 100644 --- a/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts +++ b/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts @@ -19,6 +19,7 @@ describe('Member Custom Fields Admin API', function () { loginAsOwner: () => Promise; loginAsEditor: () => Promise; useZapierAdminAPIKey: () => Promise; + useStaffTokenForOwner: () => Promise; }; // The key is minted server-side from the name, so callers pass just a name @@ -1618,6 +1619,71 @@ describe('Member Custom Fields Admin API', function () { assert.notEqual(secondWrite[0].written_by_id, owner, 'the integration is identified too'); assert.ok(secondWrite[0].written_by_id); assert.equal(secondWrite[1].written_by_id, owner); + + // Each write also shows on the member's activity feed, saying where it was made. + const filter = encodeURIComponent(`data.member_id:'${memberId}'+type:metafield_change_event`); + const { body } = await agent.get(`members/events/?filter=${filter}`).expectStatus(200); + assert.deepEqual( + body.events.map(({ data }: { data: { source: string; written_by_type: string } }) => ({ + source: data.source, + writer: data.written_by_type, + })), + [ + { source: 'admin_api', writer: 'integration' }, + { source: 'admin', writer: 'user' }, + ], + ); + }); + + // A staff token belongs to a person rather than an integration, but whoever holds it is + // calling the API rather than using Admin, and the feed has to say so. + it('says a change made with a staff token was made through the Admin API', async function () { + const field = await createField({ name: 'Company' }); + const memberId = await createMember(); + + await agent.useStaffTokenForOwner(); + try { + await setValues(memberId, { [field.key]: 'Acme' }); + } finally { + await agent.loginAsOwner(); + } + + const filter = encodeURIComponent(`data.member_id:'${memberId}'+type:metafield_change_event`); + const { body } = await agent.get(`members/events/?filter=${filter}`).expectStatus(200); + assert.deepEqual( + body.events.map(({ data }: { data: { source: string; written_by_type: string } }) => ({ + source: data.source, + writer: data.written_by_type, + })), + [{ source: 'admin_api', writer: 'user' }], + ); + }); + + // No API writes an entry the feed can't read, so the only way to show what happens to + // one is to put it in the table directly. + it('shows an unreadable activity entry as naming no fields rather than failing the feed', async function () { + const field = await createField({ name: 'Department' }); + const memberId = await createMember(); + await setValues(memberId, { [field.key]: 'Sales' }); + + await models.Base.knex('members_metafield_change_events').insert({ + id: '0123456789abcdef01234567', + member_id: memberId, + written_by_type: 'import', + written_by_id: null, + source: 'import', + metafields: '{not json', + created_at: '2020-01-01 00:00:00', + }); + + const filter = encodeURIComponent(`data.member_id:'${memberId}'+type:metafield_change_event`); + const { body } = await agent.get(`members/events/?filter=${filter}`).expectStatus(200); + assert.deepEqual( + body.events.map(({ data }: { data: { metafields: Array<{ name: string }> } }) => + data.metafields.map(({ name }) => name), + ), + [['Department'], []], + ); }); it("drops a field's values when the field is permanently deleted", async function () { diff --git a/ghost/core/test/e2e-api/admin/members-import-custom-fields.test.ts b/ghost/core/test/e2e-api/admin/members-import-custom-fields.test.ts index 538d85149fc..3686a544c15 100644 --- a/ghost/core/test/e2e-api/admin/members-import-custom-fields.test.ts +++ b/ghost/core/test/e2e-api/admin/members-import-custom-fields.test.ts @@ -91,6 +91,7 @@ describe('Members import — custom fields', function () { afterEach(async function () { mockManager.restore(); await models.Base.knex('members_metafield_values').del(); + await models.Base.knex('members_metafield_change_events').del(); await models.Base.knex('members_metafields').del(); // Every test file in this suite runs against one shared database, so a member left behind // here changes counts and listings that later files assert on. The imported members have @@ -437,6 +438,31 @@ describe('Members import — custom fields', function () { // Recorded at the moment of the write because it cannot be reconstructed afterwards. // Asserted per row, since that is the granularity a write touches. + it("puts one entry on each imported member's activity feed", async function () { + const key = await createField('Shipping Address', 'address'); + const email = 'cf-import-activity@example.com'; + const columns = ['line1', 'city'].map((sub) => `metafields.custom.${key}.${sub}`).join(','); + + await importCSV(`email,${columns}\n${email},1 High Street,London\n`); + + const member = await findMember(email); + const filter = encodeURIComponent(`data.member_id:'${member.id}'+type:metafield_change_event`); + const res = await ( + request.get(localUtils.API.getApiQuery(`members/events/?filter=${filter}`)) as any + ) + .set('Origin', config.get('url')) + .expect(200); + + // One entry for the row, naming the field once however many of its parts it filled. + assert.equal(res.body.events.length, 1); + const [event] = res.body.events; + assert.equal(event.data.source, 'import'); + assert.equal(event.data.written_by_type, 'import'); + assert.deepEqual(event.data.metafields, [ + { namespace: 'custom', key, name: 'Shipping Address' }, + ]); + }); + it('records that the import wrote each value', async function () { const key = await createField('Shipping Address', 'address'); const email = 'cf-import-source@example.com'; diff --git a/ghost/core/test/e2e-api/members/custom-fields.test.ts b/ghost/core/test/e2e-api/members/custom-fields.test.ts index 64091199c96..9f0f50fcc6a 100644 --- a/ghost/core/test/e2e-api/members/custom-fields.test.ts +++ b/ghost/core/test/e2e-api/members/custom-fields.test.ts @@ -265,6 +265,131 @@ describe('Member Custom Fields Members API', function () { assert.equal(after.metafields.custom[fieldKey], '9', 'the defined field kept its value'); }); + describe('the member activity feed', function () { + /** The entries staff see for this member's own field changes, newest first. */ + async function fieldChangesInActivityFeed() { + const filter = encodeURIComponent(`data.member_id:'${memberId}'+type:metafield_change_event`); + // Every test here starts with a staff write, so the entries outgrow the default page. + const { body } = await adminAgent + .get(`members/events/?filter=${filter}&limit=1000`) + .expectStatus(200); + return body.events; + } + + it('shows staff which fields a member changed, and nothing of what they hold', async function () { + const addressKey = await defineField('Home address', { type: 'address' }); + const before = await fieldChangesInActivityFeed(); + + await membersAgent + .put('/api/member/') + .body({ + metafields: { + custom: { [fieldKey]: '12', [addressKey]: { line1: '1 Secret Lane' } }, + }, + }) + .expectStatus(200); + + const events = await fieldChangesInActivityFeed(); + assert.equal(events.length, before.length + 1, 'one entry for one write'); + + const [event] = events; + assert.equal(event.data.member.id, memberId); + // The member as the feed shows them: who they are and their avatar, nothing more. + assert.deepEqual(Object.keys(event.data.member).sort(), [ + 'avatar_image', + 'email', + 'id', + 'name', + 'uuid', + ]); + assert.equal(event.data.member.email, MEMBER_EMAIL); + assert.equal(event.data.written_by_type, 'member', 'the member made the change themselves'); + assert.equal(event.data.source, 'portal', 'from their own account'); + assert.deepEqual( + event.data.metafields.map((field: { name: string }) => field.name), + [SHOE_SIZE, 'Home address'], + ); + + const entry = JSON.stringify(event); + assert.ok(!entry.includes('1 Secret Lane'), 'the value is not repeated in the feed'); + assert.ok(!entry.includes('"12"'), 'for any field'); + }); + + it('places an entry in time like every other event', async function () { + await membersAgent + .put('/api/member/') + .body({ metafields: { custom: { [fieldKey]: '11' } } }) + .expectStatus(200); + + const [event] = await fieldChangesInActivityFeed(); + const createdAt = Date.parse(event.data.created_at); + assert.ok(Math.abs(Date.now() - createdAt) < 60_000, 'stamped with when it happened'); + + // The feed pages through time with these filters, so an entry that sorts wrongly + // against them turns up on the wrong page, or on every page. + const since = async (operator: string) => { + const filter = encodeURIComponent( + `data.member_id:'${memberId}'+type:metafield_change_event+data.created_at:${operator}'2020-01-01 00:00:00'`, + ); + const { body } = await adminAgent.get(`members/events/?filter=${filter}`).expectStatus(200); + return body.events.map((entry: { data: { id: string } }) => entry.data.id); + }; + assert.ok((await since('>')).includes(event.data.id), 'after a date long past'); + assert.ok(!(await since('<')).includes(event.data.id), 'and not before it'); + }); + + it('keeps naming a field after the publisher deletes it', async function () { + const retiredKey = await defineField('Former employer'); + await membersAgent + .put('/api/member/') + .body({ metafields: { custom: { [retiredKey]: 'Acme' } } }) + .expectStatus(200); + + await archiveField(retiredKey); + await adminAgent.delete(`members/metafields/custom/${retiredKey}/`).expectStatus(204); + defined.delete(retiredKey); + + const [event] = await fieldChangesInActivityFeed(); + assert.deepEqual(event.data.metafields, [ + { namespace: 'custom', key: retiredKey, name: 'Former employer' }, + ]); + }); + + it('shows staff a change staff made, and that it was made in Admin', async function () { + const before = await fieldChangesInActivityFeed(); + + await setValuesAsStaff({ [fieldKey]: '10' }); + + const events = await fieldChangesInActivityFeed(); + assert.equal(events.length, before.length + 1, 'one entry for one write'); + const [event] = events; + assert.equal(event.data.written_by_type, 'user', 'a member of staff made the change'); + assert.equal(event.data.source, 'admin'); + assert.deepEqual( + event.data.metafields.map((field: { name: string }) => field.name), + [SHOE_SIZE], + ); + }); + + it('records nothing for a change that names no fields, or one that is refused', async function () { + const before = await fieldChangesInActivityFeed(); + + await membersAgent.put('/api/member/').body({ name: 'Only renamed' }).expectStatus(200); + await membersAgent + .put('/api/member/') + .body({ metafields: { custom: {} } }) + .expectStatus(200); + // Refused, so nothing changed and nothing is reported as having changed. + await membersAgent + .put('/api/member/') + .body({ metafields: { custom: { [fieldKey]: 'x'.repeat(256) } } }) + .expectStatus(422); + + const after = await fieldChangesInActivityFeed(); + assert.equal(after.length, before.length); + }); + }); + describe('what a publisher has kept to themselves', function () { it('says nothing at all about a field the member may not see', async function () { const privateKey = await defineField('Internal note', { access: 'none' }); diff --git a/ghost/core/test/e2e-api/members/webhooks.test.js b/ghost/core/test/e2e-api/members/webhooks.test.js index 78834112c7b..883ec8729e5 100644 --- a/ghost/core/test/e2e-api/members/webhooks.test.js +++ b/ghost/core/test/e2e-api/members/webhooks.test.js @@ -1514,6 +1514,29 @@ describe('Members API', function () { // Asked for on the page and kept by Stripe against the customer it invoices. // Ghost never copies one into a publisher's field, so there is nothing here for it. assert.equal(member.metafields.custom[fieldKeys.vat], undefined); + + // Each value arrives through its own binding, so the member's activity feed has + // an entry per field stored, each saying it was collected at checkout. + const filter = encodeURIComponent( + `data.member_id:'${member.id}'+type:metafield_change_event`, + ); + const { body } = await adminAgent + .get(`/members/events/?filter=${filter}`) + .expectStatus(200); + assert.deepEqual( + body.events + .map(({ data }) => ({ + field: data.metafields[0].name, + source: data.source, + writer: data.written_by_type, + })) + .sort((a, b) => a.field.localeCompare(b.field)), + [ + { field: 'Delivery address', source: 'checkout', writer: 'binding' }, + { field: 'Recipient name', source: 'checkout', writer: 'binding' }, + { field: 'T-shirt size', source: 'checkout', writer: 'binding' }, + ], + ); }); // Turning collection off has to stop the collecting, and Stripe keeps returning diff --git a/packages/metafield-types/src/index.ts b/packages/metafield-types/src/index.ts index ebe97c17898..0535ebc787b 100644 --- a/packages/metafield-types/src/index.ts +++ b/packages/metafield-types/src/index.ts @@ -91,6 +91,47 @@ export const MEMBER_ACCESS = { write: 'write', } as const satisfies Record; +/** + * Where a change to a member's values was made, as a member's activity feed names it. + * Shared so a place added on the server is one Admin has to be told how to describe. + */ +export const METAFIELD_CHANGE_SOURCES = [ + 'admin', + 'admin_api', + 'portal', + 'import', + 'checkout', +] as const; +export type MetafieldChangeSource = (typeof METAFIELD_CHANGE_SOURCES)[number]; + +/** Whether a source is one this build knows how to name; a newer server can send others. */ +export const isMetafieldChangeSource = (source: string): source is MetafieldChangeSource => + (METAFIELD_CHANGE_SOURCES as readonly string[]).includes(source); + +/** A field as an activity feed entry names it: its name is copied so the entry outlives a rename. */ +export const MetafieldChangeEventFieldSchema = z.object({ + namespace: z.string(), + key: z.string(), + name: z.string(), +}); +export type MetafieldChangeEventField = z.infer; + +/** + * An entry on a member's activity feed saying which fields a write changed, as the members + * events endpoint returns it. The server builds it and Admin reads it, so both are held to + * this one shape. `created_at` is a date on the server and its serialised string in a + * client. `source` stays open to places a newer server adds. + */ +export interface MetafieldChangeEntry { + id: string; + member_id: string; + written_by_type: string; + written_by_id: string | null; + source: MetafieldChangeSource | (string & {}); + metafields: MetafieldChangeEventField[]; + created_at: TDate; +} + /** * Bytes, not characters, because MySQL TEXT holds 65,535 of them: a character bound would * accept a multibyte value the column cannot hold, and 65,535 emoji is four times over. diff --git a/packages/metafield-types/test/index.test.ts b/packages/metafield-types/test/index.test.ts index b9e062a0a5f..e898afb461c 100644 --- a/packages/metafield-types/test/index.test.ts +++ b/packages/metafield-types/test/index.test.ts @@ -4,6 +4,7 @@ import { FIELD_TYPES, FIELD_TYPE_IDS, MAX_LONG_TEXT_BYTES, + isMetafieldChangeSource, partTypesOf, subFieldsOf, type Address, @@ -298,4 +299,13 @@ describe('metafield-types catalog', function () { } }); }); + + // A client built before a place existed must still render an entry from a newer server, + // so it asks whether it knows a place rather than assuming it does. + describe('which places a change can be made from', function () { + it('knows the places this build names, and no others', function () { + assert.equal(isMetafieldChangeSource('portal'), true); + assert.equal(isMetafieldChangeSource('somewhere_new'), false); + }); + }); }); From 4135878afac1ded810cc5d212e88916f90712bed Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 16 Sep 2026 16:59:33 +0100 Subject: [PATCH 05/27] Added custom field changes to the member activity feed in Admin ref https://linear.app/ghost/issue/BER-3958 The members events endpoint now returns an entry whenever a member's custom fields change, and without this Admin would render it as an unlabelled row. The row names the changed fields and where the change was made, and the activity page can filter on it where the site has custom fields. The preview on a member's page now leaves out the same events as their full activity page, so following "View all" never shows a different set. --- apps/admin-x-framework/src/api/members.ts | 18 +++-- .../members/activity/activity-filters.test.ts | 19 ++++- .../src/members/activity/activity-filters.ts | 10 +++ .../member-activity.acceptance.test.tsx | 24 +++++++ .../src/members/activity/member-activity.tsx | 12 +--- .../members/activity/use-activity-settings.ts | 22 ++++++ .../members/detail/member-activity-feed.tsx | 22 +++++- .../src/members/detail/member-event.test.ts | 72 +++++++++++++++++++ apps/admin/src/members/detail/member-event.ts | 57 +++++++++++++++ 9 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 apps/admin/src/members/activity/use-activity-settings.ts diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index e3108006af1..1f2dca1ab79 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -7,6 +7,7 @@ import { createQuery, createQueryWithId, } from '../utils/api/hooks'; +import { escapeNqlString } from '@tryghost/nql-string'; import { apiUrl, type RequestOptions } from '../utils/api/fetch-api'; import type { FieldValue } from '@tryghost/metafield-types'; import { useCurrentUser } from './current-user'; @@ -872,8 +873,15 @@ function memberEventsCursor(events: MemberActivityEvent[]): string | undefined { return new Date(createdAt).toISOString().slice(0, 19).replace('T', ' '); } -function buildMemberEventsFilter(memberId: string): string { - return `data.member_id:'${memberId}'`; +// The same exclusion the full activity page sends, so the preview and the page leave out +// the same events. +function buildMemberEventsFilter(memberId: string, excludedEvents: string[]): string { + return [ + excludedEvents.length > 0 && `type:-[${excludedEvents.map(escapeNqlString).join(',')}]`, + `data.member_id:'${memberId}'`, + ] + .filter(Boolean) + .join('+'); } const useMemberActivityFeedQuery = createInfiniteQuery({ @@ -914,12 +922,12 @@ const useMemberActivityFeedQuery = createInfiniteQuery { expect(global).not.toContain('automated_email_sent_event'); expect(activityQueryOptions({ settings: {}, excluded: null, memberId: 'abc' })).toEqual({ memberId: 'abc', - excludedEvents: ['aggregated_click_event'], + excludedEvents: ['aggregated_click_event', 'metafield_change_event'], }); expect(availableActivityTypes({}, 'abc').map(({ event }) => event)).toContain( 'email_opened_event', @@ -55,6 +55,23 @@ describe('activity filters', () => { ); }); + it('offers custom field changes only where the site has custom fields', () => { + const events = (settings: Parameters[0]) => + availableActivityTypes(settings, 'abc').map(({ event }) => event); + expect(events({ customFieldsAvailable: true })).toContain('metafield_change_event'); + expect(events({})).not.toContain('metafield_change_event'); + expect( + activityQueryOptions({ settings: {}, excluded: null, memberId: 'abc' }).excludedEvents, + ).toContain('metafield_change_event'); + expect( + activityQueryOptions({ + settings: { customFieldsAvailable: true }, + excluded: null, + memberId: 'abc', + }).excludedEvents, + ).not.toContain('metafield_change_event'); + }); + it.each([ ['subscription_event', 'gift_redemption_event', 'gift_ended_event'], ['payment_event', 'donation_event', 'gift_purchase_event'], diff --git a/apps/admin/src/members/activity/activity-filters.ts b/apps/admin/src/members/activity/activity-filters.ts index 195dd578a1a..a44b2583b1d 100644 --- a/apps/admin/src/members/activity/activity-filters.ts +++ b/apps/admin/src/members/activity/activity-filters.ts @@ -2,6 +2,7 @@ export interface ActivitySettings { editorDefaultEmailRecipients?: string; commentsEnabled?: string; emailTrackClicks?: boolean; + customFieldsAvailable?: boolean; } const EMAIL_EVENTS = [ @@ -64,6 +65,12 @@ const EVENT_TYPES = [ group: 'emails', icon: 'event-sent-email', }, + { + event: 'metafield_change_event', + name: 'Custom fields updated', + group: 'others', + icon: 'event-metafields-changed', + }, { event: 'feedback_event', name: 'Feedback', group: 'others', icon: 'event-more-like-this' }, ]; @@ -81,6 +88,9 @@ function hiddenActivityEvents(settings: ActivitySettings, memberId?: string): st if (settings.editorDefaultEmailRecipients === 'disabled') { hidden.push('newsletter_event'); } + if (!settings.customFieldsAvailable) { + hidden.push('metafield_change_event'); + } return hidden; } diff --git a/apps/admin/src/members/activity/member-activity.acceptance.test.tsx b/apps/admin/src/members/activity/member-activity.acceptance.test.tsx index 5da03cc46ef..73461de3af8 100644 --- a/apps/admin/src/members/activity/member-activity.acceptance.test.tsx +++ b/apps/admin/src/members/activity/member-activity.acceptance.test.tsx @@ -169,6 +169,30 @@ describe('Member activity', () => { .toContain('comment_event'); }); + it("asks for the same events on a member's page as on their full activity page", async () => { + const { eventsApi } = world([]); + fakeAdminEndpoint('GET', /^\/members\/ada\/\?include=tiers/, { members: [ada] }); + const settings = { + browseSettings: { + response: settingsResponse({ settings: { comments_enabled: 'off' } }), + }, + }; + const excludedTypes = () => + new URL(eventsApi.lastRequest!.url).searchParams + .get('filter') + ?.match(/type:-\[([^\]]*)\]/)?.[1] + ?.replaceAll("'", ''); + + await renderAdminApp('/members/ada', { labs, boot: settings }); + await expect.poll(excludedTypes).toBeDefined(); + const preview = excludedTypes()!.split(',').sort(); + expect(preview).toContain('comment_event'); + expect(preview).toContain('metafield_change_event'); + + await renderAdminApp('/members-activity?member=ada', { labs, boot: settings }); + await expect.poll(() => excludedTypes()?.split(',').sort()).toEqual(preview); + }); + it('renders absent members and unknown event types without unsafe links', async () => { const unknown = event('future', 'future_event', null); const signup = event('unsafe'); diff --git a/apps/admin/src/members/activity/member-activity.tsx b/apps/admin/src/members/activity/member-activity.tsx index 84bdb0238c7..6bef07dad73 100644 --- a/apps/admin/src/members/activity/member-activity.tsx +++ b/apps/admin/src/members/activity/member-activity.tsx @@ -30,13 +30,14 @@ import { APIError } from '@tryghost/admin-x-framework/errors'; import { useBrowseMemberActivityFeed, useMember } from '@tryghost/admin-x-framework/api/members'; import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers'; -import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { getSettingValue } from '@tryghost/admin-x-framework/api/settings'; import { formatMemberName, memberAvatarProps } from '@/members/member-format'; import { parseActivityEvent } from './activity-event'; import ActivityEmailPreview from './activity-email-preview'; import ActivityMemberSearch from './activity-member-search'; import ActivityRow from './activity-row'; import { EventIcon } from '@/members/detail/member-activity-feed'; +import { useActivitySettings } from './use-activity-settings'; import { availableActivityTypes, activityQueryOptions, @@ -50,14 +51,7 @@ function ActivityPage() { const excluded = params.get('excludedEvents'); const [previewEmail, setPreviewEmail] = useState(null); const sentinel = useRef(null); - const settingsQuery = useBrowseSettings({ defaultErrorHandler: false }); - const settings = settingsQuery.data?.settings ?? []; - const activitySettings = { - editorDefaultEmailRecipients: - getSettingValue(settings, 'editor_default_email_recipients') ?? undefined, - commentsEnabled: getSettingValue(settings, 'comments_enabled') ?? undefined, - emailTrackClicks: getSettingValue(settings, 'email_track_clicks') ?? undefined, - }; + const { settingsQuery, settings, activitySettings } = useActivitySettings(); const paidMembersEnabled = getSettingValue(settings, 'paid_members_enabled') ?? false; const memberQuery = useMember(memberId ?? '', { enabled: !!memberId, diff --git a/apps/admin/src/members/activity/use-activity-settings.ts b/apps/admin/src/members/activity/use-activity-settings.ts new file mode 100644 index 00000000000..536f34fb378 --- /dev/null +++ b/apps/admin/src/members/activity/use-activity-settings.ts @@ -0,0 +1,22 @@ +import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { useCustomFieldsAvailable } from '@/shared/member-custom-fields/use-availability'; +import type { ActivitySettings } from './activity-filters'; + +/** + * What decides which events a site's activity shows. One hook for every screen that + * lists activity, so a member's page and their full activity page cannot disagree about + * which events exist. + */ +export function useActivitySettings() { + const settingsQuery = useBrowseSettings({ defaultErrorHandler: false }); + const settings = settingsQuery.data?.settings ?? []; + const customFieldsAvailable = useCustomFieldsAvailable(); + const activitySettings: ActivitySettings = { + editorDefaultEmailRecipients: + getSettingValue(settings, 'editor_default_email_recipients') ?? undefined, + commentsEnabled: getSettingValue(settings, 'comments_enabled') ?? undefined, + emailTrackClicks: getSettingValue(settings, 'email_track_clicks') ?? undefined, + customFieldsAvailable, + }; + return { settingsQuery, settings, activitySettings }; +} diff --git a/apps/admin/src/members/detail/member-activity-feed.tsx b/apps/admin/src/members/detail/member-activity-feed.tsx index 6f106d81ce8..ac43deabbb9 100644 --- a/apps/admin/src/members/detail/member-activity-feed.tsx +++ b/apps/admin/src/members/detail/member-activity-feed.tsx @@ -6,6 +6,8 @@ import { useShade } from '@tryghost/shade/app'; import { isSafeHref } from './is-safe-href'; import { parseMemberEvent } from './member-event'; import { useMemberActivityFeed } from '@tryghost/admin-x-framework/api/members'; +import { activityQueryOptions } from '@/members/activity/activity-filters'; +import { useActivitySettings } from '@/members/activity/use-activity-settings'; import type { MemberActivityEvent } from '@tryghost/admin-x-framework/api/members'; import type { ParsedMemberEvent } from './member-event'; @@ -60,6 +62,8 @@ export const EventIcon: React.FC<{ iconName: string }> = ({ iconName }) => { return ; case 'event-email-changed': return ; + case 'event-metafields-changed': + return ; case 'event-comment': return ; case 'event-click': @@ -203,10 +207,24 @@ const MemberActivityFeed: React.FC = ({ // behind the "View all" link. On the create screen there's no memberId // to query against, so disable the fetch entirely — an unsaved member has // no events by definition. - const { data, isLoading } = useMemberActivityFeed(memberId ?? '', { + // Leaves out the same events the member's full activity page does, so following + // "View all" never shows a different set of events. + const { settingsQuery, activitySettings } = useActivitySettings(); + const { excludedEvents } = activityQueryOptions({ + settings: activitySettings, + memberId, + excluded: null, + }); + // Waits for settings rather than asking twice. If they fail to load it still asks, with + // the defaults: the preview may then include newsletter or comment events a site has + // turned off, where the full page shows an error instead, but that beats a preview + // stuck showing no activity. + const { data, isLoading: feedLoading } = useMemberActivityFeed(memberId ?? '', { limit: '5', - enabled: !!memberId, + enabled: !!memberId && !settingsQuery.isLoading, + excludedEvents, }); + const isLoading = settingsQuery.isLoading || feedLoading; const rawEvents: MemberActivityEvent[] = data?.events ?? []; const events = rawEvents.map((rawEvent) => parseMemberEvent(rawEvent, { diff --git a/apps/admin/src/members/detail/member-event.test.ts b/apps/admin/src/members/detail/member-event.test.ts index 18981a062db..7ef5fd281a3 100644 --- a/apps/admin/src/members/detail/member-event.test.ts +++ b/apps/admin/src/members/detail/member-event.test.ts @@ -64,6 +64,7 @@ describe('parseMemberEvent — icon', () => { ['email_failed_event', {}, 'event-email-delivery-failed'], ['email_complaint_event', {}, 'event-email-delivery-spam'], ['email_change_event', {}, 'event-email-changed'], + ['metafield_change_event', { source: 'portal', metafields: [] }, 'event-metafields-changed'], ['comment_event', {}, 'event-comment'], ['click_event', {}, 'event-click'], ['aggregated_click_event', {}, 'event-click'], @@ -164,6 +165,77 @@ describe('parseMemberEvent — action text', () => { ); }); + describe('metafield_change_event', () => { + const fields = (...names: string[]) => + names.map((name, index) => ({ namespace: 'custom', key: `field_${index}`, name })); + + it('names the fields a member changed, and where', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('Home address', 'Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Home address and Job title in Portal'); + }); + + it('names a single field on its own', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Job title in Portal'); + }); + + it('counts the rest once a list is too long to read at a glance', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('A', 'B', 'C', 'D', 'E'), + }), + defaultCtx, + ).action, + ).toBe('updated A, B, C and 2 more fields in Portal'); + }); + + it('reads the place a change was made after the fields', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { source: 'admin_api', metafields: fields('Job title') }), + defaultCtx, + ).action, + ).toBe('updated Job title through the Admin API'); + }); + + it('does not claim a place it cannot name', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'somewhere_new', + metafields: fields('Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Job title'); + }); + + it('describes an entry that names no fields as a generic change', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { source: 'portal', metafields: [] }), + defaultCtx, + ).action, + ).toBe('updated custom fields in Portal'); + }); + }); + it('automated_email_sent_event with free/paid slug → welcome (Free/Paid)', () => { expect( parseMemberEvent( diff --git a/apps/admin/src/members/detail/member-event.ts b/apps/admin/src/members/detail/member-event.ts index 5a7597ef5af..2f3399305ad 100644 --- a/apps/admin/src/members/detail/member-event.ts +++ b/apps/admin/src/members/detail/member-event.ts @@ -1,3 +1,9 @@ +import { + isMetafieldChangeSource, + type MetafieldChangeEntry, + type MetafieldChangeSource, +} from '@tryghost/metafield-types'; + // Port of Ember `app/helpers/parse-member-event.js`. // The Ember helper is a Glimmer helper with injected services; we take the // tiny slice of settings-derived context it actually reads (`hasMultipleTiers`, @@ -180,9 +186,57 @@ function getIcon(event: RawMemberEvent): string { if (event.type === 'email_change_event') { icon = 'email-changed'; } + if (event.type === 'metafield_change_event') { + icon = 'metafields-changed'; + } return `event-${icon}`; } +/** Past this many, the rest of a list of field names is counted rather than named. */ +const NAMED_FIELDS_LIMIT = 3; + +/** + * A change to a member's custom fields, as the members events endpoint sends it. Typed + * rather than checked at runtime, like every other response from Ghost's own API: the + * server owns this shape and Admin trusts it. + */ +interface MetafieldChangeEvent extends RawMemberEvent { + type: 'metafield_change_event'; + data: RawMemberEvent['data'] & MetafieldChangeEntry; +} + +function isMetafieldChangeEvent(event: RawMemberEvent): event is MetafieldChangeEvent { + return event.type === 'metafield_change_event'; +} + +// Where a change was made, as it reads after the fields. Keyed by the shared vocabulary, +// so a place added on the server does not compile here until it has a label. A newer +// server than this Admin can still send one it does not know, which is left out. +const METAFIELD_CHANGE_PLACES: Record = { + admin: 'in Admin', + admin_api: 'through the Admin API', + import: 'from an import', + checkout: 'at checkout', + portal: 'in Portal', +}; + +function metafieldChangeAction(event: MetafieldChangeEvent): string { + const names = event.data.metafields.map(({ name }) => name); + + let changed = 'custom fields'; + if (names.length > NAMED_FIELDS_LIMIT) { + const rest = names.length - NAMED_FIELDS_LIMIT; + changed = `${names.slice(0, NAMED_FIELDS_LIMIT).join(', ')} and ${rest} more ${rest === 1 ? 'field' : 'fields'}`; + } else if (names.length > 0) { + changed = + names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + } + + const { source } = event.data; + const place = isMetafieldChangeSource(source) ? METAFIELD_CHANGE_PLACES[source] : undefined; + return place ? `updated ${changed} ${place}` : `updated ${changed}`; +} + function getAction(event: RawMemberEvent, hasMultipleNewsletters: boolean): string | undefined { if ( event.type === 'signup_event' || @@ -271,6 +325,9 @@ function getAction(event: RawMemberEvent, hasMultipleNewsletters: boolean): stri } return 'Email address changed'; } + if (isMetafieldChangeEvent(event)) { + return metafieldChangeAction(event); + } if (event.type === 'donation_event') { return 'Made a one-time payment'; } From 417618e48b6fcb1c22ab39ade72b39d4adaf19b2 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 16 Sep 2026 19:57:28 +0100 Subject: [PATCH 06/27] Added custom field changes to the Ember member activity page ref https://linear.app/ghost/issue/BER-3958 The members activity page is still Ember unless the React version's labs flag is on, and Ember had no case for the custom field change event, so each entry rendered as a row with no text. Ember now describes the entry the same way React Admin does, offers it as a filter, and hides it where custom fields are not available, so both versions of the page show the same thing. --- .../app/controllers/members-activity.js | 9 +++++ .../app/helpers/parse-member-event.js | 34 ++++++++++++++++ apps/ember-admin/app/services/feature.js | 1 + .../app/utils/member-event-types.js | 1 + .../assets/icons/event-metafields-changed.svg | 5 +++ .../filter-dropdown-custom-fields-updated.svg | 5 +++ .../tests/acceptance/members-activity-test.js | 40 +++++++++++++++++++ .../unit/helpers/parse-member-event-test.js | 29 ++++++++++++++ 8 files changed, 124 insertions(+) create mode 100644 apps/ember-admin/public/assets/icons/event-metafields-changed.svg create mode 100644 apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg diff --git a/apps/ember-admin/app/controllers/members-activity.js b/apps/ember-admin/app/controllers/members-activity.js index 36989fc776e..11ca289bd6a 100644 --- a/apps/ember-admin/app/controllers/members-activity.js +++ b/apps/ember-admin/app/controllers/members-activity.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import MemberFetcher from 'ghost-admin/helpers/member-fetcher'; import {EMAIL_EVENTS, NEWSLETTER_EVENTS} from 'ghost-admin/helpers/members-event-filter'; import {action} from '@ember/object'; +import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; import {use} from 'ember-could-get-used-to-this'; @@ -10,6 +11,7 @@ export default class MembersActivityController extends Controller { @service router; @service settings; @service store; + @inject config; @service feature; queryParams = ['excludedEvents', 'member']; @@ -22,6 +24,7 @@ export default class MembersActivityController extends Controller { // we don't want to show or allow filtering of certain events in some situations // - no member selected = don't show email events, they flood the list and the API can't paginate correctly // - newsletter is disabled = don't show email or newletter events + // - custom fields are unavailable = don't show custom field changes get hiddenEvents() { const hiddenEvents = []; @@ -34,6 +37,12 @@ export default class MembersActivityController extends Controller { hiddenEvents.push(...EMAIL_EVENTS, ...NEWSLETTER_EVENTS); } + // Same availability rule as React Admin: the labs flag, and the host limit. + const customFieldsLimited = this.config.hostSettings?.limits?.limitCustomFields?.disabled === true; + if (!this.feature.membersCustomFields || customFieldsLimited) { + hiddenEvents.push('metafield_change_event'); + } + return hiddenEvents; } diff --git a/apps/ember-admin/app/helpers/parse-member-event.js b/apps/ember-admin/app/helpers/parse-member-event.js index 13030377d36..ef128e54ecc 100644 --- a/apps/ember-admin/app/helpers/parse-member-event.js +++ b/apps/ember-admin/app/helpers/parse-member-event.js @@ -158,9 +158,39 @@ export default class ParseMemberEventHelper extends Helper { icon = 'email-changed'; } + if (event.type === 'metafield_change_event') { + icon = 'metafields-changed'; + } + return 'event-' + icon; } + // Mirrors the React Admin activity feed, which describes the same event. + getMetafieldChangeAction(event) { + const namedFieldsLimit = 3; + const places = { + admin: 'in Admin', + admin_api: 'through the Admin API', + import: 'from an import', + checkout: 'at checkout', + portal: 'in Portal' + }; + const names = (event.data.metafields || []).map(field => field.name); + + let changed = 'custom fields'; + if (names.length > namedFieldsLimit) { + const rest = names.length - namedFieldsLimit; + changed = `${names.slice(0, namedFieldsLimit).join(', ')} and ${rest} more ${rest === 1 ? 'field' : 'fields'}`; + } else if (names.length === 1) { + changed = names[0]; + } else if (names.length > 1) { + changed = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; + } + + const place = Object.hasOwn(places, event.data.source) ? places[event.data.source] : null; + return place ? `updated ${changed} ${place}` : `updated ${changed}`; + } + getAction(event, hasMultipleNewsletters) { if (event.type === 'signup_event' || (event.type === 'subscription_event' && event.data.type === 'created' && event.data.signup)) { return 'signed up'; @@ -270,6 +300,10 @@ export default class ParseMemberEventHelper extends Helper { return 'Email address changed'; } + if (event.type === 'metafield_change_event') { + return this.getMetafieldChangeAction(event); + } + if (event.type === 'donation_event') { return 'Made a one-time payment'; } diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 495cbdc1d02..862de2527c3 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -102,6 +102,7 @@ export default class FeatureService extends Service { @feature('csvContentImporter') csvContentImporter; @feature('postsListReact') postsListReact; @feature('membersActivityReact') membersActivityReact; + @feature('membersCustomFields') membersCustomFields; @feature('editorReact') editorReact; @feature('improveSendingUI') improveSendingUI; _user = null; diff --git a/apps/ember-admin/app/utils/member-event-types.js b/apps/ember-admin/app/utils/member-event-types.js index 94db184f3f6..4897039af22 100644 --- a/apps/ember-admin/app/utils/member-event-types.js +++ b/apps/ember-admin/app/utils/member-event-types.js @@ -10,6 +10,7 @@ export const ALL_EVENT_TYPES = [ {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}, {event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'}, {event: 'automated_email_sent_event', icon: 'filter-dropdown-email-received', name: 'Welcome email received', group: 'emails'}, + {event: 'metafield_change_event', icon: 'filter-dropdown-custom-fields-updated', name: 'Custom fields updated', group: 'others'}, {event: 'feedback_event', icon: 'filter-dropdown-feedback', name: 'Feedback', group: 'others'} ]; diff --git a/apps/ember-admin/public/assets/icons/event-metafields-changed.svg b/apps/ember-admin/public/assets/icons/event-metafields-changed.svg new file mode 100644 index 00000000000..96678d20b15 --- /dev/null +++ b/apps/ember-admin/public/assets/icons/event-metafields-changed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg b/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg new file mode 100644 index 00000000000..3fa915ed137 --- /dev/null +++ b/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ember-admin/tests/acceptance/members-activity-test.js b/apps/ember-admin/tests/acceptance/members-activity-test.js index a97cd2027ff..6dbb16976bb 100644 --- a/apps/ember-admin/tests/acceptance/members-activity-test.js +++ b/apps/ember-admin/tests/acceptance/members-activity-test.js @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {click, currentURL, find, findAll} from '@ember/test-helpers'; import {describe, it} from 'mocha'; +import {enableLabsFlag} from '../helpers/labs-flag'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -99,6 +100,45 @@ describe('Acceptance: Members activity', function () { expect(findAll('.gh-members-activity-event').length).to.equal(1); }); + describe('custom field changes', function () { + beforeEach(function () { + this.server.create('member-activity-event', { + memberId: 1, + createdAt: moment('2024-08-19 08:18:08').format('YYYY-MM-DD HH:mm:ss'), + type: 'metafield_change_event', + data: { + source: 'portal', + metafields: [{namespace: 'custom', key: 'job_title', name: 'Job title'}] + } + }); + }); + + it('names the fields and where they were changed, and can be filtered', async function () { + enableLabsFlag(this.server, 'membersCustomFields'); + + await visit('/members-activity'); + expect(find('.gh-members-activity-event-text').textContent.trim()).to.equal('Updated Job title in Portal'); + + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.exist; + }); + + it('is not offered where custom fields are not available', async function () { + await visit('/members-activity'); + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.not.exist; + }); + + it('is not offered when the plan does not include custom fields', async function () { + enableLabsFlag(this.server, 'membersCustomFields'); + this.server.db.configs.update(1, {hostSettings: {limits: {limitCustomFields: {disabled: true}}}}); + + await visit('/members-activity'); + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.not.exist; + }); + }); + it('includes one time (donation) payments under payments filtering', async function () { await visit('/members-activity'); await click('[data-test-id="filter-events-button"]'); diff --git a/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js b/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js index b17c773bf5e..5345912171f 100644 --- a/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js +++ b/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js @@ -154,4 +154,33 @@ describe('Unit: Helper: parse-member-event', function () { expect(result.icon).to.equal('event-gift'); }); }); + + describe('metafield_change_event', function () { + function fields(...names) { + return names.map((name, index) => ({namespace: 'custom', key: `field_${index}`, name})); + } + + it('names the fields that changed and where', function () { + const event = buildEvent({ + type: 'metafield_change_event', + data: {source: 'portal', metafields: fields('Home address', 'Job title')} + }); + const result = helper.compute([event]); + expect(result.action).to.equal('updated Home address and Job title in Portal'); + expect(result.icon).to.equal('event-metafields-changed'); + }); + + it('counts the rest once a list is too long to read at a glance', function () { + const event = buildEvent({ + type: 'metafield_change_event', + data: {source: 'import', metafields: fields('A', 'B', 'C', 'D', 'E')} + }); + expect(helper.compute([event]).action).to.equal('updated A, B, C and 2 more fields from an import'); + }); + + it('leaves out a place it does not know', function () { + const event = buildEvent({type: 'metafield_change_event', data: {source: 'somewhere_new', metafields: fields('Job title')}}); + expect(helper.compute([event]).action).to.equal('updated Job title'); + }); + }); }); From bf56be279cebaaafdf45c0bb7b3428147fdb7baf Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Thu, 17 Sep 2026 15:27:15 +0200 Subject: [PATCH 07/27] Moved newsletter sending to a serialisable job ref https://linear.app/ghost/issue/HKG-1980 Newsletter sends ran as closures on the legacy job manager's inline queue. A closure only exists in the process that created it, so a restart mid-dispatch lost the send and we relied on the boot resume scan to recover it. Sends are now serialisable jobs, which is what lets us move them to a durable backend. With one in place, a send survives a restart without further changes to the email path. We also give them their own lane to run in, so that they're not blocked by other jobs like imports and email analytics fetching --- docs/codebase/jobs.md | 73 ++++++++++++------- ghost/core/core/boot.js | 5 +- .../server/services/email-service/README.md | 35 +++++++++ .../email-service/batch-sending-service.js | 23 +++--- .../email-service/email-service-wrapper.js | 5 +- .../services/email-service/email-service.d.ts | 7 ++ .../services/email-service/email-service.js | 18 ++++- .../email-service/jobs/send-email-job.ts | 12 +++ .../services/jobs-service/jobs-service.ts | 5 ++ .../jobs-service/register-job-handlers.ts | 17 +++++ ghost/core/test/e2e-api/admin/emails.test.js | 4 +- .../test/e2e-api/admin/posts-legacy.test.js | 34 ++++++--- .../core/test/legacy/api/admin/posts.test.js | 16 ++++ .../batch-sending-service.test.js | 29 +++++--- .../email-service/email-service.test.js | 64 +++++++++++++++- .../email-service/jobs/send-email-job.test.ts | 20 +++++ .../register-job-handlers.test.ts | 29 ++++++++ 17 files changed, 325 insertions(+), 71 deletions(-) create mode 100644 ghost/core/core/server/services/email-service/email-service.d.ts create mode 100644 ghost/core/core/server/services/email-service/jobs/send-email-job.ts create mode 100644 ghost/core/test/unit/server/services/email-service/jobs/send-email-job.test.ts diff --git a/docs/codebase/jobs.md b/docs/codebase/jobs.md index 0ee3ea96958..f405f6452cb 100644 --- a/docs/codebase/jobs.md +++ b/docs/codebase/jobs.md @@ -1,44 +1,54 @@ # Jobs System -Ghost's jobs system runs work inline, in a worker thread, or in-process through -the class-based jobs service in -`ghost/core/core/server/services/jobs-service/`. Jobs can run once or on a +Ghost's jobs system runs work in-process through the class-based jobs service +in `ghost/core/core/server/services/jobs-service/`, or through the legacy jobs +service for jobs which have not yet migrated. Jobs can run once or on a schedule. -Use inline jobs for short work which does not block the event loop. Inline jobs -cannot be scheduled. Scheduled and offloaded jobs registered through the legacy -Bree-based service run in worker threads, so they must initialize their own -dependencies and cannot rely on the main Ghost process's memory. Jobs migrated -to the class-based service (token cleanup, gift cleanup, gift reminders, update -checks, members imports) run in-process and share the main process's initialized -services. +Class-based jobs run in-process and share the main process's initialized +services. Keep their work asynchronous so they do not block the event loop. + +The legacy service also supports in-process work through its inline jobs, +which cannot be scheduled. Its scheduled and offloaded jobs run in worker +threads through Bree, so they must initialize their own dependencies and +cannot rely on the main Ghost process's memory. ## Adding a job -Class-based jobs are registered through -`jobsService.handle(JobClass, handler)` in +New jobs use the class-based jobs service. Define a data-only `Job` subclass +with a unique static type and serializable payload, register its handler +through `jobsService.handle(JobClass, handler)` in `ghost/core/core/server/services/jobs-service/register-job-handlers.ts`, which -boot wires up before starting the service; the queue options below are part of -this API. Legacy jobs are registered through the service in -`ghost/core/core/server/services/jobs/`, a wrapper around -`@tryghost/job-manager` that provides Ghost's logging, configuration, models, -and events. +boot wires up before starting the service, and inject `JobsService` into the +service that dispatches the job from boot. Handlers should only route the +rehydrated payload to an initialized service method. The queue options below +are part of this API. -Existing examples include: +Current examples include: -- Gift reminders, which run in-process on a schedule through the class-based - service. -- The site content import (`ghost/core/core/server/data/importer/`), which runs - as an inline job. -- Email analytics, which uses scheduled worker jobs. +- [Gift reminders](../../ghost/core/core/server/services/gifts/jobs/send-gift-reminders-job.ts), + dispatched on a recurring schedule. +- [Newsletter sending](../../ghost/core/core/server/services/email-service/jobs/send-email-job.ts), + dispatched once with an email ID. Prefer an existing job with similar lifecycle and failure requirements as the starting point for a new one. -When adding a service which registers jobs, give it an explicit `init()` call +When adding a service which dispatches jobs, give it an explicit `init()` call from `ghost/core/core/boot.js`. Keep the wrapper's `init()` idempotent, but let -boot own service construction and worker setup rather than initializing on the -first request. +boot own service initialization and dependency injection rather than +initializing on the first request. Wrappers can construct their services +inside `init()`. + +## Legacy jobs + +The legacy service in `ghost/core/core/server/services/jobs/` wraps +`@tryghost/job-manager` and remains for unmigrated jobs. Do not add new jobs to +it. Existing legacy examples include: + +- The site content import (`ghost/core/core/server/data/importer/`), which runs + as an inline job. +- Email analytics, which uses scheduled worker jobs. ## Queues @@ -51,6 +61,12 @@ affects which workers run the job and how many run at once - delivery always routes by job type. Webmention processing runs on its own `webmentions` queue this way. +Newsletter sends use the dedicated `email` queue with concurrency 2, so they +do not compete with member imports, content CSV imports, and other shared jobs +for queue slots. Each send already runs up to two batch workers. Allowing two +sends keeps one long send or retry from blocking every other newsletter. The +in-memory backend enforces these limits per process. + ## Testing Tests for the legacy jobs wrapper live in @@ -58,6 +74,11 @@ Tests for the legacy jobs wrapper live in service in `ghost/core/test/unit/server/services/jobs-service/`. Tests should cover the job's result and failure behavior. +Awaiting `dispatch()` only waits for the backend's enqueue call. Tests which +need the work to finish should wait for its observable result. Newsletter +tests should follow the [email service testing guidance](../../ghost/core/core/server/services/email-service/README.md#testing); +the legacy job manager's `allSettled` event does not cover class-based jobs. + ## Scheduling The legacy jobs service uses Bree for scheduled work; the class-based service diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index ee32ea7d1ac..8efc26cfde0 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -401,7 +401,7 @@ async function initServices({ ghostServer, config, prometheusClient, jobsService indexnow.init(), slack.init(), audienceFeedback.init(), - emailService.init({ ghostServer }), + emailService.init({ ghostServer, jobsService }), emailAnalytics.init({ automationsApi, config, @@ -694,11 +694,13 @@ async function bootGhost({ backend = true, frontend = true, server = true } = {} const memberJobs = require('./server/services/members/jobs'); const mentionsService = require('./server/services/mentions'); const membersService = require('./server/services/members'); + const emailService = require('./server/services/email-service'); memberJobs.init(); assert(gifts.service, 'Gift service should be initialized'); assert(mentionsService.controller, 'Mentions controller should be initialized'); assert(mentionsService.sendingService, 'Mentions sending service should be initialized'); assert(membersService.handleImportJob, 'Members service should be initialized'); + assert(emailService.service, 'Email service should be initialized'); registerJobHandlers({ jobsService, memberJobs, @@ -707,6 +709,7 @@ async function bootGhost({ backend = true, frontend = true, server = true } = {} mentionsController: mentionsService.controller, mentionsSendingService: mentionsService.sendingService, membersService, + emailService: emailService.service, }); await jobsService.start(); debug('End: Register job handlers'); diff --git a/ghost/core/core/server/services/email-service/README.md b/ghost/core/core/server/services/email-service/README.md index 12a3809f149..b66c4236ded 100644 --- a/ghost/core/core/server/services/email-service/README.md +++ b/ghost/core/core/server/services/email-service/README.md @@ -4,6 +4,41 @@ Renders newsletter emails, splits them into recipient batches, and submits the batches to the configured email provider. Domain terms live in [CONTEXT.md](CONTEXT.md). +## Job lifecycle + +New sends, retries, and boot recovery dispatch a data-only `SendEmailJob` +containing the email ID through the class-based jobs service. Boot injects +that service and registers `EmailService.handleSendEmailJob`, which delegates +to `BatchSendingService.emailJob`. The batch sender refetches the email and +acquires its status lock before preparing or submitting batches. See the +[jobs guide](../../../../../../docs/codebase/jobs.md#queues) for queue isolation +and concurrency limits. + +`scheduleEmail()` waits for dispatch to complete, not for submission. The +current in-memory backend provides no durable acceptance guarantee and drops +dispatches after shutdown starts without rejecting them. Email status records +the sending outcome; a job handler returning does not imply submission, since +it may have skipped the send, recorded a failure, or stopped for shutdown. + +Shutdown first stops workers from claiming new batches. The batch sender's +separate cleanup task waits for active batch preparation and submission even +if the jobs backend's shutdown wait times out. This tracking currently ends +before the final email status write. On boot, recovery reschedules eligible +interrupted sends within `bulkEmail:resumeMaxAgeMs` and skips batches already +submitted. + +## Testing + +Tests that publish or retry a newsletter should mock the email provider and +await `waitForEmailStatus(emailId)` from +[`test/utils/batch-email-utils.js`](../../../../test/utils/batch-email-utils.js) +before restoring mocks or deleting fixtures. The helper returns on either +`submitted` or `failed`, so assert the expected status explicitly. + +A terminal email status describes the send's outcome; it does not prove that +every competing retry or job attempting the status lock has finished. Tests +which create competing attempts must also wait for those attempts to settle. + ## Sending status The sending status served by the Admin API's `emails/:id/status` endpoint is diff --git a/ghost/core/core/server/services/email-service/batch-sending-service.js b/ghost/core/core/server/services/email-service/batch-sending-service.js index 601d3f189d9..e7c687a56a1 100644 --- a/ghost/core/core/server/services/email-service/batch-sending-service.js +++ b/ghost/core/core/server/services/email-service/batch-sending-service.js @@ -2,6 +2,7 @@ const logging = require('@tryghost/logging'); const ObjectID = require('bson-objectid').default; const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); +const SendEmailJob = require('./jobs/send-email-job').default; const messages = { emailErrorPartialFailure: 'An error occurred, and your newsletter was only partially sent. Please retry sending the remaining emails.', @@ -17,7 +18,7 @@ const SHUTDOWN_CODE = 'BULK_EMAIL_SHUTDOWN_IN_PROGRESS'; * @typedef {import('./email-renderer')} EmailRenderer * @typedef {import('./domain-warming-service').DomainWarmingService} DomainWarmingService * @typedef {import('./email-renderer').MemberLike} MemberLike - * @typedef {object} JobsService + * @typedef {import('../jobs-service/jobs-service').JobsService} JobsService * @typedef {object} Email * @typedef {object} Newsletter * @typedef {object} Post @@ -188,23 +189,20 @@ class BatchSendingService { } /** - * Schedules a background job that sends the email in the background if it is pending or failed. + * Dispatches the job that sends the email; the job itself only proceeds if the email + * is pending or failed. + * Resolves when dispatch completes, not when the email is sent. * @param {Email} email - * @returns {void} + * @returns {Promise} */ - scheduleEmail(email) { + async scheduleEmail(email) { + await this.#jobsService.dispatch(new SendEmailJob({ emailId: email.id })); logging.info(`[Background Job] batch-sending-service-job queued for email ${email.id}`); - return this.#jobsService.addJob({ - name: 'batch-sending-service-job', - job: this.emailJob.bind(this), - data: { emailId: email.id }, - offloaded: false, - }); } /** - * @private - * @param {{emailId: string}} data Data passed from the job service. We only need the emailId because we need to refetch the email anyway to make sure the status is right and 'locked'. + * Sends an email after refetching it and acquiring its status lock. + * @param {{emailId: string}} data Identifier of the email to refetch and lock. */ async emailJob({ emailId }) { logging.info(`[Background Job] batch-sending-service-job started for email ${emailId}`); @@ -270,6 +268,7 @@ class BatchSendingService { { ...this.#getAfterRetryConfig(), description: `email ${emailId} -> submitted` }, ); logging.info( + { system: { event: 'send_email.submitted', email_id: emailId } }, `[Background Job] batch-sending-service-job completed for email ${emailId} in ${Date.now() - startTime}ms`, ); } catch (e) { diff --git a/ghost/core/core/server/services/email-service/email-service-wrapper.js b/ghost/core/core/server/services/email-service/email-service-wrapper.js index ff77e39e300..53ea3a65062 100644 --- a/ghost/core/core/server/services/email-service/email-service-wrapper.js +++ b/ghost/core/core/server/services/email-service/email-service-wrapper.js @@ -1,3 +1,4 @@ +const assert = require('node:assert/strict'); const debug = require('@tryghost/debug')('i18n'); const url = require('../../api/endpoints/utils/serializers/output/utils/url'); const events = require('../../lib/common/events'); @@ -14,10 +15,11 @@ class EmailServiceWrapper { return jsonModel.url; } - init({ ghostServer } = {}) { + init({ ghostServer, jobsService } = {}) { if (this.service) { return; } + assert(jobsService, 'Email service requires the jobs service'); const EmailService = require('./email-service'); const EmailController = require('./email-controller'); @@ -36,7 +38,6 @@ class EmailServiceWrapper { const configService = require('../../../shared/config'); const settingsCache = require('../../../shared/settings-cache'); const settingsHelpers = require('../settings-helpers'); - const jobsService = require('../jobs'); const membersService = require('../members'); const db = require('../../data/db'); const sentry = require('../../../shared/sentry'); diff --git a/ghost/core/core/server/services/email-service/email-service.d.ts b/ghost/core/core/server/services/email-service/email-service.d.ts new file mode 100644 index 00000000000..c81df0fe9c1 --- /dev/null +++ b/ghost/core/core/server/services/email-service/email-service.d.ts @@ -0,0 +1,7 @@ +import type SendEmailJob from './jobs/send-email-job'; + +declare class EmailService { + handleSendEmailJob(job: SendEmailJob): Promise; +} + +export = EmailService; diff --git a/ghost/core/core/server/services/email-service/email-service.js b/ghost/core/core/server/services/email-service/email-service.js index 16ae37ea4b8..07956e5f0e7 100644 --- a/ghost/core/core/server/services/email-service/email-service.js +++ b/ghost/core/core/server/services/email-service/email-service.js @@ -204,7 +204,7 @@ class EmailService { }); try { - this.#batchSendingService.scheduleEmail(email); + await this.#batchSendingService.scheduleEmail(email); } catch (e) { await email.save( { @@ -334,7 +334,7 @@ class EmailService { logging.warn(`Email resume: scheduling ${email.id} for re-send ${JSON.stringify(breadcrumb)}`); // Skip checkLimits — this email already passed limits when first sent. - this.#batchSendingService.scheduleEmail(email); + await this.#batchSendingService.scheduleEmail(email); } async #buildResumeBreadcrumb(email) { @@ -392,10 +392,22 @@ class EmailService { // so we have a immediate response when retrying an email (schedule can take a while to kick off sometimes) await email.save({ status: 'pending' }, { patch: true }); - this.#batchSendingService.scheduleEmail(email); + try { + await this.#batchSendingService.scheduleEmail(email); + } catch (e) { + await email.save({ status: 'failed' }, { patch: true }); + throw e; + } return email; } + /** + * @param {import('./jobs/send-email-job').default} job + */ + async handleSendEmailJob(job) { + await this.#batchSendingService.emailJob({ emailId: job.emailId }); + } + /** * @params {string|null} [audienceStatus] - the audience's free/paid status * ('status:free' / 'status:-free'), see EmailRenderer#describeSegment diff --git a/ghost/core/core/server/services/email-service/jobs/send-email-job.ts b/ghost/core/core/server/services/email-service/jobs/send-email-job.ts new file mode 100644 index 00000000000..ccdc4731d0f --- /dev/null +++ b/ghost/core/core/server/services/email-service/jobs/send-email-job.ts @@ -0,0 +1,12 @@ +import { Job } from '../../jobs-service/job'; + +export default class SendEmailJob extends Job { + static type = 'send-email'; + + readonly emailId: string; + + constructor({ emailId }: { emailId: string }) { + super(); + this.emailId = emailId; + } +} diff --git a/ghost/core/core/server/services/jobs-service/jobs-service.ts b/ghost/core/core/server/services/jobs-service/jobs-service.ts index f1ee98be509..e85db9fdd27 100644 --- a/ghost/core/core/server/services/jobs-service/jobs-service.ts +++ b/ghost/core/core/server/services/jobs-service/jobs-service.ts @@ -114,6 +114,11 @@ export class JobsService { return queue === undefined ? undefined : { queue }; } + /** + * Resolves when the backend's enqueue call completes, without waiting for the + * handler to run. Enqueue errors reject this promise. Resolution does not + * guarantee execution: the backend controls what happens to work during shutdown. + */ async dispatch(job: Job): Promise { const envelope = this.#buildEnvelope(job); await this.#backend.enqueue(envelope, this.#routingFor(envelope.type)); diff --git a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts index a21d89571b2..53294b4d014 100644 --- a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -15,6 +15,8 @@ import type MentionController from '../mentions/mention-controller'; import type MentionSendingService from '../mentions/mention-sending-service'; import ProcessWebmentionJob from '../mentions/process-webmention-job'; import SendWebmentionsJob from '../mentions/send-webmentions-job'; +import type EmailService from '../email-service/email-service'; +import SendEmailJob from '../email-service/jobs/send-email-job'; const updateCheck = require('../update-check'); @@ -26,6 +28,11 @@ const updateCheck = require('../update-check'); // concurrency. const WEBMENTIONS_QUEUE: JobHandlingOptions = { queue: 'webmentions', concurrency: 3 }; +// Keep newsletter sends independent of imports and other shared work. Two sends +// can progress at once, each with its own two batch workers, so a long send or +// retry does not hold up every other newsletter. +const EMAIL_QUEUE: JobHandlingOptions = { queue: 'email', concurrency: 2 }; + interface RegisterJobHandlersDependencies { jobsService: JobsService; memberJobs: { @@ -39,6 +46,7 @@ interface RegisterJobHandlersDependencies { membersService: { handleImportJob(job: MembersImportJob): Promise; }; + emailService: EmailService; } export default function registerJobHandlers({ @@ -49,6 +57,7 @@ export default function registerJobHandlers({ mentionsController, mentionsSendingService, membersService, + emailService, }: RegisterJobHandlersDependencies): void { jobsService.handle(CleanTokensJob, async () => { await memberJobs.cleanTokens(); @@ -97,4 +106,12 @@ export default function registerJobHandlers({ }, WEBMENTIONS_QUEUE, ); + + jobsService.handle( + SendEmailJob, + async (job) => { + await emailService.handleSendEmailJob(job); + }, + EMAIL_QUEUE, + ); } diff --git a/ghost/core/test/e2e-api/admin/emails.test.js b/ghost/core/test/e2e-api/admin/emails.test.js index 1ec0ae1d778..79f970a3776 100644 --- a/ghost/core/test/e2e-api/admin/emails.test.js +++ b/ghost/core/test/e2e-api/admin/emails.test.js @@ -16,7 +16,7 @@ const { } = matchers; const assert = require('node:assert/strict'); const sinon = require('sinon'); -const jobManager = require('../../../core/server/services/jobs/job-service'); +const { waitForEmailStatus } = require('../../utils/batch-email-utils'); const models = require('../../../core/server/models'); const db = require('../../../core/server/data/db'); const settingsHelpers = require('../../../core/server/services/settings-helpers'); @@ -199,7 +199,7 @@ describe('Emails API', function () { etag: anyEtag, }); - await jobManager.allSettled(); + await waitForEmailStatus(fixtureManager.get('emails', 1).id); mockManager.assert.emittedEvent('email.edited'); }); diff --git a/ghost/core/test/e2e-api/admin/posts-legacy.test.js b/ghost/core/test/e2e-api/admin/posts-legacy.test.js index 50e08939ce3..337cdcd988e 100644 --- a/ghost/core/test/e2e-api/admin/posts-legacy.test.js +++ b/ghost/core/test/e2e-api/admin/posts-legacy.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const { assertExists } = require('../../utils/assertions'); +const { waitForEmailStatus } = require('../../utils/batch-email-utils'); const supertest = require('supertest'); const _ = require('lodash'); const moment = require('moment-timezone'); @@ -1036,7 +1037,8 @@ describe('Posts API', function () { assertExists(email); assert.equal(email.get('newsletter_id'), newsletterId); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Interprets sent as published for a post with email', async function () { @@ -1117,7 +1119,8 @@ describe('Posts API', function () { assertExists(email); assert.equal(email.get('newsletter_id'), newsletterId); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish an email_only post by setting status to published', async function () { @@ -1197,7 +1200,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'all'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish an email_only post with free filter', async function () { @@ -1280,7 +1284,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'status:free'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish an email_only post by setting the status to sent', async function () { @@ -1363,7 +1368,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'status:free'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish a scheduled post', async function () { @@ -1469,7 +1475,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'all'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish a scheduled post with custom email segment', async function () { @@ -1577,7 +1584,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'status:free'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish a scheduled post without newsletter', async function () { @@ -1786,7 +1794,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'all'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Cannot schedule an email only post without newsletter reference', async function () { @@ -1899,7 +1908,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'status:-free'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); const unpublished = { status: 'draft', @@ -2100,7 +2110,8 @@ describe('Posts API', function () { assertExists(email); assert.equal(email.get('newsletter_id'), newsletterId); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('Can publish an email_only post', async function () { @@ -2171,7 +2182,8 @@ describe('Posts API', function () { assert.equal(email.get('newsletter_id'), newsletterId); assert.equal(email.get('recipient_filter'), 'all'); - assert(['pending', 'submitted', 'submitting'].includes(email.get('status'))); + const sentEmail = await waitForEmailStatus(email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); }); diff --git a/ghost/core/test/legacy/api/admin/posts.test.js b/ghost/core/test/legacy/api/admin/posts.test.js index cc3f7b69f7f..320a736b75c 100644 --- a/ghost/core/test/legacy/api/admin/posts.test.js +++ b/ghost/core/test/legacy/api/admin/posts.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const { assertExists } = require('../../../utils/assertions'); +const { waitForEmailStatus } = require('../../../utils/batch-email-utils'); const _ = require('lodash'); const supertest = require('supertest'); const ObjectId = require('bson-objectid').default; @@ -603,6 +604,8 @@ describe('Posts API', function () { }); it('publishes a post with email_only and sends email to all', async function () { + mockManager.mockMailgun(); + const res = await request .post(localUtils.API.getApiQuery('posts/')) .set('Origin', config.get('url')) @@ -655,9 +658,14 @@ describe('Posts API', function () { assertExists(publishedRes.body.posts[0].email); assert.equal(publishedRes.body.posts[0].email.email_count, 4); + + const sentEmail = await waitForEmailStatus(publishedRes.body.posts[0].email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('publishes a post while setting email_only flag sends an email to paid', async function () { + mockManager.mockMailgun(); + const res = await request .post(localUtils.API.getApiQuery('posts/')) .set('Origin', config.get('url')) @@ -709,9 +717,14 @@ describe('Posts API', function () { assertExists(publishedRes.body.posts[0].email); assert.equal(publishedRes.body.posts[0].email.email_count, 2); + + const sentEmail = await waitForEmailStatus(publishedRes.body.posts[0].email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('only send an email to paid subscribed members of the selected newsletter', async function () { + mockManager.mockMailgun(); + const res = await request .post(localUtils.API.getApiQuery('posts/')) .set('Origin', config.get('url')) @@ -763,6 +776,9 @@ describe('Posts API', function () { assertExists(publishedRes.body.posts[0].email); assert.equal(publishedRes.body.posts[0].email.email_count, 2); + + const sentEmail = await waitForEmailStatus(publishedRes.body.posts[0].email.id); + assert.equal(sentEmail.get('status'), 'submitted'); }); it('read-only value do not cause errors when edited', function () { diff --git a/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js b/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js index 0005fe1fdd7..d3df061eeb0 100644 --- a/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js +++ b/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js @@ -37,17 +37,24 @@ describe('Batch Sending Service', function () { }); describe('scheduleEmail', function () { - it('schedules email', async function () { - const jobsService = { - addJob: sinon.stub().resolves(), - }; - const service = new BatchSendingService({ - jobsService, - }); - service.scheduleEmail(createModel({})); - sinon.assert.calledOnce(jobsService.addJob); - const job = jobsService.addJob.firstCall.args[0].job; - assert.equal(typeof job, 'function'); + it('dispatches a SendEmailJob for the email', async function () { + const jobsService = { dispatch: sinon.stub().resolves() }; + const service = new BatchSendingService({ jobsService }); + + await service.scheduleEmail(createModel({ id: 'email-id' })); + + sinon.assert.calledOnce(jobsService.dispatch); + const job = jobsService.dispatch.firstCall.args[0]; + assert.equal(job.constructor.type, 'send-email'); + assert.deepEqual({ ...job }, { emailId: 'email-id' }); + }); + + it('rejects when the queue refuses the dispatch', async function () { + const error = new Error('Queue unavailable'); + const jobsService = { dispatch: sinon.stub().rejects(error) }; + const service = new BatchSendingService({ jobsService }); + + await assert.rejects(() => service.scheduleEmail(createModel({ id: 'email-id' })), error); }); }); diff --git a/ghost/core/test/unit/server/services/email-service/email-service.test.js b/ghost/core/test/unit/server/services/email-service/email-service.test.js index 2e00aa8a679..4972ddca339 100644 --- a/ghost/core/test/unit/server/services/email-service/email-service.test.js +++ b/ghost/core/test/unit/server/services/email-service/email-service.test.js @@ -22,7 +22,7 @@ describe('Email Service', function () { members: null, }; verificicationRequired = false; - scheduleEmail = sinon.stub().returns(); + scheduleEmail = sinon.stub().resolves(); scheduleRecurringNewslettersJob = sinon.stub().resolves(); settings = {}; settingsCache = { @@ -491,7 +491,7 @@ describe('Email Service', function () { }), }); - scheduleEmail.throws(new Error('Test error')); + scheduleEmail.rejects(new Error('Test error')); const email = await service.createEmail(post); sinon.assert.calledOnce(scheduleEmail); @@ -509,7 +509,7 @@ describe('Email Service', function () { }), }); - scheduleEmail.throws(new Error()); + scheduleEmail.rejects(new Error()); const email = await service.createEmail(post); sinon.assert.calledOnce(scheduleEmail); @@ -547,6 +547,21 @@ describe('Email Service', function () { sinon.assert.calledOnce(scheduleEmail); }); + it('Restores failed status and preserves the error if scheduling fails', async function () { + const schedulingError = new Error('Scheduling failed'); + const email = createModel({ + status: 'failed', + error: 'Original send error', + post: createModel({ status: 'published' }), + }); + scheduleEmail.rejects(schedulingError); + + await assert.rejects(() => service.retryEmail(email), schedulingError); + + assert.equal(email.get('status'), 'failed'); + assert.equal(email.get('error'), 'Original send error'); + }); + it('Does not schedule email again if draft', async function () { const email = createModel({ status: 'failed', @@ -652,6 +667,49 @@ describe('Email Service', function () { sinon.assert.calledOnce(errorLog); }); + it('Continues recovery after a dispatch failure', async function () { + const errorLog = sinon.stub(logging, 'error'); + const updateStatusLock = sinon.stub().resolves(createModel({})); + const emails = [ + createModel({ + id: 'bad-dispatch', + status: 'pending', + post: createModel({ status: 'published' }), + }), + createModel({ + id: 'good-dispatch', + status: 'pending', + post: createModel({ status: 'published' }), + }), + ]; + scheduleEmail.onFirstCall().rejects(new Error('Queue unavailable')); + scheduleEmail.onSecondCall().resolves(); + const localService = new EmailService({ + emailSegmenter: { getMembersCount: () => Promise.resolve(0) }, + limitService: { + isLimited: () => false, + errorIfIsOverLimit: () => {}, + errorIfWouldGoOverLimit: () => {}, + }, + verificationTrigger: { checkVerificationRequired: () => Promise.resolve(false) }, + models: { Email: { findAll: filterAwareFindAll(emails) } }, + batchSendingService: { scheduleEmail, updateStatusLock }, + settingsCache, + emailRenderer, + membersRepository, + sendingService, + emailAnalyticsJobs: { scheduleRecurringNewslettersJob }, + domainWarmingService, + }); + + await localService.resumeInterruptedSends(); + + sinon.assert.calledTwice(scheduleEmail); + assert.equal(scheduleEmail.firstCall.args[0], emails[0]); + assert.equal(scheduleEmail.secondCall.args[0], emails[1]); + sinon.assert.calledOnce(errorLog); + }); + it('Marks email as failed if the parent post is no longer published or sent', async function () { const updateStatusLock = sinon.stub().resolves(createModel({})); const emails = [ diff --git a/ghost/core/test/unit/server/services/email-service/jobs/send-email-job.test.ts b/ghost/core/test/unit/server/services/email-service/jobs/send-email-job.test.ts new file mode 100644 index 00000000000..b46ece00def --- /dev/null +++ b/ghost/core/test/unit/server/services/email-service/jobs/send-email-job.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'vitest'; +import SendEmailJob from '../../../../../../core/server/services/email-service/jobs/send-email-job'; + +describe('SendEmailJob', function () { + it('uses the send-email type and a flat email ID payload', function () { + const job = new SendEmailJob({ emailId: 'email-id' }); + + assert.equal(SendEmailJob.type, 'send-email'); + assert.deepEqual({ ...job }, { emailId: 'email-id' }); + }); + + it('survives JSON serialization', function () { + const job = new SendEmailJob({ emailId: 'email-id' }); + + const revived = new SendEmailJob(JSON.parse(JSON.stringify(job))); + + assert.deepEqual(revived, job); + }); +}); diff --git a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts index 8fd2167f603..bfd76304c78 100644 --- a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts @@ -9,6 +9,7 @@ import MembersImportJob from '../../../../../core/server/services/members/jobs/m import UpdateCheckJob from '../../../../../core/server/services/update-check/jobs/update-check-job'; import ProcessWebmentionJob from '../../../../../core/server/services/mentions/process-webmention-job'; import SendWebmentionsJob from '../../../../../core/server/services/mentions/send-webmentions-job'; +import SendEmailJob from '../../../../../core/server/services/email-service/jobs/send-email-job'; const registerJobHandlers = require('../../../../../core/server/services/jobs-service/register-job-handlers').default; @@ -21,6 +22,7 @@ describe('register-job-handlers', function () { let mentionsController: { processWebmention: sinon.SinonStub }; let mentionsSendingService: { sendWebmentions: sinon.SinonStub }; let membersService: { handleImportJob: sinon.SinonStub }; + let emailService: { handleSendEmailJob: sinon.SinonStub }; // Handlers are looked up by their job type rather than registration order, // so adding a handler does not silently shift which one a test exercises. @@ -47,6 +49,7 @@ describe('register-job-handlers', function () { mentionsController = { processWebmention: sinon.stub().resolves() }; mentionsSendingService = { sendWebmentions: sinon.stub().resolves() }; membersService = { handleImportJob: sinon.stub().resolves() }; + emailService = { handleSendEmailJob: sinon.stub().resolves() }; registerJobHandlers({ jobsService, @@ -56,6 +59,7 @@ describe('register-job-handlers', function () { mentionsController, mentionsSendingService, membersService, + emailService, }); }); @@ -217,4 +221,29 @@ describe('register-job-handlers', function () { assert.deepEqual(registration.args[2], { queue: 'webmentions', concurrency: 3 }); }); + + it('runs send-email with the injected email service', async function () { + const sendEmailHandler = handlerFor('send-email'); + const job = new SendEmailJob({ emailId: 'email-id' }); + + await sendEmailHandler(job); + + assert.ok(emailService.handleSendEmailJob.calledOnceWithExactly(job)); + }); + + it('registers send-email on a dedicated queue with room for two sends', function () { + const registration = registrationFor('send-email'); + + assert.deepEqual(registration.args[2], { queue: 'email', concurrency: 2 }); + }); + + it('propagates send-email failures', async function () { + const error = new Error('Send failed'); + emailService.handleSendEmailJob.rejects(error); + + await assert.rejects( + () => handlerFor('send-email')(new SendEmailJob({ emailId: 'email-id' })), + error, + ); + }); }); From ddaf7d6f288b63b71153278ccd53aa0e8ae28da4 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 17 Sep 2026 09:39:56 -0400 Subject: [PATCH 08/27] =?UTF-8?q?=F0=9F=8E=A8=20Improved=20sitemap=20memor?= =?UTF-8?q?y=20use=20on=20sites=20with=20many=20posts=20(#30838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref - the sitemap generators hold one entry per routable resource for the lifetime of the index, and each entry was a nested element tree for the xml package plus a Moment in a parallel map. Measured against a heap snapshot from the Pro performance benchmark seeded with 10,000 posts, that came to 9.99 MB retained — 8.1% of the process heap, 159,568 objects, 9,381 of them Moments that nothing ever read as Moments: the sort coerces them with a unary minus and the lastmod wants an ISO string. Each entry is now a flat {loc, ts, imageLoc} record and the timestamp is epoch milliseconds. moment still does the parsing, because it is more forgiving than `new Date` about the shapes the database returns; only the stored value changed. Rendering builds the document by joining an array instead of handing the tree to the xml package. That drops the largest allocation in the path — the package concatenates its output, and a rope of several megabytes costs multiples of its length until something flattens it — and lets the two transform-ready urls be made absolute as they are added, rather than by a regex across the finished document. The element shape is fixed and the five characters the package escaped are escaped the same way, so the bytes are unchanged: a differential harness rendered ~2,500 resources through both implementations, including xml special characters in slugs and image names, transform-ready and external canonical urls, missing dates and the author generator's stricter image validation, and every document matched. The index generator now caches its xml. It is the only sitemap a crawler fetches repeatedly, and rendering it counts every resource of every type — at 10,000 posts that `Object.keys(...).length` was 240 ms of the benchmark's profile, a third of all sitemap CPU, recomputed per request. The cache is valid exactly while the manager's index is built, so it is dropped with it. At 50,000 posts the generators hold 14.6 MB rather than 51.1 MB, a render peaks 30 MB above steady state rather than 162 MB, and takes 176 ms rather than 1,796 ms. --- .../sitemap/base-site-map-generator.js | 165 ++++++++---------- .../sitemap/site-map-index-generator.js | 37 ++-- .../services/sitemap/site-map-manager.js | 3 + .../frontend/services/sitemap/sitemap-xml.ts | 117 +++++++++++++ .../core/frontend/services/sitemap/utils.js | 17 -- ghost/core/package.json | 1 - .../services/sitemap/generator.test.js | 134 +++++++++++--- .../frontend/services/sitemap/manager.test.js | 16 ++ .../services/sitemap/sitemap-xml.test.ts | 93 ++++++++++ pnpm-lock.yaml | 3 - 10 files changed, 423 insertions(+), 163 deletions(-) create mode 100644 ghost/core/core/frontend/services/sitemap/sitemap-xml.ts delete mode 100644 ghost/core/core/frontend/services/sitemap/utils.js create mode 100644 ghost/core/test/unit/frontend/services/sitemap/sitemap-xml.test.ts diff --git a/ghost/core/core/frontend/services/sitemap/base-site-map-generator.js b/ghost/core/core/frontend/services/sitemap/base-site-map-generator.js index 15a538610c1..7177c91a8c6 100644 --- a/ghost/core/core/frontend/services/sitemap/base-site-map-generator.js +++ b/ghost/core/core/frontend/services/sitemap/base-site-map-generator.js @@ -1,27 +1,28 @@ -const _ = require('lodash'); -const xml = require('xml'); const moment = require('moment'); -const path = require('path'); const urlUtils = require('../../../shared/url-utils').default; -const localUtils = require('./utils'); - -// Sitemap specific xml namespace declarations that should not change -const XMLNS_DECLS = { - _attr: { - xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9', - 'xmlns:image': 'http://www.google.com/schemas/sitemap-image/1.1', - }, -}; +const sitemapXml = require('./sitemap-xml'); class BaseSiteMapGenerator { constructor() { - this.nodeLookup = {}; - this.nodeTimeLookup = {}; + // id -> {loc, ts, imageLoc}: one flat record per resource, rather than + // the nested element tree the xml package used to take plus a parallel + // map of Moments. Both were held for the lifetime of the index, and at + // 10k posts they measured ~1.1 kB per resource against ~300 B for this. + this.nodeLookup = new Map(); this.siteMapContent = new Map(); this.lastModified = 0; this.maxPerPage = 50000; } + /** + * How many resources this generator holds. Read by the index generator to + * work out how many pages the type needs, so it does not have to know how + * they are stored. + */ + get size() { + return this.nodeLookup.size; + } + hasCanonicalUrl(datum, url) { if (!datum?.canonical_url) { return false; @@ -46,72 +47,58 @@ class BaseSiteMapGenerator { } generateXmlFromNodes(page) { - // Get a mapping of node to timestamp - let nodesToProcess = _.map(this.nodeLookup, (node, id) => { - return { - id: id, - // Using negative here to sort newest to oldest - ts: -(this.nodeTimeLookup[id] || 0), - node: node, - }; - }); - - // Sort nodes by timestamp - nodesToProcess = _.sortBy(nodesToProcess, 'ts'); + // Sort newest to oldest. The records are sorted in place of a wrapper + // object per resource, so a render allocates one array of references. + const records = [...this.nodeLookup.values()]; + records.sort((a, b) => b.ts - a.ts); // Get the page of nodes that was requested - nodesToProcess = nodesToProcess.slice((page - 1) * this.maxPerPage, page * this.maxPerPage); + const pageRecords = records.slice((page - 1) * this.maxPerPage, page * this.maxPerPage); // Do not generate empty sitemaps - if (nodesToProcess.length === 0) { + if (pageRecords.length === 0) { return null; } - // Grab just the nodes - const nodes = _.map(nodesToProcess, 'node'); - - const data = { - // Concat the elements to the _attr declaration - urlset: [XMLNS_DECLS].concat(nodes), - }; - - // Generate full xml - let sitemapXml = localUtils.getDeclarations() + xml(data); - - // Perform url transformations - // - Necessary because sitemap data is supplied by the router which - // uses knex directly bypassing model-layer attribute transforms - sitemapXml = urlUtils.transformReadyToAbsolute(sitemapXml); - - return sitemapXml; + return sitemapXml.renderUrlSet(pageRecords); } addUrl(url, datum) { - // Computed once and threaded through: the three consumers below each - // used to construct their own moment() from the same datum, which - // dominates the cost of bulk adds. + if (this.hasCanonicalUrl(datum, url)) { + return; + } + + // Computed once and threaded through: the consumers below each used to + // construct their own moment() from the same datum, which dominates the + // cost of bulk adds. const lastModified = this.getLastModifiedForDatum(datum); - const node = this.createUrlNodeFromDatum(url, datum, lastModified); - if (node && !this.hasCanonicalUrl(datum, url)) { - this.updateLastModified(datum, lastModified); - this.updateLookups(datum, node, lastModified); - // force regeneration of xml - this.siteMapContent.clear(); - } + this.updateLastModified(datum, lastModified); + this.updateLookups(datum, this.createRecordFromDatum(url, datum, lastModified)); + // force regeneration of xml + this.siteMapContent.clear(); } /** - * @returns {moment.Moment} + * The parse stays with moment, which is more forgiving than `new Date` of + * the shapes the database hands back; only what is stored changes, from a + * Moment to the epoch milliseconds the sort and the lastmod both want. + * + * @returns {number} epoch milliseconds */ getLastModifiedForDatum(datum) { - if (datum.updated_at || datum.published_at || datum.created_at) { - const modifiedDate = datum.updated_at || datum.published_at || datum.created_at; + const modifiedDate = datum.updated_at || datum.published_at || datum.created_at; - return moment(modifiedDate); - } else { - return moment(); + if (!modifiedDate) { + return Date.now(); } + + const lastModified = moment(modifiedDate).valueOf(); + + // An unparseable date used to render an empty ; now it would + // throw out of `new Date(NaN).toISOString()` and take the whole sitemap + // with it, so fall back to now. + return Number.isNaN(lastModified) ? Date.now() : lastModified; } updateLastModified(datum, lastModified = this.getLastModifiedForDatum(datum)) { @@ -121,54 +108,40 @@ class BaseSiteMapGenerator { } /** - * * @param {string} url * @param {Object} datum - * @returns + * @param {number} [lastModified] epoch milliseconds + * @returns {{loc: string, ts: number, imageLoc: string|null}} */ - createUrlNodeFromDatum(url, datum, lastModified = this.getLastModifiedForDatum(datum)) { - let node; - let imgNode; - - node = { - url: [{ loc: url }, { lastmod: lastModified.toISOString() }], + createRecordFromDatum(url, datum, lastModified = this.getLastModifiedForDatum(datum)) { + return { + // Transformed here rather than over the finished document: the two + // urls are the only transform-ready values a sitemap carries, and a + // regex across several megabytes of xml both costs and leaves a rope. + loc: urlUtils.transformReadyToAbsolute(url), + ts: lastModified, + imageLoc: this.createImageLocFromDatum(datum), }; - - imgNode = this.createImageNodeFromDatum(datum); - - if (imgNode) { - node.url.push(imgNode); - } - - return node; } - createImageNodeFromDatum(datum) { + createImageLocFromDatum(datum) { // Check for cover first because user has cover but the rest only have image const image = datum.cover_image || datum.profile_image || datum.feature_image; - let imageUrl; - let imageEl; - if (!image) { - return; + return null; } // Grab the image url - imageUrl = urlUtils.urlFor('image', { image: image }, true); + const imageUrl = urlUtils.urlFor('image', { image: image }, true); - // Verify the url structure + // Verify the url structure. Checked before the transform, as it always + // has been: the url the validator sees is the one urlFor returned. if (!this.validateImageUrl(imageUrl)) { - return; + return null; } - // Create the weird xml node syntax structure that is expected - imageEl = [{ 'image:loc': imageUrl }, { 'image:caption': path.basename(imageUrl) }]; - - // Return the node to be added to the url xml node - return { - 'image:image': imageEl, - }; + return urlUtils.transformReadyToAbsolute(imageUrl); } validateImageUrl(imageUrl) { @@ -185,14 +158,12 @@ class BaseSiteMapGenerator { return content; } - updateLookups(datum, node, lastModified = this.getLastModifiedForDatum(datum)) { - this.nodeLookup[datum.id] = node; - this.nodeTimeLookup[datum.id] = lastModified; + updateLookups(datum, record) { + this.nodeLookup.set(datum.id, record); } reset() { - this.nodeLookup = {}; - this.nodeTimeLookup = {}; + this.nodeLookup.clear(); this.siteMapContent.clear(); this.lastModified = 0; } diff --git a/ghost/core/core/frontend/services/sitemap/site-map-index-generator.js b/ghost/core/core/frontend/services/sitemap/site-map-index-generator.js index 7a4216653c6..ee5f3439bad 100644 --- a/ghost/core/core/frontend/services/sitemap/site-map-index-generator.js +++ b/ghost/core/core/frontend/services/sitemap/site-map-index-generator.js @@ -1,37 +1,31 @@ const _ = require('lodash'); -const xml = require('xml'); -const moment = require('moment'); const urlUtils = require('../../../shared/url-utils').default; -const localUtils = require('./utils'); - -const XMLNS_DECLS = { - _attr: { - xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9', - }, -}; +const sitemapXml = require('./sitemap-xml'); class SiteMapIndexGenerator { constructor(options) { options = options || {}; this.types = options.types; this.maxPerPage = options.maxPerPage; + // The index is the one sitemap crawlers fetch repeatedly, and rendering + // it counts every resource of every type. Cached until something + // invalidates the index; the manager resets this when it does. + this.siteMapContent = null; } getXml() { - const urlElements = this.generateSiteMapUrlElements(); + if (this.siteMapContent !== null) { + return this.siteMapContent; + } - const data = { - // Concat the elements to the _attr declaration - sitemapindex: [XMLNS_DECLS].concat(urlElements), - }; + this.siteMapContent = sitemapXml.renderSiteMapIndex(this.generateSiteMapUrlElements()); - // Return the xml - return localUtils.getDeclarations() + xml(data); + return this.siteMapContent; } generateSiteMapUrlElements() { return _.map(this.types, (resourceType) => { - const noOfPages = Math.ceil(Object.keys(resourceType.nodeLookup).length / this.maxPerPage); + const noOfPages = Math.ceil(resourceType.size / this.maxPerPage); const pages = []; for (let i = 0; i < noOfPages; i++) { const page = i === 0 ? '' : `-${i + 1}`; @@ -39,16 +33,17 @@ class SiteMapIndexGenerator { { relativeUrl: '/sitemap-' + resourceType.name + page + '.xml' }, true, ); - const lastModified = resourceType.lastModified; - pages.push({ - sitemap: [{ loc: url }, { lastmod: moment(lastModified).toISOString() }], - }); + pages.push({ loc: url, ts: resourceType.lastModified }); } return pages; }).flat(); } + + reset() { + this.siteMapContent = null; + } } module.exports = SiteMapIndexGenerator; diff --git a/ghost/core/core/frontend/services/sitemap/site-map-manager.js b/ghost/core/core/frontend/services/sitemap/site-map-manager.js index 9bc3e54faae..b08f8b1951c 100644 --- a/ghost/core/core/frontend/services/sitemap/site-map-manager.js +++ b/ghost/core/core/frontend/services/sitemap/site-map-manager.js @@ -197,6 +197,9 @@ class SiteMapManager { _invalidateIndex() { this._indexBuilt = false; this._indexEpoch += 1; + // The index generator's cached xml is valid exactly while _indexBuilt + // is, so the two are dropped together. + this.index.reset(); } /** diff --git a/ghost/core/core/frontend/services/sitemap/sitemap-xml.ts b/ghost/core/core/frontend/services/sitemap/sitemap-xml.ts new file mode 100644 index 00000000000..948d33e681f --- /dev/null +++ b/ghost/core/core/frontend/services/sitemap/sitemap-xml.ts @@ -0,0 +1,117 @@ +import path from 'node:path'; +import urlUtils from '../../../shared/url-utils'; + +// Ghost renders exactly two sitemap documents, both with a shape the +// sitemaps.org schema fixes: a per resource type, and the +// that lists them. They are emitted here directly rather than +// through a generic xml library — for a shape this fixed a library spends +// several times as long walking an object tree as writing the tags costs, and +// keeping both documents in one module keeps escaping in one auditable place. + +const URLSET_OPEN = + ''; +const URLSET_CLOSE = ''; + +const SITEMAPINDEX_OPEN = ''; +const SITEMAPINDEX_CLOSE = ''; + +// The characters the xml package escaped in text content, kept character for +// character so the rendered sitemaps do not change. +const XML_ESCAPES: Record = { + '&': '&', + '"': '"', + "'": ''', + '<': '<', + '>': '>', +}; + +/** + * An entry of a sitemap document: an absolute url, and the epoch milliseconds + * behind its . + */ +export interface SiteMapEntry { + loc: string; + ts: number; +} + +/** + * A entry, which may also carry an image. + */ +export interface SiteMapUrlEntry extends SiteMapEntry { + imageLoc?: string | null; +} + +export function escapeXml(value: string): string { + return value.replace(/[&"'<>]/g, (char) => XML_ESCAPES[char]); +} + +export function getDeclarations(): string { + const baseUrl = urlUtils.urlFor('sitemap_xsl', true).replace(/^(http:|https:)/, ''); + + return ( + '' + + '' + ); +} + +/** + * The document for one page of one resource type. + * + * @param entries in the order they should appear + */ +export function renderUrlSet(entries: readonly SiteMapUrlEntry[]): string { + // Joined rather than concatenated: join returns a flat string, where + // repeated concatenation leaves a rope whose fragments are held for as long + // as the cached sitemap is. + const parts = [getDeclarations(), URLSET_OPEN]; + + for (const entry of entries) { + parts.push( + '', + escapeXml(entry.loc), + '', + new Date(entry.ts).toISOString(), + '', + ); + + if (entry.imageLoc) { + parts.push( + '', + escapeXml(entry.imageLoc), + '', + escapeXml(path.basename(entry.imageLoc)), + '', + ); + } + + parts.push(''); + } + + parts.push(URLSET_CLOSE); + + return parts.join(''); +} + +/** + * The document listing every page of every resource type. + */ +export function renderSiteMapIndex(entries: readonly SiteMapEntry[]): string { + const parts = [getDeclarations(), SITEMAPINDEX_OPEN]; + + for (const entry of entries) { + parts.push( + '', + escapeXml(entry.loc), + '', + new Date(entry.ts).toISOString(), + '', + ); + } + + parts.push(SITEMAPINDEX_CLOSE); + + return parts.join(''); +} diff --git a/ghost/core/core/frontend/services/sitemap/utils.js b/ghost/core/core/frontend/services/sitemap/utils.js deleted file mode 100644 index 47fcb97ddb4..00000000000 --- a/ghost/core/core/frontend/services/sitemap/utils.js +++ /dev/null @@ -1,17 +0,0 @@ -const urlUtils = require('../../../shared/url-utils').default; -let sitemapsUtils; - -sitemapsUtils = { - getDeclarations: function () { - let baseUrl = urlUtils.urlFor('sitemap_xsl', true); - baseUrl = baseUrl.replace(/^(http:|https:)/, ''); - return ( - '' + - '' - ); - }, -}; - -module.exports = sitemapsUtils; diff --git a/ghost/core/package.json b/ghost/core/package.json index 18b8d962746..d5abf3ad924 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -246,7 +246,6 @@ "type-fest": "catalog:", "ua-parser-js": "1.0.41", "unidecode": "catalog:", - "xml": "1.0.1", "zod": "catalog:" }, "devDependencies": { diff --git a/ghost/core/test/unit/frontend/services/sitemap/generator.test.js b/ghost/core/test/unit/frontend/services/sitemap/generator.test.js index 9571a30f196..885e86dee7c 100644 --- a/ghost/core/test/unit/frontend/services/sitemap/generator.test.js +++ b/ghost/core/test/unit/frontend/services/sitemap/generator.test.js @@ -1,6 +1,5 @@ const sinon = require('sinon'); const ObjectId = require('bson-objectid').default; -const _ = require('lodash'); const assert = require('node:assert/strict'); const testUtils = require('../../../../utils'); const urlUtils = require('../../../../../core/shared/url-utils').default; @@ -35,7 +34,7 @@ describe('Generators', function () { generator.getXml(); // We end up with 10 nodes - assert.equal(Object.keys(generator.nodeLookup).length, 10); + assert.equal(generator.size, 10); // But only 5 are output in the xml assert.equal(generator.siteMapContent.get(1).match(//g).length, 5); @@ -63,7 +62,7 @@ describe('Generators', function () { addPostAt(generator, 'older', '2024-01-01T00:00:00.000Z'); addPostAt(generator, 'newer', '2024-06-01T00:00:00.000Z'); - assert.equal(generator.lastModified.toISOString(), '2024-06-01T00:00:00.000Z'); + assert.equal(new Date(generator.lastModified).toISOString(), '2024-06-01T00:00:00.000Z'); // The index rebuild resets every generator and replays only the // resources that are still routable — here "newer" was deleted. @@ -71,7 +70,7 @@ describe('Generators', function () { addPostAt(generator, 'older', '2024-01-01T00:00:00.000Z'); assert.equal( - generator.lastModified.toISOString(), + new Date(generator.lastModified).toISOString(), '2024-01-01T00:00:00.000Z', 'lastModified must fall back to the newest surviving resource', ); @@ -159,10 +158,30 @@ describe('Generators', function () { generator.types.posts.reset(); addPostAt('older', '2024-01-01T00:00:00.000Z'); + // The index caches its xml; the manager drops that cache with the + // rest of the index whenever anything invalidates it. + generator.reset(); assert.match(generator.getXml(), /2024-01-01T00:00:00.000Z<\/lastmod>/); }); + it('renders once and serves the cache until it is reset', function () { + generator.types.posts.addUrl('http://my-ghost-blog.com/episode-1/', { + id: 'identifier1', + staticRoute: true, + }); + + const first = generator.getXml(); + sinon.spy(generator, 'generateSiteMapUrlElements'); + + assert.equal(generator.getXml(), first); + sinon.assert.notCalled(generator.generateSiteMapUrlElements); + + generator.reset(); + generator.getXml(); + sinon.assert.calledOnce(generator.generateSiteMapUrlElements); + }); + it('creates multiple pages when there are too many posts', function () { for (let i = 0; i < 10; i++) { generator.types.posts.addUrl( @@ -188,9 +207,9 @@ describe('Generators', function () { generator = new PostGenerator(); }); - describe('fn: createNodeFromDatum', function () { + describe('url elements', function () { it('adds an image:image element if post has a cover image', function () { - const urlNode = generator.createUrlNodeFromDatum( + generator.addUrl( 'https://myblog.com/test/', testUtils.DataGenerator.forKnex.createPost({ feature_image: 'post-100.jpg', @@ -199,24 +218,91 @@ describe('Generators', function () { }), ); - assert(Array.isArray(urlNode.url)); - assert.equal(urlNode.url.length, 3); - - /** - * A urlNode looks something like: - * { url: - * [ { loc: 'http://127.0.0.1:2369/author/' }, - * { lastmod: '2014-12-22T11:54:00.100Z' }, - * { 'image:image': [ - * { 'image:loc': 'post-100.jpg' }, - * { 'image:caption': 'post-100.jpg' } - * ] } - * ] } - */ - const flatNode = _.extend.apply(_, urlNode.url); - assert('loc' in flatNode); - assert('lastmod' in flatNode); - assert('image:image' in flatNode); + const xml = generator.getXml(); + + assert.match(xml, /https:\/\/myblog\.com\/test\/<\/loc>/); + assert.match(xml, /\d{4}-\d{2}-\d{2}T[\d:.]+Z<\/lastmod>/); + assert.match( + xml, + /[^<]+<\/image:loc>post-100\.jpg<\/image:caption><\/image:image>/, + ); + }); + + it('omits the image:image element when there is no image', function () { + generator.addUrl( + 'https://myblog.com/test/', + testUtils.DataGenerator.forKnex.createPost({ + feature_image: null, + page: false, + slug: 'test', + }), + ); + + const xml = generator.getXml(); + + assert.match(xml, /https:\/\/myblog\.com\/test\/<\/loc>/); + assert.doesNotMatch(xml, /image:image/); + }); + + it('falls back to now when the datum carries no date at all', function () { + const before = Date.now(); + + generator.addUrl('https://myblog.com/test/', { + id: 'identifier1', + staticRoute: true, + }); + + const lastmod = Date.parse(generator.getXml().match(/([^<]+)<\/lastmod>/)[1]); + assert(lastmod >= before && lastmod <= Date.now()); + }); + + it('falls back to now rather than throwing on a date it cannot parse', function () { + const before = Date.now(); + + generator.addUrl('https://myblog.com/test/', { + id: 'identifier1', + // An Invalid Date, which `new Date(...).toISOString()` throws on + updated_at: new Date(NaN), + }); + + const lastmod = Date.parse(generator.getXml().match(/([^<]+)<\/lastmod>/)[1]); + assert(lastmod >= before && lastmod <= Date.now()); + }); + + it('omits the image:image element when the url does not validate', function () { + const userGenerator = new UserGenerator(); + + // A path without a host: rejected by the author generator's stricter + // check, so the entry keeps its and loses only the image. + sinon.stub(urlUtils, 'urlFor').returns('/content/images/1.jpg'); + + userGenerator.addUrl('https://myblog.com/author/jo/', { + id: 'identifier1', + profile_image: '1.jpg', + updated_at: '2024-01-01T00:00:00.000Z', + }); + + const record = userGenerator.nodeLookup.get('identifier1'); + assert.equal(record.imageLoc, null); + assert.equal(record.loc, 'https://myblog.com/author/jo/'); + }); + + it('escapes xml special characters in urls and captions', function () { + generator.addUrl( + 'https://myblog.com/a&b/', + testUtils.DataGenerator.forKnex.createPost({ + feature_image: 'me & you.jpg', + page: false, + slug: 'a&b', + }), + ); + + const xml = generator.getXml(); + + assert.match(xml, /https:\/\/myblog\.com\/a&b\/<\/loc>/); + assert.match(xml, /me & you\.jpg<\/image:caption>/); + // The raw ampersand must not survive anywhere in the document + assert.doesNotMatch(xml, /&(?!amp;|quot;|apos;|lt;|gt;)/); }); }); diff --git a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js index 8209dfbf8db..e245a55c443 100644 --- a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js +++ b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js @@ -229,6 +229,22 @@ describe('Unit: sitemap/manager', function () { sinon.assert.callCount(fetchStub, 8); }); + it('drops the index generator cache whenever the index is invalidated', async function () { + const siteMapManager = makeManager(); + await siteMapManager.getIndexXml(); + + // Stand in for a rendered index; the real getXml is stubbed here. + siteMapManager.index.siteMapContent = ''; + + eventsToRemember['site.changed'](); + + assert.equal( + siteMapManager.index.siteMapContent, + null, + 'a cached index would outlive the content it describes', + ); + }); + it('resets the generators at the start of every apply so a rebuild holds no dropped resources', async function () { sandbox.stub(PostGenerator.prototype, 'reset'); const siteMapManager = makeManager(); diff --git a/ghost/core/test/unit/frontend/services/sitemap/sitemap-xml.test.ts b/ghost/core/test/unit/frontend/services/sitemap/sitemap-xml.test.ts new file mode 100644 index 00000000000..cf2e1891d82 --- /dev/null +++ b/ghost/core/test/unit/frontend/services/sitemap/sitemap-xml.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { + escapeXml, + renderSiteMapIndex, + renderUrlSet, +} from '../../../../../core/frontend/services/sitemap/sitemap-xml'; + +describe('sitemap xml', function () { + const TS = Date.UTC(2024, 0, 1); + + describe('fn: escapeXml', function () { + it('escapes the five xml entities and nothing else', function () { + assert.equal(escapeXml(`&"'<>`), '&"'<>'); + assert.equal(escapeXml('plain/path-ü'), 'plain/path-ü'); + }); + }); + + describe('fn: renderUrlSet', function () { + it('renders loc and lastmod for each entry, in the order given', function () { + const xml = renderUrlSet([ + { loc: 'https://blog.com/second/', ts: TS }, + { loc: 'https://blog.com/first/', ts: TS + 1000 }, + ]); + + assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); + assert.match(xml, /2024-01-01T00:00:00\.000Z<\/lastmod>/); + assert(xml.endsWith('')); + }); + + it('adds an image:image element, captioned with the file name', function () { + const xml = renderUrlSet([ + { loc: 'https://blog.com/a/', ts: TS, imageLoc: 'https://blog.com/content/images/x.jpg' }, + ]); + + assert.match( + xml, + /https:\/\/blog\.com\/content\/images\/x\.jpg<\/image:loc>x\.jpg<\/image:caption><\/image:image>/, + ); + }); + + it('omits the image element when there is no image', function () { + const xml = renderUrlSet([{ loc: 'https://blog.com/a/', ts: TS, imageLoc: null }]); + + assert.doesNotMatch(xml, /image:image/); + }); + + it('escapes urls and captions', function () { + const xml = renderUrlSet([ + { loc: 'https://blog.com/a&b/', ts: TS, imageLoc: 'https://blog.com/me & you.jpg' }, + ]); + + assert.match(xml, /https:\/\/blog\.com\/a&b\/<\/loc>/); + assert.match(xml, /me & you\.jpg<\/image:caption>/); + // No raw ampersand may survive anywhere in the document + assert.doesNotMatch(xml, /&(?!amp;|quot;|apos;|lt;|gt;)/); + }); + + it('renders an empty but well-formed urlset for no entries', function () { + // Suppressing empty sitemaps is the generator's job, not this module's. + const xml = renderUrlSet([]); + + assert.match(xml, /]+><\/urlset>$/); + }); + }); + + describe('fn: renderSiteMapIndex', function () { + it('renders a sitemap element per entry', function () { + const xml = renderSiteMapIndex([ + { loc: 'https://blog.com/sitemap-posts.xml', ts: TS }, + { loc: 'https://blog.com/sitemap-posts-2.xml', ts: TS }, + ]); + + assert.match( + xml, + //, + ); + assert.equal(xml.match(//g)?.length, 2); + assert.match( + xml, + /https:\/\/blog\.com\/sitemap-posts\.xml<\/loc>2024-01-01T00:00:00\.000Z<\/lastmod><\/sitemap>/, + ); + assert(xml.endsWith('')); + }); + + it('renders an empty but well-formed index for no entries', function () { + const xml = renderSiteMapIndex([]); + + assert.match(xml, /]+><\/sitemapindex>$/); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3d9e9b1360..2d22fc4fbae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2949,9 +2949,6 @@ importers: unidecode: specifier: 'catalog:' version: 1.1.0 - xml: - specifier: 1.0.1 - version: 1.0.1 zod: specifier: 'catalog:' version: 4.4.3 From 1764e5e7d5a805e16e538dffb57a568c4b176c9c Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 17 Sep 2026 10:01:36 -0400 Subject: [PATCH 09/27] =?UTF-8?q?=F0=9F=8E=A8=20Improved=20site=20responsi?= =?UTF-8?q?veness=20while=20the=20sitemap=20rebuilds=20(#30839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref - every publish, update or delete empties the sitemap index, and the next sitemap read rebuilt it in one synchronous loop over every routable resource. The loop had to be synchronous because it reset and refilled the live generators, so a yield would have let a request render a half-built index. With the real url service and 300,000 posts that loop blocked the event loop for 1.9 s on a fast laptop, stalling every other request on the instance, and longer on production CPUs. A rebuild now fills fresh generators and swaps them in once complete. Nothing reads the new set before the swap, so the build yields every 10 ms. The build checks for invalidation after every yield, not just after the fetch. That matters because a RouteRegistered can now arrive mid-build. An invalidated build is abandoned without swapping. The index generator is recreated at the swap because it holds references to the generators it counts. Route entries are replayed into each new pages generator instead of being added to the live one. The longest block during a rebuild went from 72 ms to 10 ms at 10k posts, 337 ms to 12 ms at 50k, and 1,947 ms to 14 ms at 300k. Total rebuild time rose about 10% at 300k. The slice is a time budget rather than a row count, so it holds on slower CPUs; 5 to 50 ms slices gave the same total, and the longest block tracked the slice plus a few ms of GC. Reads still wait for the rebuild rather than being served the previous index. site.changed is emitted by the same response that purges the CDN, and builds start on read, so the first read after a purge is the one the CDN stores for the full cache maxAge. Serving the previous index there would pin a sitemap missing the change on every publish. After this change the wait only affects sitemap readers, not the rest of the site. Longer builds make it more likely a change lands mid-build, so a read now retries up to three builds before failing with the existing SITEMAP_BUILD_SUPERSEDED 503. Both generator sets are in memory at the swap. To keep that from raising the peak, each fetched row is released once applied. Live heap at 300k peaks at 308 MB either way (old generators plus fetched rows); at the swap it is 273 MB, where holding the rows would have made it 395 MB. --- .../services/sitemap/site-map-manager.js | 122 +++++++++++------ .../frontend/services/sitemap/manager.test.js | 127 ++++++++++++++---- 2 files changed, 178 insertions(+), 71 deletions(-) diff --git a/ghost/core/core/frontend/services/sitemap/site-map-manager.js b/ghost/core/core/frontend/services/sitemap/site-map-manager.js index b08f8b1951c..dd1f320d3a4 100644 --- a/ghost/core/core/frontend/services/sitemap/site-map-manager.js +++ b/ghost/core/core/frontend/services/sitemap/site-map-manager.js @@ -1,3 +1,5 @@ +const { performance } = require('node:perf_hooks'); +const { setImmediate: yieldToEventLoop } = require('node:timers/promises'); const errors = require('@tryghost/errors'); const urlUtils = require('../../../shared/url-utils').default; const IndexMapGenerator = require('./site-map-index-generator'); @@ -22,11 +24,22 @@ const SITEMAP_COLUMNS = [ 'canonical_url', ]; +const RESOURCE_TYPES = ['posts', 'pages', 'tags', 'authors']; + +// Longest stretch a build runs before yielding to the event loop. +const DEFAULT_BUILD_SLICE_MS = 10; + +// Builds a reader will start before giving up on a site that keeps changing. +const MAX_BUILD_ATTEMPTS = 3; + class SiteMapManager { constructor(options) { options = options || {}; options.maxPerPage = options.maxPerPage || 50000; + // Every build creates fresh generators from these. + this._options = options; + this._buildSliceMs = options.buildSliceMs ?? DEFAULT_BUILD_SLICE_MS; this.pages = options.pages || this.createPagesGenerator(options); this.posts = options.posts || this.createPostsGenerator(options); @@ -54,7 +67,7 @@ class SiteMapManager { this._indexEpoch = 0; // Static/collection route entries only arrive via RouteRegistered, // which fires at boot and routes reload. They are recorded here so - // every rebuild can replay them after resetting the generators. + // every rebuild can replay them into its fresh generators. this._routerEntries = []; routingEvents.on('RouteRegistered', ({ path, type, id }) => { @@ -66,7 +79,6 @@ class SiteMapManager { datum: { id, staticRoute: type === 'StaticRoutesRouter' }, }; this._routerEntries.push(entry); - this.pages.addUrl(entry.url, entry.datum); // A router registering after a build must not leave a // zero-router index marked built — the CDN would pin it. this._invalidateIndex(); @@ -90,13 +102,13 @@ class SiteMapManager { }); } - createIndexGenerator(options) { + createIndexGenerator(options, types = this) { return new IndexMapGenerator({ types: { - pages: this.pages, - posts: this.posts, - authors: this.authors, - tags: this.tags, + pages: types.pages, + posts: types.posts, + authors: types.authors, + tags: types.tags, }, maxPerPage: options.maxPerPage, }); @@ -133,64 +145,88 @@ class SiteMapManager { * no caller can render from an unbuilt index. The index is built on first * read; the invalidation signals empty it and the next read rebuilds. * - * Concurrent readers share one build. A build whose result was - * invalidated while it ran is discarded and the read fails — the index - * must never serve pre-invalidation data (the CDN would pin it for the - * full cache maxAge), and a 503 is retried by crawlers and stored by - * nobody. Deliberately no retry; if SITEMAP_BUILD_SUPERSEDED shows up in - * the logs at any rate worth caring about, add one then. + * Reads wait for the rebuild rather than being served the previous index. + * site.changed is emitted by the same response that purges the CDN, so the + * first read after an invalidation is the one the CDN stores for the full + * cache maxAge: serving the previous index there would pin a sitemap + * missing the change on every publish. + * + * Concurrent readers share one build. A build invalidated while it runs is + * abandoned and a fresh one started; a site changing faster than the index + * can be built fails the read with a 503, which crawlers retry and nobody + * stores. */ async _ensureIndexReady() { - if (this._indexBuilt) { - return; - } - if (!this._buildInFlight) { - this._buildInFlight = this._buildIndex().finally(() => { - this._buildInFlight = null; - }); + for (let attempt = 0; attempt < MAX_BUILD_ATTEMPTS && !this._indexBuilt; attempt++) { + if (!this._buildInFlight) { + this._buildInFlight = this._buildIndex().finally(() => { + this._buildInFlight = null; + }); + } + await this._buildInFlight; } - await this._buildInFlight; if (!this._indexBuilt) { throw new errors.MaintenanceError({ - message: 'Sitemap index build was invalidated by a concurrent site change', + message: 'Sitemap index build was repeatedly invalidated by concurrent site changes', code: 'SITEMAP_BUILD_SUPERSEDED', }); } } + /** + * Builds into fresh generators and swaps them in once complete, so the + * build can yield to the event loop without any reader seeing a partial + * index. The epoch is checked after every yield: an invalidation (including + * a RouteRegistered mid-build) abandons the build without swapping. + */ async _buildIndex() { const epoch = this._indexEpoch; const urlService = this._getUrlService(); const fetch = (type) => urlService.getRoutableResources(type, { columns: SITEMAP_COLUMNS }); - const [posts, pages, tags, authors] = await Promise.all([ - fetch('posts'), - fetch('pages'), - fetch('tags'), - fetch('authors'), - ]); + const [posts, pages, tags, authors] = await Promise.all(RESOURCE_TYPES.map(fetch)); const resources = { posts, pages, tags, authors }; if (epoch !== this._indexEpoch) { - // Invalidated while fetching: leave the generators alone and let - // _ensureIndexReady start over. return; } - // Everything from here on is synchronous, so no request can observe - // a half-applied index. - this.posts.reset(); - this.pages.reset(); - this.tags.reset(); - this.users.reset(); + + const next = { + posts: this.createPostsGenerator(this._options), + pages: this.createPagesGenerator(this._options), + tags: this.createTagsGenerator(this._options), + authors: this.createUsersGenerator(this._options), + }; for (const entry of this._routerEntries) { - this.pages.addUrl(entry.url, entry.datum); + next.pages.addUrl(entry.url, entry.datum); } - for (const type of ['posts', 'pages', 'tags', 'authors']) { - for (const datum of resources[type]) { - this._applyResource(type, datum); + + let sliceStart = performance.now(); + for (const type of RESOURCE_TYPES) { + const rows = resources[type]; + for (let i = 0; i < rows.length; i++) { + this._applyResource(next, type, rows[i]); + // Release each row once applied, so rows and records are not both + // fully resident. + rows[i] = undefined; + + if (performance.now() - sliceStart >= this._buildSliceMs) { + await yieldToEventLoop(); + if (epoch !== this._indexEpoch) { + return; + } + sliceStart = performance.now(); + } } } + + this.posts = next.posts; + this.pages = next.pages; + this.tags = next.tags; + this.users = this.authors = next.authors; + // The index generator holds references to the generators it counts. + this.index = this.createIndexGenerator(this._options, next); this._indexBuilt = true; } @@ -203,14 +239,14 @@ class SiteMapManager { } /** - * Add a single resource to the index. + * Add a single resource to a set of generators being built. */ - _applyResource(type, datum) { + _applyResource(generators, type, datum) { const url = this._getUrlService().getUrlForResource({ ...datum, type }, { absolute: true }); // Exact match on the not-found sentinel: a real resource can carry // a slug like "404" (/tag/404/) and must stay in the sitemap. if (url && url !== this._notFoundUrl()) { - this[type].addUrl(url, datum); + generators[type].addUrl(url, datum); } } diff --git a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js index e245a55c443..5bf3d88adc9 100644 --- a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js +++ b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js @@ -1,5 +1,6 @@ const sinon = require('sinon'); const assert = require('node:assert/strict'); +const { setImmediate: yieldToEventLoop } = require('node:timers/promises'); const { assertExists } = require('../../../../utils/assertions'); // Stuff we are testing @@ -88,8 +89,9 @@ describe('Unit: sitemap/manager', function () { let fetchStub; let getUrlForResource; - function makeManager() { + function makeManager(options = {}) { return new SiteMapManager({ + ...options, posts: new PostGenerator(), pages: new PageGenerator(), tags: new TagGenerator(), @@ -245,17 +247,66 @@ describe('Unit: sitemap/manager', function () { ); }); - it('resets the generators at the start of every apply so a rebuild holds no dropped resources', async function () { - sandbox.stub(PostGenerator.prototype, 'reset'); - const siteMapManager = makeManager(); - await siteMapManager.getSiteMapXml('posts'); + it('builds into fresh generators so a rebuild holds no dropped resources', async function () { + PostGenerator.prototype.addUrl.callThrough(); + try { + fetchStub + .withArgs('posts') + .onFirstCall() + .resolves([ + { id: 'p1', slug: 'kept' }, + { id: 'p2', slug: 'unpublished' }, + ]) + .onSecondCall() + .resolves([{ id: 'p1', slug: 'kept' }]); + const siteMapManager = makeManager(); + await siteMapManager.getSiteMapXml('posts'); + const firstPosts = siteMapManager.posts; + + eventsToRemember['site.changed'](); + await siteMapManager.getSiteMapXml('posts'); + + assert.notEqual(siteMapManager.posts, firstPosts); + assert.deepEqual([...siteMapManager.posts.nodeLookup.keys()], ['p1']); + } finally { + PostGenerator.prototype.addUrl.resetBehavior(); + } + }); + it('swaps the index generator with the generators it counts', async function () { + const siteMapManager = makeManager(); + await siteMapManager.getIndexXml(); eventsToRemember['site.changed'](); - await siteMapManager.getSiteMapXml('posts'); + await siteMapManager.getIndexXml(); - // Once per build. Without this, a post unpublished between builds - // would stay in the sitemap forever. - sinon.assert.calledTwice(PostGenerator.prototype.reset); + const { types } = siteMapManager.index; + assert.equal(types.posts, siteMapManager.posts); + assert.equal(types.pages, siteMapManager.pages); + assert.equal(types.tags, siteMapManager.tags); + assert.equal(types.authors, siteMapManager.authors); + assert.equal(siteMapManager.users, siteMapManager.authors); + }); + + it('yields during a build without exposing the generators being built', async function () { + fetchStub.withArgs('posts').resolves([ + { id: 'p1', slug: 'a' }, + { id: 'p2', slug: 'b' }, + { id: 'p3', slug: 'c' }, + ]); + // A zero slice yields after every resource. + const siteMapManager = makeManager({ buildSliceMs: 0 }); + const livePosts = siteMapManager.posts; + const livePages = siteMapManager.pages; + const reader = siteMapManager.getSiteMapXml('posts'); + + await yieldToEventLoop(); + await yieldToEventLoop(); + sinon.assert.called(PostGenerator.prototype.addUrl); + assert.equal(siteMapManager.posts, livePosts); + assert.equal(siteMapManager.pages, livePages); + + await reader; + assert.notEqual(siteMapManager.posts, livePosts); }); it('replays static/collection route entries into every rebuild', async function () { @@ -306,37 +357,57 @@ describe('Unit: sitemap/manager', function () { ); }); - it('fails a read with a 503 when the build is invalidated mid-flight, and rebuilds on the next read', async function () { - let resolveFirstFetch; - fetchStub - .withArgs('posts') - .onFirstCall() - .returns( - new Promise((resolve) => { - resolveFirstFetch = () => resolve([]); - }), - ) - .onSecondCall() - .resolves([]); - - const siteMapManager = makeManager(); + it('abandons a build invalidated mid-apply and serves the rebuild instead', async function () { + fetchStub.withArgs('posts').callsFake(async () => [ + { id: 'p1', slug: 'a' }, + { id: 'p2', slug: 'b' }, + ]); + const siteMapManager = makeManager({ buildSliceMs: 0 }); + const livePosts = siteMapManager.posts; const reader = siteMapManager.getSiteMapXml('posts'); - eventsToRemember['site.changed'](); - resolveFirstFetch(); + // Wait for the first yield inside the apply loop, then change the + // site. A router registering mid-build counts too. + while (!PostGenerator.prototype.addUrl.called) { + await yieldToEventLoop(); + } + assert.equal(fetchStub.callCount, 4); + emitAboutRouter(); + + await reader; + sinon.assert.callCount(fetchStub, 8); + assert.notEqual(siteMapManager.posts, livePosts); + sinon.assert.calledWith( + PageGenerator.prototype.addUrl, + aboutUrl, + sinon.match({ id: 'sr1' }), + ); + }); + + it('fails a read with a 503 when every build attempt is invalidated, and rebuilds on the next read', async function () { + let keepChanging = true; + fetchStub.withArgs('posts').callsFake(async () => { + if (keepChanging) { + eventsToRemember['site.changed'](); + } + return []; + }); + + const siteMapManager = makeManager(); // Never serve pre-invalidation data: a stale 200 would be // pinned by the CDN for the full cache maxAge. A 503 is // retried by crawlers and stored by nobody. - await assert.rejects(reader, (err) => { + await assert.rejects(siteMapManager.getSiteMapXml('posts'), (err) => { assert.equal(err.statusCode, 503); assert.equal(err.code, 'SITEMAP_BUILD_SUPERSEDED'); return true; }); + sinon.assert.callCount(fetchStub, 12); - // The next read starts fresh and succeeds. + keepChanging = false; await siteMapManager.getSiteMapXml('posts'); - sinon.assert.callCount(fetchStub, 8); + sinon.assert.callCount(fetchStub, 16); }); it('rejects readers when the build fails and retries on the next read', async function () { From becf78db4cc49016e4237d37662aa76e7da6342d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 09:37:13 -0500 Subject: [PATCH 10/27] Added internal docs for common E2E test error (#30837) no ref --- docs/contributing/testing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index c4a85559245..9f3c9f4d99a 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -157,6 +157,9 @@ optional Redis and object-storage adapters skip when their services are not available; start the relevant development services when you need to exercise those adapters. +If E2E tests fail with `ECONNREFUSED 127.0.0.1:3306`, start the development +server with `pnpm dev`. + ## Run Browser E2E Tests The browser suite needs its test infrastructure running. For the normal From a9135eefc438ad0a41f576674340d2275026ebc2 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Thu, 17 Sep 2026 09:39:50 -0500 Subject: [PATCH 11/27] Changed the post settings sections to a narrow session port (#30755) no ref Updated the sidebar handling to use a portion of the editor state instead of the full object. --- .../src/editor/koenig-post-editor.test.tsx | 92 +++++++++++++++ apps/admin/src/editor/koenig-post-editor.tsx | 8 +- apps/admin/src/editor/settings/README.md | 4 +- .../src/editor/settings/access-section.tsx | 4 +- .../src/editor/settings/authors-section.tsx | 4 +- .../settings/code-injection-section.tsx | 4 +- .../src/editor/settings/delete-section.tsx | 4 +- .../settings/editor-settings-port.test.tsx | 109 ++++++++++++++++++ .../editor/settings/editor-settings-port.ts | 81 +++++++++++++ .../src/editor/settings/meta-data-section.tsx | 4 +- .../editor/settings/post-history-section.tsx | 12 +- .../settings/post-settings-sidebar.test.tsx | 64 ++++++++++ .../editor/settings/post-settings-sidebar.tsx | 63 ++++++---- .../editor/settings/publish-date-section.tsx | 4 +- .../editor/settings/show-title-section.tsx | 4 +- .../editor/settings/social-card-section.tsx | 4 +- .../src/editor/settings/tags-section.tsx | 4 +- .../src/editor/settings/template-section.tsx | 4 +- .../admin/src/editor/settings/url-section.tsx | 4 +- .../settings/use-settings-field.test.ts | 4 +- .../src/editor/settings/use-settings-field.ts | 4 +- 21 files changed, 430 insertions(+), 55 deletions(-) create mode 100644 apps/admin/src/editor/koenig-post-editor.test.tsx create mode 100644 apps/admin/src/editor/settings/editor-settings-port.test.tsx create mode 100644 apps/admin/src/editor/settings/editor-settings-port.ts create mode 100644 apps/admin/src/editor/settings/post-settings-sidebar.test.tsx diff --git a/apps/admin/src/editor/koenig-post-editor.test.tsx b/apps/admin/src/editor/koenig-post-editor.test.tsx new file mode 100644 index 00000000000..fd5816ccd2f --- /dev/null +++ b/apps/admin/src/editor/koenig-post-editor.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import type { PostCardConfig, PostType } from './card-config'; +import type { FeatureImageBinding } from './session/feature-image-binding'; +import { PostEditor } from './post-editor'; + +// Counted once per render of a composer subtree, which is how many times the +// hidden and the visible instance rebuild between them. +const composerRendered = vi.hoisted(() => vi.fn()); + +vi.mock('@/settings/components/koenig-loader', () => { + const stub = { + KoenigComposer: ({ children }: { children: ReactNode }) => { + composerRendered(); + return
{children}
; + }, + KoenigEditor: () => null, + WordCountPlugin: () => null, + TKCountPlugin: () => null, + }; + const resource = { read: () => stub }; + return { loadKoenig: () => resource }; +}); + +vi.mock('@tryghost/shade/app', async (importOriginal) => ({ + ...(await importOriginal>()), + useFocusContext: () => ({ darkMode: false }), +})); + +vi.mock('@tryghost/admin-x-framework/api/images', () => ({ + getImageUrl: () => '', + useUploadImage: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +const CARD_CONFIG = { siteUrl: 'https://example.com' } as unknown as PostCardConfig; + +const NOOP = () => {}; + +const FEATURE_IMAGE: FeatureImageBinding = { + featureImage: null, + featureImageAlt: null, + featureImageCaption: null, + onFeatureImageChange: NOOP, + onFeatureImageClear: NOOP, + onFeatureImageAltChange: NOOP, + onFeatureImageCaptionChange: NOOP, + onFeatureImageCaptionBlur: NOOP, +}; + +/** + * The editor surface as the screen mounts it, over a title the surface owns: + * typing into it re-renders everything the body's props are built in. + */ +function Harness({ postType }: { postType: PostType }) { + const [title, setTitle] = useState(''); + + return ( + + ); +} + +describe('KoenigPostEditor re-renders', () => { + it('does not rebuild its composers when the title is typed into', () => { + const { rerender } = render(); + const title = screen.getByTestId('editor-title-input'); + + expect(composerRendered).toHaveBeenCalledTimes(2); + + fireEvent.change(title, { target: { value: 'A' } }); + fireEvent.change(title, { target: { value: 'A t' } }); + + expect(composerRendered).toHaveBeenCalledTimes(2); + + // The count moves for a prop the body actually reads, so it is the memo + // holding it still rather than the probe missing renders. + rerender(); + + expect(composerRendered).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index f5b1d65d84c..598435d07ec 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback } from 'react'; +import { memo, Suspense, useCallback } from 'react'; import { LoadingIndicator } from '@tryghost/shade/components'; import { editorBody, editorSecondaryInstance } from '@tryghost/test-data/selectors/editor'; import ErrorBoundary from '@/settings/components/error-boundary'; @@ -83,7 +83,9 @@ function KoenigInstanceMount({ ); } -export function KoenigPostEditor(props: KoenigPostEditorProps) { +// Memoized: every prop is referentially stable, so a settings edit elsewhere in +// the editor must not re-render two composer subtrees. +export const KoenigPostEditor = memo(function KoenigPostEditor(props: KoenigPostEditorProps) { const editor = loadKoenig(); const { onSecondaryError } = props; @@ -121,4 +123,4 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) { ); -} +}); diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index 69f919c9ed0..8e89db870b5 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -3,7 +3,9 @@ `apps/admin/src/editor/settings/` holds the settings panel beside the post editor: the frame, its header toggle, and the sections that edit a post's non-body fields. Nothing here talks to the API. Every field goes through the -editing session, which is the only writer. +editing session, which is the only writer. A section is handed a narrow port +onto that session — the settings fields, their writers, and the few other +members the sections read — rather than the whole editing handle. ## Save policy diff --git a/apps/admin/src/editor/settings/access-section.tsx b/apps/admin/src/editor/settings/access-section.tsx index 7b221b81856..f31ba1d228b 100644 --- a/apps/admin/src/editor/settings/access-section.tsx +++ b/apps/admin/src/editor/settings/access-section.tsx @@ -21,7 +21,7 @@ import type { PostType } from '@/editor/card-config'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { TIERS_REQUIRED, tiersIncomplete } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SectionLoadError } from './section-load-error'; import { SettingsSection } from './settings-section'; import { @@ -86,7 +86,7 @@ function TierGroup({ } export interface AccessSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/authors-section.tsx b/apps/admin/src/editor/settings/authors-section.tsx index 952657be8eb..0b8d7341f4e 100644 --- a/apps/admin/src/editor/settings/authors-section.tsx +++ b/apps/admin/src/editor/settings/authors-section.tsx @@ -5,13 +5,13 @@ import type { PostAuthor } from '@tryghost/admin-x-framework/api/posts'; import { settingsAuthorsError } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { AUTHORS_REQUIRED } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; import { AuthorsPicker } from './authors-picker'; import { AUTHORS_SEARCH_PARAMS, selectedAuthors, type AuthorOption } from './authors-options'; export interface AuthorsSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; currentUser?: User; } diff --git a/apps/admin/src/editor/settings/code-injection-section.tsx b/apps/admin/src/editor/settings/code-injection-section.tsx index 42455a5a1c3..994dca306b2 100644 --- a/apps/admin/src/editor/settings/code-injection-section.tsx +++ b/apps/admin/src/editor/settings/code-injection-section.tsx @@ -1,7 +1,7 @@ import { CodeEditor } from '@tryghost/shade/components'; import { LucideIcon } from '@tryghost/shade/utils'; import type { PostType } from '@/editor/card-config'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSubview } from './settings-subview'; // A binding that returns true prevents the event's default, which the pane @@ -38,7 +38,7 @@ function EditorLabel({ text, helper }: { text: string; helper: string }) { } export interface CodeInjectionSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/delete-section.tsx b/apps/admin/src/editor/settings/delete-section.tsx index d246c389bb8..6ea5a54408c 100644 --- a/apps/admin/src/editor/settings/delete-section.tsx +++ b/apps/admin/src/editor/settings/delete-section.tsx @@ -24,11 +24,11 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { DEFAULT_TITLE } from '@/editor/engine/save-engine'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; export interface DeleteSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/editor-settings-port.test.tsx b/apps/admin/src/editor/settings/editor-settings-port.test.tsx new file mode 100644 index 00000000000..cc5b491e944 --- /dev/null +++ b/apps/admin/src/editor/settings/editor-settings-port.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { body, record } from '@/editor/session/__test-utils__/session-harness'; +import { useEditorSession } from '@/editor/session/use-editor-session'; +import { useEditorSettingsPort } from './editor-settings-port'; + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: () => ({ key: 'editor', state: null }), +})); + +// The real hooks hand back one stable function per mount; a fresh mock per +// render would make the handle churn for a reason the hook does not own. +const stable = vi.hoisted(() => ({ fetchApi: vi.fn(), generateSlug: vi.fn() })); + +vi.mock('@tryghost/admin-x-framework/hooks', () => ({ + useFetchApi: () => stable.fetchApi, +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: () => ({ data: undefined }), +})); + +vi.mock('@tryghost/admin-x-framework/api/slugs', () => ({ + useGenerateSlug: () => stable.generateSlug, +})); + +vi.mock('@tryghost/admin-x-framework/api/posts', () => ({ + useAddPost: () => ({ mutateAsync: vi.fn() }), + useEditPost: () => ({ mutateAsync: vi.fn() }), + useEditorPost: () => ({ data: undefined }), + postsDataType: 'PostsResponseType', +})); + +vi.mock('@tryghost/admin-x-framework/api/pages', () => ({ + useAddPage: () => ({ mutateAsync: vi.fn() }), + useEditPage: () => ({ mutateAsync: vi.fn() }), + useEditorPage: () => ({ data: undefined }), + pagesDataType: 'PagesResponseType', +})); + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function setup() { + return renderHook( + () => { + const session = useEditorSession({ + postType: 'post', + record: record(), + siteUrl: 'https://example.com', + }); + return { session, port: useEditorSettingsPort(session) }; + }, + { wrapper: Wrapper }, + ); +} + +/** + * The port is what the settings sections memoize on, so it has to move with the + * members it carries rather than with everything the session publishes. + */ +describe('useEditorSettingsPort', () => { + it('keeps the same port across a render that changed nothing', () => { + const { result, rerender } = setup(); + const { port } = result.current; + + rerender(); + + expect(result.current.port).toBe(port); + }); + + it('keeps the same port when a body edit moves the handle', () => { + const { result } = setup(); + act(() => result.current.session.bind.onSecondaryChange(body('Hello'))); + + const { session, port } = result.current; + act(() => result.current.session.bind.onLexicalChange(body('Hello again'))); + + expect(result.current.session).not.toBe(session); + expect(result.current.session.isDirty()).toBe(true); + expect(result.current.port).toBe(port); + }); + + it('replaces the port but not its binding when a settings field changes', () => { + const { result } = setup(); + const { port } = result.current; + + act(() => result.current.port.stageSettings({ meta_title: 'A meta title' })); + + expect(result.current.port).not.toBe(port); + expect(result.current.port.settings.meta_title).toBe('A meta title'); + expect(result.current.port.bind).toBe(port.bind); + }); + + it('replaces the port when the title changes, which the sections read', () => { + const { result } = setup(); + const { port } = result.current; + + act(() => result.current.session.bind.onTitleChange('A new title')); + + expect(result.current.port).not.toBe(port); + expect(result.current.port.bind.title).toBe('A new title'); + }); +}); diff --git a/apps/admin/src/editor/settings/editor-settings-port.ts b/apps/admin/src/editor/settings/editor-settings-port.ts new file mode 100644 index 00000000000..3a014a286b7 --- /dev/null +++ b/apps/admin/src/editor/settings/editor-settings-port.ts @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import type { + EditorSessionBinding, + EditorSessionHandle, +} from '@/editor/session/use-editor-session'; + +/** What the settings panel may reach on the editing session, and nothing else. */ +export type EditorSettingsPort = Pick< + EditorSessionHandle, + | 'commitSettings' + | 'createdId' + | 'dispose' + | 'editPublishedAt' + | 'editSettings' + | 'editSlug' + | 'loadedRecord' + | 'publishTime' + | 'restoreRevision' + | 'settings' + | 'slug' + | 'stageSettings' +> & { + bind: Pick; +}; + +/** The port's identity tracks the members it carries, not the render that produced it. */ +export function useEditorSettingsPort(session: EditorSessionHandle): EditorSettingsPort { + const { title, excerpt, onExcerptChange } = session.bind; + const { + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + } = session; + + const bind = useMemo( + () => ({ title, excerpt, onExcerptChange }), + [title, excerpt, onExcerptChange], + ); + + return useMemo( + () => ({ + bind, + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + }), + [ + bind, + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + ], + ); +} diff --git a/apps/admin/src/editor/settings/meta-data-section.tsx b/apps/admin/src/editor/settings/meta-data-section.tsx index f0d12f4aa39..5d8fe1e5554 100644 --- a/apps/admin/src/editor/settings/meta-data-section.tsx +++ b/apps/admin/src/editor/settings/meta-data-section.tsx @@ -7,7 +7,7 @@ import { settingsMetaTitleInput, settingsSerpPreview, } from '@tryghost/test-data/selectors/editor'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { META_DESCRIPTION_RECOMMENDED, META_TITLE_RECOMMENDED, @@ -67,7 +67,7 @@ function SearchPreview({ } export interface MetaDataSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; siteUrl: string; } diff --git a/apps/admin/src/editor/settings/post-history-section.tsx b/apps/admin/src/editor/settings/post-history-section.tsx index 5aba278ba62..d9df38d3fd8 100644 --- a/apps/admin/src/editor/settings/post-history-section.tsx +++ b/apps/admin/src/editor/settings/post-history-section.tsx @@ -4,16 +4,19 @@ import { LucideIcon } from '@tryghost/shade/utils'; import { useFocusContext } from '@tryghost/shade/app'; import { settingsPostHistoryButton } from '@tryghost/test-data/selectors/editor'; import type { PostCardConfig, PostType } from '@/editor/card-config'; +import type { SaveEngineState } from '@/editor/engine/save-engine'; import { useSiteTimezone } from '@/editor/use-editor-settings'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { canViewPostHistory, revisionEntries, type RevisionEntry } from './post-history'; import { PostHistoryModal } from './post-history-modal'; import { SettingsSection } from './settings-section'; export interface PostHistorySectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; cardConfig: PostCardConfig; + /** The save engine's state, which says whether a restore can be written at all. */ + state: SaveEngineState; /** The excerpt has its own home under the title, and is restored with the version. */ showExcerpt: boolean; } @@ -28,6 +31,7 @@ export function PostHistorySection({ postType, cardConfig, showExcerpt, + state, }: PostHistorySectionProps) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); @@ -82,8 +86,8 @@ export function PostHistorySection({ open={open} postType={postType} restoreError={ - (session.state.kind === 'error' && session.state.error.kind === 'session-invalid') || - session.state.kind === 'reauth-pending' + (state.kind === 'error' && state.error.kind === 'session-invalid') || + state.kind === 'reauth-pending' ? 'Your session expired. Sign in again in a new tab, then try restoring again.' : undefined } diff --git a/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx b/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx new file mode 100644 index 00000000000..5bdeb7f3320 --- /dev/null +++ b/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx @@ -0,0 +1,64 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { PostCardConfig } from '@/editor/card-config'; +import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import { PostSettingsSidebar } from './post-settings-sidebar'; + +const mocks = vi.hoisted(() => ({ + port: {}, + urlSection: vi.fn(() => null), +})); + +vi.mock('./editor-settings-port', () => ({ + useEditorSettingsPort: () => mocks.port, +})); + +vi.mock('./access-section', () => ({ AccessSection: () => null })); +vi.mock('./authors-section', () => ({ AuthorsSection: () => null })); +vi.mock('./code-injection-section', () => ({ CodeInjectionSection: () => null })); +vi.mock('./delete-section', () => ({ DeleteSection: () => null })); +vi.mock('./keyboard-shortcuts-section', () => ({ KeyboardShortcutsSection: () => null })); +vi.mock('./meta-data-section', () => ({ MetaDataSection: () => null })); +vi.mock('./post-history-section', () => ({ PostHistorySection: () => null })); +vi.mock('./publish-date-section', () => ({ PublishDateSection: () => null })); +vi.mock('./show-title-section', () => ({ ShowTitleSection: () => null })); +vi.mock('./social-card-section', () => ({ SocialCardSection: () => null })); +vi.mock('./tags-section', () => ({ TagsSection: () => null })); +vi.mock('./template-section', () => ({ TemplateSection: () => null })); +vi.mock('./url-section', () => ({ UrlSection: mocks.urlSection })); + +const CARD_CONFIG = {} as PostCardConfig; + +function handle(kind: 'idle' | 'debouncing') { + return { state: { kind } } as EditorSessionHandle; +} + +function Sidebar({ session, siteUrl }: { session: EditorSessionHandle; siteUrl: string }) { + return ( + + ); +} + +describe('PostSettingsSidebar renders', () => { + it('does not rebuild an unaffected section when only save state changes', () => { + const { rerender } = render(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(1); + + // A prop the URL section reads still moves it, so the probe catches renders. + rerender(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/admin/src/editor/settings/post-settings-sidebar.tsx b/apps/admin/src/editor/settings/post-settings-sidebar.tsx index 5b7b406b79e..edcb2de8f05 100644 --- a/apps/admin/src/editor/settings/post-settings-sidebar.tsx +++ b/apps/admin/src/editor/settings/post-settings-sidebar.tsx @@ -1,4 +1,4 @@ -import { Fragment, type ReactNode, useEffect, useId } from 'react'; +import { Fragment, memo, type ReactNode, useEffect, useId } from 'react'; import { Label, Separator, Switch, Textarea } from '@tryghost/shade/components'; import { Inline, Text } from '@tryghost/shade/primitives'; import { cn } from '@tryghost/shade/utils'; @@ -20,6 +20,7 @@ import { PublishDateSection } from './publish-date-section'; import { AuthorsSection } from './authors-section'; import { CodeInjectionSection } from './code-injection-section'; import { DeleteSection } from './delete-section'; +import { type EditorSettingsPort, useEditorSettingsPort } from './editor-settings-port'; import { KeyboardShortcutsSection } from './keyboard-shortcuts-section'; import { MetaDataSection } from './meta-data-section'; import { PostHistorySection } from './post-history-section'; @@ -33,7 +34,21 @@ import { TagsSection } from './tags-section'; import { TemplateSection } from './template-section'; import { UrlSection } from './url-section'; -function ExcerptSection({ session }: { session: EditorSessionHandle }) { +const MemoAccessSection = memo(AccessSection); +const MemoAuthorsSection = memo(AuthorsSection); +const MemoCodeInjectionSection = memo(CodeInjectionSection); +const MemoDeleteSection = memo(DeleteSection); +const MemoKeyboardShortcutsSection = memo(KeyboardShortcutsSection); +const MemoMetaDataSection = memo(MetaDataSection); +const MemoPostHistorySection = memo(PostHistorySection); +const MemoPublishDateSection = memo(PublishDateSection); +const MemoShowTitleSection = memo(ShowTitleSection); +const MemoSocialCardSection = memo(SocialCardSection); +const MemoTagsSection = memo(TagsSection); +const MemoTemplateSection = memo(TemplateSection); +const MemoUrlSection = memo(UrlSection); + +const ExcerptSection = memo(function ExcerptSection({ session }: { session: EditorSettingsPort }) { const inputId = useId(); return ( @@ -49,13 +64,13 @@ function ExcerptSection({ session }: { session: EditorSessionHandle }) { /> ); -} +}); -function FeaturedSection({ +const FeaturedSection = memo(function FeaturedSection({ session, postType, }: { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; }) { const inputId = useId(); @@ -73,7 +88,7 @@ function FeaturedSection({ ); -} +}); export interface PostSettingsSidebarProps { session: EditorSessionHandle; @@ -94,7 +109,7 @@ export interface PostSettingsSidebarProps { * through the session, which owns when it is persisted (see the README). */ export function PostSettingsSidebar({ - session, + session: handle, postType, siteUrl, cardConfig, @@ -102,6 +117,9 @@ export function PostSettingsSidebar({ currentUser, hasInlineExcerpt = false, }: PostSettingsSidebarProps) { + // The sections take the narrow port rather than the handle, so an edit they + // cannot see does not hand them a new object. + const session = useEditorSettingsPort(handle); // Owner, Administrator and Editor manage featured and access. const canManagePost = !!currentUser && canAccessSettings(currentUser); const canTag = !!currentUser && !isContributorUser(currentUser); @@ -110,24 +128,26 @@ export function PostSettingsSidebar({ const subviews = useSubviewController(); const sections: Record = { - url: , - 'publish-date': , - tags: canTag ? : null, + url: , + 'publish-date': , + tags: canTag ? : null, excerpt: hasInlineExcerpt ? null : , featured: canManagePost ? : null, - access: canManagePost ? : null, + access: canManagePost ? : null, authors: canCreditOthers ? ( - + ) : null, 'show-title-and-feature-image': - postType === 'page' ? : null, - template: , - delete: , - 'code-injection': , - 'meta-data': , - 'keyboard-shortcuts': , + postType === 'page' ? ( + + ) : null, + template: , + delete: , + 'code-injection': , + 'meta-data': , + 'keyboard-shortcuts': , 'x-card': ( - ), 'facebook-card': ( - ), 'post-history': ( - ), }; diff --git a/apps/admin/src/editor/settings/publish-date-section.tsx b/apps/admin/src/editor/settings/publish-date-section.tsx index c9c9eeeb994..0bfd3588199 100644 --- a/apps/admin/src/editor/settings/publish-date-section.tsx +++ b/apps/admin/src/editor/settings/publish-date-section.tsx @@ -10,14 +10,14 @@ import { import { DateTimePicker } from '@/editor/date-time-picker'; import { useSiteTimezone } from '@/editor/use-editor-settings'; import { PUBLISHED_AT_MUST_BE_PAST, publishedAtInFuture } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; /** A scheduled post is re-timed from the publish menu, not from here. */ const RESCHEDULE_NOTE = 'Use the publish menu to re-schedule'; export interface PublishDateSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; } /** diff --git a/apps/admin/src/editor/settings/show-title-section.tsx b/apps/admin/src/editor/settings/show-title-section.tsx index f8950e69b29..097fa69ca1e 100644 --- a/apps/admin/src/editor/settings/show-title-section.tsx +++ b/apps/admin/src/editor/settings/show-title-section.tsx @@ -8,7 +8,7 @@ import { settingsShowTitleWarning, } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; const PAGE_BUILDER_ATTRIBUTE = 'show_title_and_feature_image'; @@ -16,7 +16,7 @@ const THEME_WARNING = "Uh-oh. Looks like your theme doesn't support this feature const THEME_DOCS_URL = 'https://docs.ghost.org/themes/helpers/'; export interface ShowTitleSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; currentUser?: User; } diff --git a/apps/admin/src/editor/settings/social-card-section.tsx b/apps/admin/src/editor/settings/social-card-section.tsx index b229e6fdd53..65ce33e2435 100644 --- a/apps/admin/src/editor/settings/social-card-section.tsx +++ b/apps/admin/src/editor/settings/social-card-section.tsx @@ -4,8 +4,8 @@ import { Stack, Text } from '@tryghost/shade/primitives'; import BrandIcon from '@/shared/brand-icon/brand-icon'; import type { PostCardConfig } from '@/editor/card-config'; import { ImageField } from '@/editor/image-field'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { useImageFieldUpload } from '@/editor/use-image-field-upload'; +import type { EditorSettingsPort } from './editor-settings-port'; import { truncate } from './meta-data-fields'; import { SettingsSubview } from './settings-subview'; import { @@ -23,7 +23,7 @@ import { useSettingsField } from './use-settings-field'; export interface SocialCardSectionProps { /** Which network's card this pane edits (see `social-card-networks.ts`). */ network: SocialCardNetwork; - session: EditorSessionHandle; + session: EditorSettingsPort; /** The site's homepage URL, which the card previews the post under. */ siteUrl: string; /** The feature image the writer is looking at, which the card falls back to. */ diff --git a/apps/admin/src/editor/settings/tags-section.tsx b/apps/admin/src/editor/settings/tags-section.tsx index 7e7280ff17d..e9075292bbc 100644 --- a/apps/admin/src/editor/settings/tags-section.tsx +++ b/apps/admin/src/editor/settings/tags-section.tsx @@ -7,7 +7,7 @@ import { settingsTagsToken, } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { TagPicker } from '@/shared/tags/tag-picker'; import { addTag, removeTag, type TagLike } from '@/shared/tags/tag-selection'; import { SettingsSection } from './settings-section'; @@ -19,7 +19,7 @@ const TAG_NAME_MAX_LENGTH = 191; * The post's tags in order, which is the `sort_order` Ghost stores. The field * holds the records the chips are drawn from; the save writes identities. */ -export function TagsSection({ session }: { session: EditorSessionHandle }) { +export function TagsSection({ session }: { session: EditorSettingsPort }) { const inputId = useId(); const tags = session.settings.tags; diff --git a/apps/admin/src/editor/settings/template-section.tsx b/apps/admin/src/editor/settings/template-section.tsx index 3990e80b0e4..faf9bd6f0ab 100644 --- a/apps/admin/src/editor/settings/template-section.tsx +++ b/apps/admin/src/editor/settings/template-section.tsx @@ -15,7 +15,7 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SectionLoadError } from './section-load-error'; import { SettingsSection } from './settings-section'; import { @@ -28,7 +28,7 @@ import { } from './template-options'; export interface TemplateSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/url-section.tsx b/apps/admin/src/editor/settings/url-section.tsx index 7daf62cd2de..ccac33e15c5 100644 --- a/apps/admin/src/editor/settings/url-section.tsx +++ b/apps/admin/src/editor/settings/url-section.tsx @@ -8,7 +8,7 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { normalizeManualSlug } from '@/editor/engine/slug-machine'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; import { formatUrlPreview } from './url-preview'; @@ -23,7 +23,7 @@ export function UrlSection({ postType, siteUrl, }: { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; siteUrl: string; }) { diff --git a/apps/admin/src/editor/settings/use-settings-field.test.ts b/apps/admin/src/editor/settings/use-settings-field.test.ts index 7698f7d905a..78ab09b7f9b 100644 --- a/apps/admin/src/editor/settings/use-settings-field.test.ts +++ b/apps/admin/src/editor/settings/use-settings-field.test.ts @@ -7,7 +7,7 @@ import { OG_TITLE_MAX, type ValidatedSettingsFields, } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { useSettingsField } from './use-settings-field'; const SETTINGS: ValidatedSettingsFields = { @@ -26,7 +26,7 @@ function fakeSession(settings: Partial = {}) { settings: { ...SETTINGS, ...settings }, stageSettings: vi.fn(), commitSettings: vi.fn(), - } as unknown as EditorSessionHandle & { + } as unknown as EditorSettingsPort & { stageSettings: ReturnType; commitSettings: ReturnType; }; diff --git a/apps/admin/src/editor/settings/use-settings-field.ts b/apps/admin/src/editor/settings/use-settings-field.ts index 05b4f3cd816..81fe710ba2c 100644 --- a/apps/admin/src/editor/settings/use-settings-field.ts +++ b/apps/admin/src/editor/settings/use-settings-field.ts @@ -3,7 +3,7 @@ import { settingsFieldErrorFor, type ValidatedSettingsFieldKey, } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; /** The settings keys a plain text field writes: the ones held to a length. */ export type SettingsTextFieldKey = Exclude; @@ -30,7 +30,7 @@ export interface SettingsFieldBinding { * is a hint's id, which the field points at alongside any error. */ export function useSettingsField( - session: EditorSessionHandle, + session: EditorSettingsPort, key: SettingsTextFieldKey, describedBy?: string, ): SettingsFieldBinding { From c84ff9271d60d1cb375e77ab36613bd00e61c368 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Thu, 17 Sep 2026 09:40:41 -0500 Subject: [PATCH 12/27] Changed the React editor's capped browses to share their params and pages (#30759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref - Centralizes the shared tier and newsletter query parameters - Makes the author picker fetch every staff page - Makes email preview fetch every newsletter page - Reuses the publish flow’s newsletter cache, then filters active newsletters client-side - Keeps archived newsletters selectable when already attached to a post - Adds tests covering pagination and shared request URLs --- apps/admin/src/editor/browse-params.ts | 13 +++++++ ...editor-settings-access.acceptance.test.tsx | 13 +++++++ ...ditor-settings-authors.acceptance.test.tsx | 18 ++++++++++ apps/admin/src/editor/preview/README.md | 2 ++ .../post-preview-modal.component.test.tsx | 36 +++++++++++++++++-- .../src/editor/preview/post-preview-modal.tsx | 34 +++++++++++++++--- .../components/email-recipients-options.tsx | 3 +- .../src/editor/publish/use-publish-inputs.ts | 3 +- apps/admin/src/editor/settings/README.md | 4 ++- .../src/editor/settings/access-section.tsx | 3 +- .../src/editor/settings/authors-section.tsx | 25 ++++++++----- 11 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 apps/admin/src/editor/browse-params.ts diff --git a/apps/admin/src/editor/browse-params.ts b/apps/admin/src/editor/browse-params.ts new file mode 100644 index 00000000000..d76bf0e4fb8 --- /dev/null +++ b/apps/admin/src/editor/browse-params.ts @@ -0,0 +1,13 @@ +/** + * Browse search params the editor reads from more than one place. A query's + * cache key is the serialized URL, so key order here is what shares the entry. + */ + +/** Every paid tier, archived ones included: the access, preview and publish tier pickers. */ +export const PAID_TIERS_SEARCH_PARAMS = { filter: 'type:paid', limit: 'all' } as const; + +/** + * Every newsletter, archived ones included. The preview narrows to active ones + * in the client rather than asking for a second, differently filtered list. + */ +export const NEWSLETTERS_SEARCH_PARAMS = { limit: 'all' } as const; diff --git a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx index 8554e14cd1b..37b7b57610e 100644 --- a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx @@ -345,6 +345,19 @@ describe('Post settings access', () => { await expect.element(editorScreen.updateButton()).toBeEnabled(); }); + it('browses paid tiers at the URL the publish flow and preview also send', async () => { + fakeSavablePost({ visibility: 'tiers', tiers: [{ id: GOLD.id }] }); + const tiersApi = fakeTiers(SITE_TIERS); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAccess(); + + await expect.element(editorScreen.settingsTier('Gold')).toBeVisible(); + // A differently spelled param order would be a second cache entry and a second browse. + await expect + .poll(() => new URL(tiersApi.lastRequest?.url ?? '', window.location.origin).search) + .toBe('?filter=type%3Apaid&limit=all'); + }); + it('leaves Access out for a role that cannot set it', async () => { fakeSavablePost({ authors: [{ id: '1' }] }); await renderAdminApp(`/editor/post/${POST_ID}`, asContributor()); diff --git a/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx index 9a9c90c73f3..86c0f7a4c15 100644 --- a/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { + browseResponse, currentUserResponse, fakeAdminEndpoint, fakeEditorChrome, @@ -388,6 +389,23 @@ describe('Post settings authors', () => { expect(editorScreen.settingsAuthorNames()).toEqual(['Owner User']); }); + it('offers every staff member, past the first page of the browse', async () => { + fakeSavablePost(); + // `limit=all` is capped by Core, so the site's staff can span several pages. + const staffApi = fakeAdminEndpoint('GET', /^\/users\/\?/, ({ url }) => { + const pageNumber = Number(new URL(url).searchParams.get('page') ?? '1'); + return browseResponse('users', [NADIA, JOSE], { page: pageNumber, limit: 1 }); + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAuthors(); + await openAuthorList(); + + await expect.element(editorScreen.settingsAuthorOption(JOSE.name)).toBeVisible(); + await expect.element(editorScreen.settingsAuthorOption(NADIA.name)).toBeVisible(); + await expect.poll(() => staffApi.requests.length).toBe(2); + expect(new URL(staffApi.requests[1].url).searchParams.get('page')).toBe('2'); + }); + it.each(['Author', 'Contributor'] as StaffRoleName[])( 'leaves Authors out for a %s', async (role) => { diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md index 0b17da081e9..ec814adbe94 100644 --- a/apps/admin/src/editor/preview/README.md +++ b/apps/admin/src/editor/preview/README.md @@ -33,6 +33,8 @@ The Email tab is offered for posts only, when members are on, newsletters are no The rendered email arrives as a complete HTML document and is shown in a `srcdoc` iframe sandboxed without `allow-scripts` and without `allow-same-origin`, so it can neither run its own scripts nor reach the admin page. Scrollbar styling is concatenated into that document because the admin stylesheet does not apply inside it. +The newsletters offered are the site's active ones, read from the same full browse the publish flow reads and narrowed here, every page of it. The post's own newsletter stays selectable even once it has been archived, which is looked up by slug; a newsletter the site has deleted leaves the email unsendable. + Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected. ## Not here yet diff --git a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx index b18f82a1b08..c9958fb9463 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { page } from 'vitest/browser'; import { + browseResponse, configResponse, currentUserResponse, fakeAdminEndpoint, @@ -371,6 +372,37 @@ describe('Post preview modal', () => { await expect.poll(() => previewApi.lastRequest?.url).toContain('newsletter=monthly-roundup'); }); + it('offers every newsletter, past the first page of the browse', async () => { + fakeTiers([]); + const first = newsletter({ name: 'Weekly digest', slug: 'weekly-digest' }); + const second = newsletter({ name: 'Monthly roundup', slug: 'monthly-roundup' }); + // `limit=all` is capped by Core, so the site's newsletters can span several pages. + const newslettersApi = fakeAdminEndpoint('GET', /^\/newsletters\/\?/, ({ url }) => { + const params = new URL(url).searchParams; + if (params.get('filter')) { + return browseResponse('newsletters', [], { limit: 1 }); + } + + return browseResponse('newsletters', [first, second], { + page: Number(params.get('page') ?? '1'), + limit: 1, + }); + }); + const previewApi = fakeEmailPreview(); + await renderPreviewModal({ newsletterSlug: 'monthly-roundup' }); + + await previewScreen.emailTab().click(); + + await expect.element(previewScreen.newsletterSelect()).toHaveTextContent('Monthly roundup'); + await expect.poll(() => newslettersApi.requests.length).toBe(2); + expect(new URL(newslettersApi.requests[1].url).searchParams.get('page')).toBe('2'); + // A newsletter past the first page must not be taken for one that has been archived. + expect( + newslettersApi.requests.some((request) => new URL(request.url).searchParams.get('filter')), + ).toBe(false); + await expect.poll(() => previewApi.lastRequest?.url).toContain('newsletter=monthly-roundup'); + }); + it('preselects the post’s own newsletter', async () => { fakePreviewWorld({ newsletters: [ @@ -464,11 +496,11 @@ describe('Post preview modal', () => { await expect.poll(() => previewApi.requests.length).toBe(1); }); - it('reports and retries a failed active-newsletter lookup', async () => { + it('reports and retries a failed newsletter lookup', async () => { fakePreviewWorld(); const lookupApi = fakeAdminEndpoint( 'GET', - /^\/newsletters\/\?.*filter=status(?:%3A|:)active/, + /^\/newsletters\/\?limit=all/, { errors: [{ message: 'Could not load newsletters' }] }, { status: 500 }, ); diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx index 2e575b51a5b..cdaceddf927 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.tsx @@ -35,6 +35,7 @@ import { isOwnerUser, } from '@tryghost/admin-x-framework/api/users'; +import { NEWSLETTERS_SEARCH_PARAMS, PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { postPreviewModal, postPreviewSaveFailed } from '@tryghost/test-data/selectors/editor'; import { useEditorSettings } from '@/editor/use-editor-settings'; @@ -111,7 +112,7 @@ export function PostPreviewModal({ (isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser)); const { data: tiersData } = useBrowseTiers({ - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, enabled: open && prepareState === 'ready' && paidMembersEnabled === true, requestOptions: EDITOR_REQUEST_OPTIONS, }); @@ -119,16 +120,39 @@ export function PostPreviewModal({ const { data: newslettersData, + fetchNextPage: fetchNextNewsletterPage, + hasNextPage: hasNextNewsletterPage, isError: activeNewslettersError, - isFetching: activeNewslettersFetching, + isFetching: newslettersFetching, + isFetchingNextPage: isFetchingNextNewsletterPage, refetch: refetchActiveNewsletters, } = useBrowseNewsletters({ - searchParams: { filter: 'status:active', limit: 'all' }, + searchParams: NEWSLETTERS_SEARCH_PARAMS, enabled: open && prepareState === 'ready' && emailAvailable, requestOptions: EDITOR_REQUEST_OPTIONS, - staleTime: 0, }); - const activeNewsletters = useMemo(() => newslettersData?.newsletters ?? [], [newslettersData]); + + // Core caps `limit=all`, so the response can still contain a next page. A + // newsletter past the cap would otherwise be taken for an archived one. + useEffect(() => { + if (hasNextNewsletterPage && !isFetchingNextNewsletterPage && !activeNewslettersError) { + void fetchNextNewsletterPage(); + } + }, [ + activeNewslettersError, + fetchNextNewsletterPage, + hasNextNewsletterPage, + isFetchingNextNewsletterPage, + ]); + const activeNewslettersFetching = + newslettersFetching || hasNextNewsletterPage || isFetchingNextNewsletterPage; + // The browse carries every newsletter, which is also the publish flow's list; + // narrowing here shares that one cache entry instead of asking for a subset. + const activeNewsletters = useMemo( + () => + (newslettersData?.newsletters ?? []).filter((newsletter) => newsletter.status === 'active'), + [newslettersData], + ); // The post's newsletter is what its email renders as, so it stays selectable // even once it has left the active list. diff --git a/apps/admin/src/editor/publish/components/email-recipients-options.tsx b/apps/admin/src/editor/publish/components/email-recipients-options.tsx index 04a1a640056..ceccb175e62 100644 --- a/apps/admin/src/editor/publish/components/email-recipients-options.tsx +++ b/apps/admin/src/editor/publish/components/email-recipients-options.tsx @@ -12,6 +12,7 @@ import { publishNewsletterSelect } from '@tryghost/test-data/selectors/editor'; import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; import { useBrowseLabelsInfinite } from '@tryghost/admin-x-framework/api/labels'; import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers'; +import { PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { useEffect, useId, useMemo } from 'react'; @@ -46,7 +47,7 @@ export function EmailRecipientsOptions({ const tiersQuery = useBrowseTiers({ defaultErrorHandler: false, requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, }); const labelsQuery = useBrowseLabelsInfinite({ defaultErrorHandler: false, diff --git a/apps/admin/src/editor/publish/use-publish-inputs.ts b/apps/admin/src/editor/publish/use-publish-inputs.ts index fc4ccb03b98..75956c2d6c9 100644 --- a/apps/admin/src/editor/publish/use-publish-inputs.ts +++ b/apps/admin/src/editor/publish/use-publish-inputs.ts @@ -4,6 +4,7 @@ import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { useCallback, useEffect, useMemo } from 'react'; import { z } from 'zod'; +import { NEWSLETTERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useEditorSettings, useSiteTimezone } from '@/editor/use-editor-settings'; import type { PublishSiteInput, PublishUserInput } from './publish-options'; @@ -153,7 +154,7 @@ export function usePublishInputs(): PublishInputs { const newslettersQuery = useBrowseNewsletters({ defaultErrorHandler: false, requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { limit: 'all' }, + searchParams: NEWSLETTERS_SEARCH_PARAMS, }); const { fetchNextPage: fetchNextNewsletterPage, diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index 8e89db870b5..2c8beed5e17 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -263,7 +263,9 @@ page editor as well as the post editor. Users are never created here, so the lis offers people the site already has. It is read once, when the list is first opened, rather than on every editor entry, and the chips are named from the post's own relations until then — including the chips an edit leaves behind, so -removing one never leaves the rest reading as bare ids. +removing one never leaves the rest reading as bare ids. The read continues until +every page of staff has arrived, and the list reads as loading until it has, so +a site with more staff than one page holds still offers all of them. A failed staff lookup shows an error and a Retry action in the list. Retrying keeps the selected authors and returns focus to the search field. diff --git a/apps/admin/src/editor/settings/access-section.tsx b/apps/admin/src/editor/settings/access-section.tsx index f31ba1d228b..7d5766b7b8e 100644 --- a/apps/admin/src/editor/settings/access-section.tsx +++ b/apps/admin/src/editor/settings/access-section.tsx @@ -18,6 +18,7 @@ import { settingsVisibilitySelect, } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; +import { PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { TIERS_REQUIRED, tiersIncomplete } from '@/editor/session/settings-fields'; @@ -116,7 +117,7 @@ export function AccessSection({ session, postType }: AccessSectionProps) { defaultErrorHandler: false, enabled: visibility === 'tiers', requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, }); const options = tierOptions(tiersData?.tiers); diff --git a/apps/admin/src/editor/settings/authors-section.tsx b/apps/admin/src/editor/settings/authors-section.tsx index 0b8d7341f4e..9b40f75421b 100644 --- a/apps/admin/src/editor/settings/authors-section.tsx +++ b/apps/admin/src/editor/settings/authors-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { FieldError, Label } from '@tryghost/shade/components'; import { useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; import type { PostAuthor } from '@tryghost/admin-x-framework/api/posts'; @@ -26,12 +26,21 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { const [browsing, setBrowsing] = useState(false); const startBrowsing = useCallback(() => setBrowsing(true), []); - const { data, isFetching, isError, refetch } = useBrowseUsers({ - defaultErrorHandler: false, - enabled: browsing, - requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: AUTHORS_SEARCH_PARAMS, - }); + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, refetch } = + useBrowseUsers({ + defaultErrorHandler: false, + enabled: browsing, + requestOptions: EDITOR_REQUEST_OPTIONS, + searchParams: AUTHORS_SEARCH_PARAMS, + }); + + // Core caps `limit=all`, so the response can still contain a next page. The + // list is not complete, and so still loading, until every page has arrived. + useEffect(() => { + if (hasNextPage && !isFetchingNextPage && !isError) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isError]); const authors = session.settings.authors as ReadonlyArray; // A post this session created carries its author's identity alone, and the @@ -52,7 +61,7 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { inputId={inputId} invalid={invalid} loadError={isError} - loading={isFetching} + loading={isFetching || hasNextPage} selected={selected} staff={data?.users ?? []} onChange={change} From e5c738925c6ad45316638072282b7240445f225d Mon Sep 17 00:00:00 2001 From: Sag Date: Thu, 17 Sep 2026 16:44:59 +0200 Subject: [PATCH 13/27] Added progressive payment failure warnings to Admin ref [GVA-988](https://linear.app/ghost/issue/GVA-988) Give publishers advance warning of hosting payment failures and the suspension deadline, with payment and export actions for owners and guidance for staff. Drive warning severity from host configuration behind a flag. Clear warnings after successful payment, preserve legacy behavior when configuration is unavailable, and let open dialogs close before showing the takeover. --- apps/admin-x-framework/src/api/config.ts | 9 + apps/admin-x-framework/src/api/dunning.ts | 32 ++ .../test/unit/api/dunning.test.ts | 46 +++ apps/admin/src/dunning/dunning-banner.tsx | 59 +++ apps/admin/src/dunning/dunning-copy.test.ts | 57 +++ apps/admin/src/dunning/dunning-copy.ts | 55 +++ .../dunning/dunning-modal.acceptance.test.tsx | 78 ++++ apps/admin/src/dunning/dunning-overlay.tsx | 129 ++++++ apps/admin/src/dunning/dunning-ui.test.tsx | 366 ++++++++++++++++++ .../src/dunning/dunning.acceptance.test.tsx | 91 +++++ apps/admin/src/dunning/index.ts | 4 + apps/admin/src/dunning/minute-ticker.test.ts | 74 ++++ apps/admin/src/dunning/minute-ticker.ts | 66 ++++ apps/admin/src/dunning/pay-now-button.tsx | 29 ++ apps/admin/src/dunning/stand-down-routes.ts | 27 ++ .../src/dunning/use-blocking-modal.test.ts | 88 +++++ apps/admin/src/dunning/use-blocking-modal.ts | 51 +++ .../src/dunning/use-dunning-lock-takeover.ts | 31 ++ .../src/dunning/use-dunning-state.test.ts | 308 +++++++++++++++ apps/admin/src/dunning/use-dunning-state.ts | 204 ++++++++++ apps/admin/src/dunning/use-owner-user.ts | 22 ++ apps/admin/src/layout/admin-layout.tsx | 65 +++- .../src/layout/app-sidebar/app-sidebar.tsx | 20 +- .../advanced/labs/private-features.tsx | 6 + apps/admin/test-utils/fixtures/dunning.ts | 37 ++ .../app/components/gh-billing-iframe.js | 14 +- apps/ember-admin/app/services/billing.js | 75 ++++ apps/ember-admin/app/services/feature.js | 1 + .../components/gh-billing-iframe-test.js | 71 ++++ .../tests/unit/services/billing-test.js | 104 +++++ ghost/core/core/shared/labs.js | 1 + 31 files changed, 2203 insertions(+), 17 deletions(-) create mode 100644 apps/admin-x-framework/src/api/dunning.ts create mode 100644 apps/admin-x-framework/test/unit/api/dunning.test.ts create mode 100644 apps/admin/src/dunning/dunning-banner.tsx create mode 100644 apps/admin/src/dunning/dunning-copy.test.ts create mode 100644 apps/admin/src/dunning/dunning-copy.ts create mode 100644 apps/admin/src/dunning/dunning-modal.acceptance.test.tsx create mode 100644 apps/admin/src/dunning/dunning-overlay.tsx create mode 100644 apps/admin/src/dunning/dunning-ui.test.tsx create mode 100644 apps/admin/src/dunning/dunning.acceptance.test.tsx create mode 100644 apps/admin/src/dunning/index.ts create mode 100644 apps/admin/src/dunning/minute-ticker.test.ts create mode 100644 apps/admin/src/dunning/minute-ticker.ts create mode 100644 apps/admin/src/dunning/pay-now-button.tsx create mode 100644 apps/admin/src/dunning/stand-down-routes.ts create mode 100644 apps/admin/src/dunning/use-blocking-modal.test.ts create mode 100644 apps/admin/src/dunning/use-blocking-modal.ts create mode 100644 apps/admin/src/dunning/use-dunning-lock-takeover.ts create mode 100644 apps/admin/src/dunning/use-dunning-state.test.ts create mode 100644 apps/admin/src/dunning/use-dunning-state.ts create mode 100644 apps/admin/src/dunning/use-owner-user.ts create mode 100644 apps/admin/test-utils/fixtures/dunning.ts diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts index 618352db554..210743aac12 100644 --- a/apps/admin-x-framework/src/api/config.ts +++ b/apps/admin-x-framework/src/api/config.ts @@ -103,6 +103,15 @@ export type Config = { logoDark?: string; // Logo shown in dark mode, falls back to logo logoAlt?: string; // Alt text for the logo }; + // Payment-failure (dunning) state for the site's hosting subscription. + // Managed hosting providers set this while a payment is outstanding; + // Admin escalates from a warning banner to a locked overlay based on + // the position within the paymentFailedAt -> suspendsAt window. + dunning?: { + active?: boolean; // Only true while the payment is outstanding + paymentFailedAt?: string; // ISO date the payment first failed (window start) + suspendsAt?: string; // ISO date the host will suspend the site (window end) + }; // Search entries for billing paths, defined in host config (hostSettings.billing.search: {}) search?: { groupName?: string; diff --git a/apps/admin-x-framework/src/api/dunning.ts b/apps/admin-x-framework/src/api/dunning.ts new file mode 100644 index 00000000000..4cd6faa194f --- /dev/null +++ b/apps/admin-x-framework/src/api/dunning.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * sessionStorage keys the React admin's dunning UI and the Ember billing + * service handshake through — one side writes, the other consumes. Defined + * here so the cross-app contract lives in one place. + */ + +/** Route a "Pay now" CTA was clicked on; the post-payment return lands there. */ +export const DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY = 'ghost-dunning-pay-return-route'; + +/** `paymentFailedAt` of a failure settled by a completed payment this session. */ +export const DUNNING_PAYMENT_SETTLED_STORAGE_KEY = 'ghost-dunning-payment-settled-for'; + +const dateString = z + .string() + .transform((value) => new Date(value)) + .pipe(z.date()); + +const dunningConfigSchema = z + .object({ + active: z.literal(true), + paymentFailedAt: dateString, + suspendsAt: dateString, + }) + .refine(({ paymentFailedAt, suspendsAt }) => suspendsAt.getTime() > paymentFailedAt.getTime()); + +/** Shared by React warnings and the legacy alert so invalid config never hides both. */ +export function parseDunningConfig(value: unknown) { + const result = dunningConfigSchema.safeParse(value); + return result.success ? result.data : null; +} diff --git a/apps/admin-x-framework/test/unit/api/dunning.test.ts b/apps/admin-x-framework/test/unit/api/dunning.test.ts new file mode 100644 index 00000000000..20ae9546d49 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/dunning.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + parseDunningConfig, +} from '../../../src/api/dunning'; + +const validConfig = { + active: true, + paymentFailedAt: '2026-09-01T00:00:00Z', + suspendsAt: '2026-09-29T00:00:00Z', +}; + +describe('parseDunningConfig', () => { + it.each([ + undefined, + null, + { active: true }, + { ...validConfig, active: false }, + { ...validConfig, active: 'false' }, + { ...validConfig, paymentFailedAt: 'invalid' }, + { ...validConfig, paymentFailedAt: 1 }, + { ...validConfig, suspendsAt: true }, + { ...validConfig, suspendsAt: validConfig.paymentFailedAt }, + { ...validConfig, suspendsAt: '2026-08-01' }, + ])('rejects unusable host config: %j', (config) => { + expect(parseDunningConfig(config)).toBeNull(); + }); + + it('pins the storage keys both apps handshake through', () => { + // These literals are the on-the-wire contract between the React admin and + // the Ember billing service; renaming the constants must not change them. + expect(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY).toBe('ghost-dunning-pay-return-route'); + expect(DUNNING_PAYMENT_SETTLED_STORAGE_KEY).toBe('ghost-dunning-payment-settled-for'); + }); + + it('parses valid dates, including host timezone offsets', () => { + expect( + parseDunningConfig({ ...validConfig, paymentFailedAt: '2026-09-01T04:00:00+04:00' }), + ).toEqual({ + active: true, + paymentFailedAt: new Date(validConfig.paymentFailedAt), + suspendsAt: new Date(validConfig.suspendsAt), + }); + }); +}); diff --git a/apps/admin/src/dunning/dunning-banner.tsx b/apps/admin/src/dunning/dunning-banner.tsx new file mode 100644 index 00000000000..e2002ad0198 --- /dev/null +++ b/apps/admin/src/dunning/dunning-banner.tsx @@ -0,0 +1,59 @@ +import { Inline } from '@tryghost/shade/primitives'; +import { LucideIcon, cn } from '@tryghost/shade/utils'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { isOwnerUser } from '@tryghost/admin-x-framework/api/users'; +import { useLocation } from '@tryghost/admin-x-framework'; +import { useDunningState } from './use-dunning-state'; +import { useDunningLockTakeover } from './use-dunning-lock-takeover'; +import { PayNowButton } from './pay-now-button'; +import { bannerMessage, bannerTitle } from './dunning-copy'; +import { isBillingRoute } from './stand-down-routes'; + +/** + * Top-of-content warning strip. Carries the dunning message whenever the + * full-page takeover isn't doing so: through the warning phase, and in the + * locked phase once the takeover was dismissed or stood down for the current + * route (e.g. the export tools). Renders nothing on the billing route itself, + * or for hosts that don't inject a dunning state. + */ +export function DunningBanner() { + const { data: currentUser } = useCurrentUser(); + const state = useDunningState(); + const takeover = useDunningLockTakeover(); + const location = useLocation(); + + if (!state || takeover || !currentUser || isBillingRoute(location.pathname)) { + return null; + } + + const isOwner = isOwnerUser(currentUser); + + return ( + + + + + {bannerTitle(state, isOwner)}{' '} + {bannerMessage(state, isOwner)} + + + {isOwner && } + + ); +} diff --git a/apps/admin/src/dunning/dunning-copy.test.ts b/apps/admin/src/dunning/dunning-copy.test.ts new file mode 100644 index 00000000000..bd56b92f541 --- /dev/null +++ b/apps/admin/src/dunning/dunning-copy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; + +import type { DunningState } from './use-dunning-state'; +import { + EXPORT_URL, + bannerMessage, + bannerTitle, + daysLeftLabel, + lockedHeadline, + lockedMessage, +} from './dunning-copy'; +import { DATA_EXPORT_ROUTE } from './stand-down-routes'; + +const state = (overrides: Partial = {}): DunningState => ({ + phase: 'warning', + daysLeft: 14, + urgent: false, + lockDismissed: false, + paymentFailedAt: new Date('2026-09-01T00:00:00Z'), + suspendsAt: new Date('2026-09-29T00:00:00Z'), + ...overrides, +}); + +describe('dunning copy', () => { + test('the export CTA leads to the route the takeover stands down on', () => { + expect(EXPORT_URL).toBe(`#${DATA_EXPORT_ROUTE}`); + }); + + test('bannerTitle addresses the reader and escalates with urgency', () => { + expect(bannerTitle(state(), true)).toBe('Your payment didn’t go through.'); + expect(bannerTitle(state({ urgent: true }), true)).toBe('Action needed: payment failed.'); + expect(bannerTitle(state(), false)).toBe('This site’s payment failed.'); + expect(bannerTitle(state({ urgent: true }), false)).toBe('Payment still failing.'); + }); + + test('bannerMessage asks the owner to pay and staff to remind them', () => { + expect(bannerMessage(state(), true)).toMatch(/^Complete your payment/); + expect(bannerMessage(state(), false)).toMatch(/^Remind the site owner/); + }); + + test('daysLeftLabel handles the singular day', () => { + expect(daysLeftLabel(1)).toBe('1 day'); + expect(daysLeftLabel(6)).toBe('6 days'); + expect(bannerMessage(state({ daysLeft: 1 }), true)).toMatch(/\(1 day left\)/); + }); + + test('lockedHeadline counts down to the suspension', () => { + expect(lockedHeadline(6)).toBe('Your site will be suspended in 6 days'); + expect(lockedHeadline(1)).toBe('Your site will be suspended tomorrow'); + expect(lockedHeadline(0)).toBe('Your site will be suspended soon'); + }); + + test('lockedMessage addresses the reader', () => { + expect(lockedMessage(state(), true)).toMatch(/Pay the outstanding invoice/); + expect(lockedMessage(state(), false)).toMatch(/Remind the site owner/); + }); +}); diff --git a/apps/admin/src/dunning/dunning-copy.ts b/apps/admin/src/dunning/dunning-copy.ts new file mode 100644 index 00000000000..abb52f76f74 --- /dev/null +++ b/apps/admin/src/dunning/dunning-copy.ts @@ -0,0 +1,55 @@ +import type { DunningState } from './use-dunning-state'; +import { DATA_EXPORT_ROUTE } from './stand-down-routes'; + +/** + * Destination of the "Pay now" CTA: the billing app's payment page, on its + * return variant — after a successful payment the billing app sends Admin + * back to the page the user came from. + */ +export const PAY_URL = '#/pro/update-card/return'; + +/** Destination of the "Download my data" CTA: the export tools in settings. */ +export const EXPORT_URL = `#${DATA_EXPORT_ROUTE}`; + +export function formatDeadline(date: Date): string { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +export function daysLeftLabel(daysLeft: number): string { + return daysLeft === 1 ? '1 day' : `${daysLeft} days`; +} + +export function bannerTitle(state: DunningState, isOwner: boolean): string { + if (isOwner) { + return state.urgent ? 'Action needed: payment failed.' : 'Your payment didn’t go through.'; + } + return state.urgent ? 'Payment still failing.' : 'This site’s payment failed.'; +} + +export function bannerMessage(state: DunningState, isOwner: boolean): string { + const deadline = formatDeadline(state.suspendsAt); + const remaining = daysLeftLabel(state.daysLeft); + if (isOwner) { + return `Complete your payment by ${deadline} to avoid suspension (${remaining} left).`; + } + return `Remind the site owner to pay the outstanding invoice before ${deadline} to avoid suspension (${remaining} left).`; +} + +export function lockedHeadline(daysLeft: number): string { + if (daysLeft === 0) { + return 'Your site will be suspended soon'; + } + if (daysLeft === 1) { + return 'Your site will be suspended tomorrow'; + } + return `Your site will be suspended in ${daysLeft} days`; +} + +export function lockedMessage(state: DunningState, isOwner: boolean): string { + const failedOn = formatDeadline(state.paymentFailedAt); + const deadline = formatDeadline(state.suspendsAt); + if (isOwner) { + return `Your last payment failed on ${failedOn} and reminders have gone unanswered. Pay the outstanding invoice before ${deadline} to avoid suspension.`; + } + return `The last payment failed on ${failedOn} and reminders have gone unanswered. Remind the site owner to pay the outstanding invoice before ${deadline} to avoid suspension.`; +} diff --git a/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx b/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx new file mode 100644 index 00000000000..f7a88c7fa47 --- /dev/null +++ b/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { configResponse, fakeAdminEndpoint, renderAdminApp, tag } from '@test-utils/acceptance'; +import { tagDetailScreen } from '@/tags/detail/tag-detail.screen'; +import { DAY_MS, dunningWindow } from '@test-utils/fixtures/dunning'; + +// Control only the dunning clock: the dialog's browser focus and pointer-event +// handling must stay real to reproduce the conflict at the phase boundary. +const clock = vi.hoisted(() => ({ now: Date.now(), listeners: new Set<() => void>() })); +vi.mock('./minute-ticker', () => ({ + readSharedNow: () => clock.now, + subscribeSharedNow: (listener: () => void) => { + clock.listeners.add(listener); + return () => { + clock.listeners.delete(listener); + }; + }, + retainMinuteTicker: () => () => {}, +})); + +beforeEach(() => { + clock.now = Date.now(); + clock.listeners.clear(); +}); + +it.each(['Dismiss', 'Pay now'])( + 'waits for an existing dialog to close before showing a usable %s action', + async (action) => { + const news = tag({ name: 'News', slug: 'news' }); + fakeAdminEndpoint('GET', new RegExp(`^/tags/slug/${news.slug}/`), () => ({ tags: [news] })); + const response = configResponse(); + await renderAdminApp('/tags/news', { + labs: { dunningWarnings: true, tagDetailsReact: true }, + boot: { + browseConfig: { + response: { + config: { + ...response.config, + hostSettings: { + billing: { enabled: true, dunning: dunningWindow(20, { now: clock.now }) }, + }, + }, + }, + }, + }, + }); + + await tagDetailScreen.actionsButton().click(); + await tagDetailScreen.deleteTagMenuItem().click(); + await expect.element(tagDetailScreen.deleteModal()).toBeVisible(); + + clock.now += 2 * DAY_MS; + clock.listeners.forEach((listener) => listener()); + + // The existing dialog keeps its controls until the user closes it. The + // warning remains in the page; the takeover must not cover the dialog. + await expect.element(page.getByTestId('dunning-banner')).toHaveTextContent(/6 days left/); + await expect(page.getByTestId('dunning-overlay')).toHaveCount(0); + await tagDetailScreen + .deleteModal() + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + + const takeover = page.getByRole('alertdialog'); + await expect.element(takeover).toBeVisible(); + await expect.element(takeover).toHaveFocus(); + + if (action === 'Dismiss') { + await takeover.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await expect(takeover).toHaveCount(0); + await tagDetailScreen.nameInput().click(); + await expect.element(tagDetailScreen.nameInput()).toHaveFocus(); + } else { + await takeover.getByRole('link', { name: 'Pay now', exact: true }).click(); + await expect.poll(() => window.location.hash).toBe('#/pro/update-card/return'); + } + }, +); diff --git a/apps/admin/src/dunning/dunning-overlay.tsx b/apps/admin/src/dunning/dunning-overlay.tsx new file mode 100644 index 00000000000..0919035a8ca --- /dev/null +++ b/apps/admin/src/dunning/dunning-overlay.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef } from 'react'; +import { Button } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import type { User } from '@tryghost/admin-x-framework/api/users'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { isOwnerUser } from '@tryghost/admin-x-framework/api/users'; +import { useDunningState, dismissLock } from './use-dunning-state'; +import { useDunningLockTakeover } from './use-dunning-lock-takeover'; +import { useOwnerUser } from './use-owner-user'; +import { PayNowButton } from './pay-now-button'; +import { EXPORT_URL, lockedHeadline, lockedMessage } from './dunning-copy'; + +/** + * Who to talk to about the payment. Staff realistically reach the owner + * however they normally would — so no CTA, just the person. + */ +function OwnerCard({ owner }: { owner: User }) { + const displayName = owner.name || owner.email; + const initial = displayName.charAt(0).toUpperCase(); + + return ( + +
+ {initial} +
+ + + {displayName} (Owner) + + + {owner.email} + + +
+ ); +} + +/** + * Full-viewport takeover for the dunning locked phase. + * + * Deliberately a painted overlay, not a route lock: the aim is to make the + * outstanding payment unmissable, not to enforce it — the host suspends the + * site at `suspendsAt` regardless. It stands down on the billing route so the + * user can reach the payment form, and it can be dismissed for the session, + * dropping back to the urgent warning banner. + */ +export function DunningOverlay() { + const { data: currentUser } = useCurrentUser(); + const state = useDunningState(); + const takeover = useDunningLockTakeover(); + const isOwner = Boolean(currentUser && isOwnerUser(currentUser)); + // This component mounts on every Admin page; only fetch the user list in + // the one case that renders the owner card (staff seeing the takeover) + const owner = useOwnerUser({ enabled: takeover && Boolean(currentUser) && !isOwner }); + + // Move keyboard focus into the dialog when it takes over, so keyboard and + // screen-reader users land on the message rather than the covered page — + // and hand focus back to where it was once the takeover stands down. The + // layout makes the covered regions inert meanwhile, so Tab cannot leave + // the dialog for the covered page. + const dialogRef = useRef(null); + useEffect(() => { + if (!takeover) { + return; + } + + const previouslyFocused = document.activeElement; + dialogRef.current?.focus(); + + return () => { + if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) { + previouslyFocused.focus(); + } + }; + }, [takeover]); + + if (!state || !currentUser || !takeover) { + return null; + } + + return ( +
+ {/* Same close treatment as the full-screen Settings view's exit button */} + + +
+ +
+

+ {lockedHeadline(state.daysLeft)} +

+ + {lockedMessage(state, isOwner)} + + {isOwner ? ( + + + + + ) : ( + owner && + )} +
+
+ ); +} diff --git a/apps/admin/src/dunning/dunning-ui.test.tsx b/apps/admin/src/dunning/dunning-ui.test.tsx new file mode 100644 index 00000000000..f8be3723671 --- /dev/null +++ b/apps/admin/src/dunning/dunning-ui.test.tsx @@ -0,0 +1,366 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { browseConfigWithDunning, dunningWindow } from '@test-utils/fixtures/dunning'; + +import { DunningBanner } from './dunning-banner'; +import { DunningOverlay } from './dunning-overlay'; + +const { + mockUseBrowseConfig, + mockUseSubscriptionStatus, + mockUseCurrentUser, + mockUseBrowseUsers, + mockUseLocation, +} = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), + mockUseSubscriptionStatus: vi.fn(), + mockUseCurrentUser: vi.fn(), + mockUseBrowseUsers: vi.fn(), + mockUseLocation: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: mockUseLocation, +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +vi.mock('@tryghost/admin-x-framework/api/current-user', async () => { + const actual = await vi.importActual< + typeof import('@tryghost/admin-x-framework/api/current-user') + >('@tryghost/admin-x-framework/api/current-user'); + return { ...actual, useCurrentUser: mockUseCurrentUser }; +}); + +vi.mock('@tryghost/admin-x-framework/api/users', async () => { + const actual = await vi.importActual( + '@tryghost/admin-x-framework/api/users', + ); + return { ...actual, useBrowseUsers: mockUseBrowseUsers }; +}); + +vi.mock('@/ember-bridge', () => ({ + useSubscriptionStatus: mockUseSubscriptionStatus, +})); + +const NOW = new Date('2026-09-10T12:00:00Z'); + +const ownerUser = { + id: 'owner-id', + name: 'Aileen', + email: 'owner@example.com', + roles: [{ name: 'Owner' }], +}; +const editorUser = { + id: 'editor-id', + email: 'editor@example.com', + roles: [{ name: 'Editor' }], +}; + +describe('dunning UI', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + window.sessionStorage.clear(); + mockUseSubscriptionStatus.mockReturnValue(null); + mockUseLocation.mockReturnValue({ pathname: '/analytics' }); + mockUseCurrentUser.mockReturnValue({ data: ownerUser }); + mockUseBrowseUsers.mockReturnValue({ data: { users: [ownerUser, editorUser] } }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('DunningBanner', () => { + test('renders nothing without dunning config', () => { + mockUseBrowseConfig.mockReturnValue({ data: { config: { hostSettings: {} } } }); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('shows the owner a Pay now link to the billing app', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + render(); + + expect(screen.getByText('Your payment didn’t go through.')).toBeInTheDocument(); + expect(screen.getByText(/26 days left/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Pay now' })).toHaveAttribute( + 'href', + '#/pro/update-card/return', + ); + }); + + test('shows staff the remind-the-owner copy without any CTA', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + + render(); + + expect(screen.getByText('This site’s payment failed.')).toBeInTheDocument(); + expect(screen.getByText(/Remind the site owner/)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + test('renders nothing on the billing route', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + mockUseLocation.mockReturnValue({ pathname: '/pro/billing' }); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('hands over to the takeover once the locked phase starts', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('carries the warning on the export route while the takeover stands down', () => { + // Undismissed locked phase on /settings/migration: the takeover stands + // down so the export tools stay usable — the banner must step in, or + // the user is left with no payment warning at all. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/settings/migration' }); + + render( + <> + + + , + ); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + }); + }); + + describe('DunningOverlay', () => { + test('renders nothing during the warning phase', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + // Mounted on every Admin page, so the user list must not be fetched + // outside the staff-facing takeover + expect(mockUseBrowseUsers).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + + test('takes over for the owner in the locked phase', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render(); + + expect(screen.getByText('Your site will be suspended in 6 days')).toBeInTheDocument(); + expect(screen.getByText(/avoid suspension/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Pay now' })).toHaveAttribute( + 'href', + '#/pro/update-card/return', + ); + expect(screen.getByRole('link', { name: 'Download my data' })).toHaveAttribute( + 'href', + '#/settings/migration', + ); + }); + + test('stands down on the export route so the data download stays reachable', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/settings/migration' }); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + }); + + test('shows staff the owner card instead of a payment link', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + + render(); + + expect(screen.getByText('Aileen (Owner)')).toBeInTheDocument(); + expect(screen.getByText('owner@example.com')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(mockUseBrowseUsers).toHaveBeenCalledWith({ + enabled: true, + searchParams: { filter: "roles.name:'Owner'", limit: '1', include: 'roles' }, + }); + }); + + test('labels the owner card by email when the owner has no name', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + mockUseBrowseUsers.mockReturnValue({ data: { users: [{ ...ownerUser, name: '' }] } }); + + render(); + + expect(screen.getByText('owner@example.com (Owner)')).toBeInTheDocument(); + }); + + test('degrades to copy only when staff cannot resolve the owner', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + mockUseBrowseUsers.mockReturnValue({ data: undefined }); + + render(); + + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.getByText(/Remind the site owner/)).toBeInTheDocument(); + expect(screen.queryByText(/\(Owner\)/)).not.toBeInTheDocument(); + }); + + test('stands down on the billing route so the user can pay', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/pro' }); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + }); + + test('moves focus into the dialog and hands it back on dismissal', () => { + // Start outside the locked phase with focus on a page control + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + const view = render( + <> + + + , + ); + screen.getByTestId('page-control').focus(); + + // The window crosses into the locked phase: the takeover appears and + // takes keyboard focus so Tab starts inside the dialog + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + view.rerender( + <> + + + , + ); + expect(screen.getByTestId('dunning-overlay')).toHaveFocus(); + + // Dismissing hands focus back to the control that had it + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('page-control')).toHaveFocus(); + }); + + test('leaves focus alone when the previously focused control is gone', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + const view = render( + <> + + + , + ); + screen.getByTestId('page-control').focus(); + + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + view.rerender( + <> + + + , + ); + expect(screen.getByTestId('dunning-overlay')).toHaveFocus(); + + // The control disappears while the takeover is up (e.g. its screen + // re-rendered); dismissal must not try to focus a detached node + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(document.body).toHaveFocus(); + }); + + test('dismissing drops back to the urgent warning banner', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render( + <> + + + , + ); + + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + expect(screen.getByText('Action needed: payment failed.')).toBeInTheDocument(); + }); + + test('following Pay now suppresses the takeover without a pre-navigation flash', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const view = render( + <> + + + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'Pay now' })); + + // Nothing swaps before the route change: the takeover holds the screen + // until the billing route takes over. + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + + // The click records where to return after the payment (consumed by the + // Ember billing service's previousPage handling) + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).toBe('/analytics'); + + // On the billing route both stand down as usual. + mockUseLocation.mockReturnValue({ pathname: '/pro/update-card' }); + view.rerender( + <> + + + , + ); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + + // Leaving billing again, the recorded suppression kicks in: the banner + // carries the message and the takeover stays away. + mockUseLocation.mockReturnValue({ pathname: '/analytics' }); + view.rerender( + <> + + + , + ); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + }); + + test('shows the imminent headline when the suspension date has passed', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(30))); + + render(); + + expect(screen.getByText('Your site will be suspended soon')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/admin/src/dunning/dunning.acceptance.test.tsx b/apps/admin/src/dunning/dunning.acceptance.test.tsx new file mode 100644 index 00000000000..8d6ce126918 --- /dev/null +++ b/apps/admin/src/dunning/dunning.acceptance.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { + configResponse, + currentUserResponse, + fakeTags, + fakeUsers, + renderAdminApp, + staffRole, + staffUser, +} from '@test-utils/acceptance'; +import { tagsScreen } from '@/tags/tags.screen'; +import { dunningWindow } from '@test-utils/fixtures/dunning'; + +function configWithDunning(dunning?: unknown) { + const response = configResponse(); + return { + config: { + ...response.config, + hostSettings: { + billing: { enabled: true, dunning }, + }, + }, + }; +} + +describe('Dunning in the Admin layout', () => { + it.each([ + { label: 'an older backend without dunning config', enabled: true, dunning: undefined }, + { label: 'malformed dunning config', enabled: true, dunning: { active: true } }, + { label: 'the feature flag disabled', enabled: false, dunning: dunningWindow(22) }, + ])('keeps normal navigation usable with $label', async ({ enabled, dunning }) => { + fakeTags([]); + await renderAdminApp('/tags', { + labs: { dunningWarnings: enabled }, + boot: { browseConfig: { response: configWithDunning(dunning) } }, + }); + + await expect.element(tagsScreen.newTagLink()).toBeVisible(); + await expect(page.getByTestId('dunning-banner')).toHaveCount(0); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await tagsScreen.internalTab().click(); + await expect.element(tagsScreen.internalTab()).toHaveAttribute('aria-checked', 'true'); + }); + + it('shows the owner a warning and payment link before the takeover', async () => { + fakeTags([]); + await renderAdminApp('/tags', { + labs: { dunningWarnings: true }, + boot: { browseConfig: { response: configWithDunning(dunningWindow(2)) } }, + }); + + await expect.element(page.getByTestId('dunning-banner')).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Pay now' })) + .toHaveAttribute('href', '#/pro/update-card/return'); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + }); + + it('fetches only the owner for the staff takeover and restores the page on dismissal', async () => { + fakeTags([]); + // Declare the owner-only response and assert the server-side filter below; + // the fake deliberately does not implement NQL or depend on staff ordering. + const owner = staffUser({ + name: 'Site Owner', + email: 'owner@example.com', + roles: [staffRole({ name: 'Owner' })], + }); + const usersApi = fakeUsers([owner]); + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: 'Editor' })]; + await renderAdminApp('/tags', { + labs: { dunningWarnings: true }, + boot: { + browseConfig: { response: configWithDunning(dunningWindow(22)) }, + browseMe: { response: me }, + }, + }); + + await expect.element(page.getByRole('alertdialog')).toBeVisible(); + await expect.element(page.getByText('owner@example.com', { exact: true })).toBeVisible(); + await expect(usersApi).toHaveSentFilter("roles.name:'Owner'"); + expect(usersApi.lastRequest?.limit).toBe(1); + expect(usersApi.requests).toHaveLength(1); + await page.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await expect.element(page.getByTestId('dunning-banner')).toBeVisible(); + await tagsScreen.internalTab().click(); + await expect.element(tagsScreen.internalTab()).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/apps/admin/src/dunning/index.ts b/apps/admin/src/dunning/index.ts new file mode 100644 index 00000000000..62a7eb7a44b --- /dev/null +++ b/apps/admin/src/dunning/index.ts @@ -0,0 +1,4 @@ +export { DunningBanner } from './dunning-banner'; +export { DunningOverlay } from './dunning-overlay'; +export { useDunningLockTakeover } from './use-dunning-lock-takeover'; +export { useDunningState } from './use-dunning-state'; diff --git a/apps/admin/src/dunning/minute-ticker.test.ts b/apps/admin/src/dunning/minute-ticker.test.ts new file mode 100644 index 00000000000..51f05fcd102 --- /dev/null +++ b/apps/admin/src/dunning/minute-ticker.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { readSharedNow, retainMinuteTicker, subscribeSharedNow } from './minute-ticker'; + +describe('minute ticker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('refreshes the reading and starts ticking on first retention', () => { + vi.setSystemTime(new Date('2026-09-10T12:00:00Z')); + + const release = retainMinuteTicker(); + + expect(readSharedNow()).toBe(new Date('2026-09-10T12:00:00Z').getTime()); + expect(vi.getTimerCount()).toBe(1); + + release(); + }); + + test('notifies subscribers on each tick while retained', () => { + const release = retainMinuteTicker(); + const listener = vi.fn(); + const unsubscribe = subscribeSharedNow(listener); + const start = readSharedNow(); + + vi.advanceTimersByTime(120_000); + + expect(listener).toHaveBeenCalledTimes(2); + expect(readSharedNow()).toBe(start + 120_000); + + unsubscribe(); + release(); + }); + + test('stops notifying an unsubscribed listener', () => { + const release = retainMinuteTicker(); + const listener = vi.fn(); + subscribeSharedNow(listener)(); + + vi.advanceTimersByTime(60_000); + + expect(listener).not.toHaveBeenCalled(); + release(); + }); + + test('runs one interval however many consumers retain it', () => { + const releaseFirst = retainMinuteTicker(); + const releaseSecond = retainMinuteTicker(); + expect(vi.getTimerCount()).toBe(1); + + releaseFirst(); + expect(vi.getTimerCount()).toBe(1); + + releaseSecond(); + expect(vi.getTimerCount()).toBe(0); + }); + + test('a double release cannot stop a later retention', () => { + const releaseFirst = retainMinuteTicker(); + releaseFirst(); + releaseFirst(); + + const releaseSecond = retainMinuteTicker(); + expect(vi.getTimerCount()).toBe(1); + + releaseSecond(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/admin/src/dunning/minute-ticker.ts b/apps/admin/src/dunning/minute-ticker.ts new file mode 100644 index 00000000000..07094bff191 --- /dev/null +++ b/apps/admin/src/dunning/minute-ticker.ts @@ -0,0 +1,66 @@ +/** + * One shared once-a-minute clock for every dunning surface. + * + * Each `useDunningState` instance used to run its own interval with its own + * `Date.now()` anchor, so at a phase boundary the banner, the takeover and + * the layout could disagree for up to a minute. Reading `now` from a single + * store means every consumer derives the phase from the same reading, and a + * boundary flips all surfaces in the same render pass. + * + * The clock only ticks while at least one consumer retains it, so sessions + * without dunning in effect carry no interval at all. `retainMinuteTicker` + * refreshes the reading immediately on the first retention — the stored + * value may be as old as the module load otherwise. + */ + +const TICK_MS = 60_000; + +let sharedNow = Date.now(); +let interval: ReturnType | null = null; +let retainers = 0; + +const listeners = new Set<() => void>(); + +function tick(): void { + sharedNow = Date.now(); + listeners.forEach((listener) => listener()); +} + +/** `useSyncExternalStore` snapshot: the shared clock's last reading. */ +export function readSharedNow(): number { + return sharedNow; +} + +/** `useSyncExternalStore` subscription to the shared clock's ticks. */ +export function subscribeSharedNow(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Keeps the clock ticking until the returned release is called. The release + * is idempotent, so an effect cleanup cannot double-decrement the count. + */ +export function retainMinuteTicker(): () => void { + retainers += 1; + if (retainers === 1) { + tick(); + interval = setInterval(tick, TICK_MS); + } + + let released = false; + return () => { + if (released) { + return; + } + released = true; + + retainers -= 1; + if (retainers === 0 && interval) { + clearInterval(interval); + interval = null; + } + }; +} diff --git a/apps/admin/src/dunning/pay-now-button.tsx b/apps/admin/src/dunning/pay-now-button.tsx new file mode 100644 index 00000000000..b2808698b32 --- /dev/null +++ b/apps/admin/src/dunning/pay-now-button.tsx @@ -0,0 +1,29 @@ +import { Button } from '@tryghost/shade/components'; +import { useLocation } from '@tryghost/admin-x-framework'; +import type { DunningState } from './use-dunning-state'; +import { dismissLockQuietly, markPayNowReturnRoute } from './use-dunning-state'; +import { PAY_URL } from './dunning-copy'; + +/** + * The owner's "Pay now" CTA into the billing app's payment page. Following it + * counts as seeing the message — the locked takeover stays suppressed for the + * session (quietly, since the navigation repaints anyway) — and the clicked-from + * route is recorded so the post-payment return lands back on it. + */ +export function PayNowButton({ size, state }: { size: 'sm' | 'lg'; state: DunningState }) { + const location = useLocation(); + + return ( + + ); +} diff --git a/apps/admin/src/dunning/stand-down-routes.ts b/apps/admin/src/dunning/stand-down-routes.ts new file mode 100644 index 00000000000..e7f1e44e2ec --- /dev/null +++ b/apps/admin/src/dunning/stand-down-routes.ts @@ -0,0 +1,27 @@ +/** + * Routes where the dunning surfaces stand down so their own content stays + * usable — the takeover on both, the banner on the billing routes only. + */ + +/** + * The billing app's own routes: the dunning UI stands down there so the user + * can actually reach the payment form (and the billing app shows its own + * outstanding-invoice state). + */ +export function isBillingRoute(pathname: string): boolean { + return pathname === '/pro' || pathname.startsWith('/pro/'); +} + +/** + * The settings section holding the content-export tools — also the target of + * the takeover's "Download my data" CTA, so the two stay in step. + */ +export const DATA_EXPORT_ROUTE = '/settings/migration'; + +/** + * The locked overlay stands down on the export tools too, so its + * "Download my data" CTA leads somewhere usable. + */ +export function isDataExportRoute(pathname: string): boolean { + return pathname === DATA_EXPORT_ROUTE || pathname.startsWith(`${DATA_EXPORT_ROUTE}/`); +} diff --git a/apps/admin/src/dunning/use-blocking-modal.test.ts b/apps/admin/src/dunning/use-blocking-modal.test.ts new file mode 100644 index 00000000000..2085c2c1ed1 --- /dev/null +++ b/apps/admin/src/dunning/use-blocking-modal.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useBlockingModal } from './use-blocking-modal'; + +describe('useBlockingModal', () => { + const added: Element[] = []; + + const addModalMarker = (build: (element: HTMLDivElement) => void): HTMLDivElement => { + const element = document.createElement('div'); + build(element); + document.body.appendChild(element); + added.push(element); + return element; + }; + + afterEach(() => { + added.splice(0).forEach((element) => element.remove()); + document.body.style.pointerEvents = ''; + }); + + test('reports no blocking modal on a clean page', () => { + const { result } = renderHook(() => useBlockingModal(true)); + + expect(result.current).toBe(false); + }); + + test('reports a legacy Settings modal until its backdrop is removed', async () => { + const backdrop = addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + backdrop.remove(); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + test.each(['dialog', 'alertdialog'])( + 'reports an open %s until it leaves the open state', + async (role) => { + const dialog = addModalMarker((element) => { + element.setAttribute('role', role); + element.setAttribute('data-state', 'open'); + }); + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + dialog.setAttribute('data-state', 'closed'); + + await waitFor(() => expect(result.current).toBe(false)); + }, + ); + + test('waits for the Radix pointer lock to release after the dialog closes', async () => { + document.body.style.pointerEvents = 'none'; + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + document.body.style.pointerEvents = ''; + + await waitFor(() => expect(result.current).toBe(false)); + }); + + test('notifies every consumer of the shared observer', async () => { + const first = renderHook(() => useBlockingModal(true)); + const second = renderHook(() => useBlockingModal(true)); + + addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + + await waitFor(() => expect(first.result.current).toBe(true)); + await waitFor(() => expect(second.result.current).toBe(true)); + }); + + test('ignores every marker while disabled', () => { + addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + document.body.style.pointerEvents = 'none'; + + const { result } = renderHook(() => useBlockingModal(false)); + + expect(result.current).toBe(false); + }); +}); diff --git a/apps/admin/src/dunning/use-blocking-modal.ts b/apps/admin/src/dunning/use-blocking-modal.ts new file mode 100644 index 00000000000..da980237511 --- /dev/null +++ b/apps/admin/src/dunning/use-blocking-modal.ts @@ -0,0 +1,51 @@ +import { useSyncExternalStore } from 'react'; + +// Match the Shade and legacy modal markers used by Settings. The dunning +// takeover itself has neither marker, so it cannot suppress itself. +const MODAL_SELECTOR = + '#modal-backdrop, :is([role="dialog"], [role="alertdialog"])[data-state="open"]'; + +function hasBlockingModal(): boolean { + // Radix retains its pointer lock through a dialog's exit animation. Wait for + // that lock to release as well as for the dialog to close. + return ( + document.body.style.pointerEvents === 'none' || Boolean(document.querySelector(MODAL_SELECTOR)) + ); +} + +const listeners = new Set<() => void>(); +let observer: MutationObserver | null = null; + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + if (!observer) { + observer = new MutationObserver(() => { + listeners.forEach((notify) => notify()); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-state', 'role', 'id', 'style'], + }); + } + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = null; + } + }; +} + +const subscribeDisabled = () => () => {}; +const readDisabled = () => false; + +/** Share one observer across the layout, banner and overlay, only while locked. */ +export function useBlockingModal(enabled: boolean): boolean { + return useSyncExternalStore( + enabled ? subscribe : subscribeDisabled, + enabled ? hasBlockingModal : readDisabled, + ); +} diff --git a/apps/admin/src/dunning/use-dunning-lock-takeover.ts b/apps/admin/src/dunning/use-dunning-lock-takeover.ts new file mode 100644 index 00000000000..a2e337c6074 --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-lock-takeover.ts @@ -0,0 +1,31 @@ +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { useLocation } from '@tryghost/admin-x-framework'; +import { useDunningState } from './use-dunning-state'; +import { isBillingRoute, isDataExportRoute } from './stand-down-routes'; +import { useBlockingModal } from './use-blocking-modal'; + +/** + * Whether the dunning locked takeover is in effect for the current route: + * the overlay is showing and the surrounding chrome (sidebar) should read as + * disabled. Stands down on the billing and export routes so their content + * stays usable, and once the user has dismissed the takeover — the urgent + * warning banner carries the message from there. An existing modal keeps its + * focus and pointer ownership until it closes, then the takeover can appear. + */ +export function useDunningLockTakeover(): boolean { + const { data: currentUser } = useCurrentUser(); + const state = useDunningState(); + const location = useLocation(); + + const shouldTakeOver = Boolean( + state && + state.phase === 'locked' && + !state.lockDismissed && + currentUser && + !isBillingRoute(location.pathname) && + !isDataExportRoute(location.pathname), + ); + const modalOpen = useBlockingModal(shouldTakeOver); + + return shouldTakeOver && !modalOpen; +} diff --git a/apps/admin/src/dunning/use-dunning-state.test.ts b/apps/admin/src/dunning/use-dunning-state.test.ts new file mode 100644 index 00000000000..1c04a4298fc --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-state.test.ts @@ -0,0 +1,308 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DAY_MS, browseConfigWithDunning, dunningWindow } from '@test-utils/fixtures/dunning'; + +import { dismissLock, markPayNowReturnRoute, useDunningState } from './use-dunning-state'; + +const { mockUseBrowseConfig, mockUseSubscriptionStatus } = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), + mockUseSubscriptionStatus: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +vi.mock('@/ember-bridge', () => ({ + useSubscriptionStatus: mockUseSubscriptionStatus, +})); + +const NOW = new Date('2026-09-10T12:00:00Z'); + +describe('useDunningState', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + window.sessionStorage.clear(); + mockUseSubscriptionStatus.mockReturnValue(null); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('returns null without a dunning block', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(undefined)); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('returns null while the dunningWarnings flag is off', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2), {})); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('returns null when the block is inactive', () => { + mockUseBrowseConfig.mockReturnValue( + browseConfigWithDunning({ ...dunningWindow(2), active: false }), + ); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test.each([ + ['unparseable dates', { active: true, paymentFailedAt: 'nope', suspendsAt: 'also nope' }], + ['missing dates', { active: true }], + // A misconfigured host may send a truthy non-boolean; only `true` activates + ['a non-boolean active value', { ...dunningWindow(2), active: 'false' }], + [ + 'an inverted window', + { + active: true, + paymentFailedAt: NOW.toISOString(), + suspendsAt: new Date(NOW.getTime() - DAY_MS).toISOString(), + }, + ], + ])('returns null for %s', (_label, dunning) => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunning)); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('reports the warning phase early in the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: false, daysLeft: 26 }); + }); + + test('escalates to urgent styling past a quarter of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(8))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: true }); + }); + + test('stays in the urgent warning phase through the middle of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(14))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: true, daysLeft: 14 }); + }); + + test('locks for the last quarter of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', daysLeft: 6 }); + }); + + test('stays locked with zero days left when suspendsAt has passed', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(30))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', daysLeft: 0 }); + }); + + test('clears when the billing app reports an active subscription', () => { + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'active' } }); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('does not clear for a subscription that is still past_due', () => { + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'past_due' } }); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).not.toBeNull(); + }); + + test.each([-60, 60])( + 'clears the settled failure with the client clock skewed by %i days', + (skewDays) => { + const dunning = dunningWindow(8, { now: NOW.getTime() }); + vi.setSystemTime(new Date(NOW.getTime() + skewDays * DAY_MS)); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunning)); + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'past_due' } }); + // Written by the Ember billing service on the post-payment return. + window.sessionStorage.setItem('ghost-dunning-payment-settled-for', dunning.paymentFailedAt); + + const { result, rerender } = renderHook(() => useDunningState()); + expect(result.current).toBeNull(); + + // A subsequent failure re-arms even when the browser clock is far ahead. + mockUseBrowseConfig.mockReturnValue( + browseConfigWithDunning(dunningWindow(2, { now: NOW.getTime() })), + ); + rerender(); + expect(result.current).not.toBeNull(); + }, + ); + + test('does not suppress a different failure even if its timestamp is older', () => { + window.sessionStorage.setItem( + 'ghost-dunning-payment-settled-for', + dunningWindow(2).paymentFailedAt, + ); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked' }); + }); + + test('ignores the old browser-clock settlement marker', () => { + window.sessionStorage.setItem( + 'ghost-dunning-payment-settled-at', + new Date(NOW.getTime() + 60 * DAY_MS).toISOString(), + ); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning' }); + }); + + test('records a lock dismissal for the current episode', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: false }); + + act(() => { + dismissLock(result.current!); + }); + + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: true }); + }); + + test('a new payment failure resets the lock dismissal', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result, rerender } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + expect(result.current).toMatchObject({ lockDismissed: true }); + + // A later episode carries a different paymentFailedAt. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(23))); + rerender(); + + expect(result.current).toMatchObject({ lockDismissed: false }); + }); + + test('ticks the countdown down while dunning is in effect', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const { result } = renderHook(() => useDunningState()); + expect(result.current).toMatchObject({ daysLeft: 26 }); + + act(() => { + vi.advanceTimersByTime(DAY_MS + 60_000); + }); + + expect(result.current).toMatchObject({ daysLeft: 25 }); + }); + + test('shares one tick across every consumer of the hook', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const first = renderHook(() => useDunningState()); + const second = renderHook(() => useDunningState()); + expect(vi.getTimerCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(DAY_MS + 60_000); + }); + + // Both instances read the same clock, so phase boundaries flip together. + expect(first.result.current?.daysLeft).toBe(25); + expect(second.result.current?.daysLeft).toBe(25); + + first.unmount(); + expect(vi.getTimerCount()).toBe(1); + second.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + test('installs no periodic tick when there is nothing to derive', () => { + // The hook mounts in the admin layout on every page: without dunning in + // effect a tick would re-render every session each minute for nothing. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2), {})); + + renderHook(() => useDunningState()); + + expect(vi.getTimerCount()).toBe(0); + }); + + describe('when sessionStorage is unavailable', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('still reports state, with nothing read as settled or dismissed', () => { + // A window no other test dismisses, so the module-level in-memory + // fallback from earlier dismissals cannot match this episode. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(24))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: false }); + }); + + test('dismissing the lock still works for the lifetime of the page', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(21))); + + const { result } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + + // The in-memory fallback carries the dismissal. + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: true }); + }); + + test('recording the Pay now return route swallows the failure', () => { + // Without storage the post-payment return falls back to the overview. + expect(() => markPayNowReturnRoute('/analytics')).not.toThrow(); + }); + }); + + test('keeps reporting state after a dismissal so the warning banner stays up', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + + expect(result.current).toMatchObject({ phase: 'locked', urgent: true, lockDismissed: true }); + }); +}); diff --git a/apps/admin/src/dunning/use-dunning-state.ts b/apps/admin/src/dunning/use-dunning-state.ts new file mode 100644 index 00000000000..8049682024f --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-state.ts @@ -0,0 +1,204 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { + DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + parseDunningConfig, +} from '@tryghost/admin-x-framework/api/dunning'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; +import { useSubscriptionStatus } from '@/ember-bridge'; +import { readSharedNow, retainMinuteTicker, subscribeSharedNow } from './minute-ticker'; + +export type DunningPhase = 'warning' | 'locked'; + +export interface DunningState { + phase: DunningPhase; + /** Whole days until suspension, never negative. */ + daysLeft: number; + /** Escalated styling within the warning phase (last stretch before the lock). */ + urgent: boolean; + /** The locked takeover was stood down this session (closed, or a Pay now CTA followed). */ + lockDismissed: boolean; + paymentFailedAt: Date; + suspendsAt: Date; +} + +/** + * The locked overlay takes over once this fraction of the + * paymentFailedAt -> suspendsAt window has elapsed. Proportional rather than + * an absolute day count so the same component fits any host's window length. + */ +const LOCK_AT_FRACTION = 0.75; + +/** The warning banner escalates its styling past this fraction of the window. */ +const URGENT_AT_FRACTION = 0.25; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const LOCK_DISMISSED_KEY = 'ghost-dunning-lock-dismissed-for'; + +const dunningStoreListeners = new Set<() => void>(); + +// Fallback when sessionStorage is unavailable, so dismissing still works for +// the lifetime of the page. +let inMemoryLockDismissedFor: string | null = null; + +function readLockDismissedFor(): string | null { + try { + return window.sessionStorage.getItem(LOCK_DISMISSED_KEY); + } catch { + return inMemoryLockDismissedFor; + } +} + +/** + * One subscription serves every dunning snapshot: `dismissLock` is the only + * writer that notifies. The payment-settled snapshot is written by the Ember + * billing service without a notification on purpose — the return navigation + * triggers the render that picks it up (useSyncExternalStore re-reads its + * snapshot on every render). + */ +function subscribeDunningStore(listener: () => void): () => void { + dunningStoreListeners.add(listener); + return () => { + dunningStoreListeners.delete(listener); + }; +} + +function writeLockDismissedFor(state: DunningState): void { + inMemoryLockDismissedFor = state.paymentFailedAt.toISOString(); + try { + window.sessionStorage.setItem(LOCK_DISMISSED_KEY, inMemoryLockDismissedFor); + } catch { + // Storage can be unavailable; the in-memory fallback covers this page. + } +} + +/** + * Stands the locked takeover down for this session, swapping it out for the + * urgent warning banner right away. Keyed by `paymentFailedAt` and scoped to + * the session, so a later session — or a new payment failure — brings the + * takeover back. + */ +export function dismissLock(state: DunningState): void { + writeLockDismissedFor(state); + dunningStoreListeners.forEach((listener) => listener()); +} + +/** + * The "Pay now" variant of dismissLock: records the suppression without + * forcing an immediate re-render. The click is followed by navigation to the + * billing route, where the takeover and banner stand down anyway — an eager + * swap would flash the underlying screen with the warning banner for a frame + * before the route change lands. useSyncExternalStore re-reads its snapshot + * on every render, so the route change itself picks the stored value up. + */ +export function dismissLockQuietly(state: DunningState): void { + writeLockDismissedFor(state); +} + +/** + * Records the route a "Pay now" CTA was clicked on. The billing app's + * post-payment `previousPage` request is resolved from this on the Ember + * side — an explicit route rather than history.back(), so a deep link (or a + * tab whose history points outside Admin) falls back to the billing overview + * instead of leaving Ghost. + */ +export function markPayNowReturnRoute(route: string): void { + try { + window.sessionStorage.setItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, route); + } catch { + // Without storage the post-payment return falls back to the overview. + } +} + +/** + * Written by the Ember billing service when the billing app reports a + * completed payment (its `previousPage` return request only follows one). + * Read here so the warnings stand down the moment the user lands back, + * rather than lingering until the webhook-settled subscription state + * arrives seconds later. The return navigation triggers the render that + * picks the value up — no notification needed. + */ +function readPaymentSettledFor(): string | null { + try { + return window.sessionStorage.getItem(DUNNING_PAYMENT_SETTLED_STORAGE_KEY); + } catch { + return null; + } +} + +/** + * Payment-failure (dunning) state for the site's hosting subscription, derived + * from host-provided config (`hostSettings.billing.dunning`). + * + * Returns `null` when there is nothing to show: no dunning block, a malformed + * one (the /config/ response isn't runtime-validated, so guard against a + * misconfigured host config), or a live subscription that has become active + * (the billing app reports payment over the Ember bridge before the server + * config catches up). + * + * The phase is computed client-side from the position within the + * paymentFailedAt -> suspendsAt window so no config rewrite is needed for the + * warning -> locked transition. + */ +export function useDunningState(): DunningState | null { + const { data: config } = useBrowseConfig(); + const subscriptionStatus = useSubscriptionStatus(); + // Labs-gated while in development: hosts can ship and test the config + // pipeline without end users seeing any dunning UI. + const dunningWarningsEnabled = useFeatureFlag('dunningWarnings'); + + const dunning = dunningWarningsEnabled + ? parseDunningConfig(config?.config.hostSettings?.billing?.dunning) + : null; + const dunningInEffect = Boolean(dunning); + + // Re-derive the phase and countdown periodically; transitions land on date + // boundaries, so a coarse tick keeps them fresh without churn. The tick is + // shared so every consumer of this hook reads the same `now` and phase + // boundaries flip all surfaces together — and it is only retained while + // dunning is in effect, since this hook mounts in the admin layout and an + // unconditional interval would re-render every session each minute. + const now = useSyncExternalStore(subscribeSharedNow, readSharedNow); + useEffect(() => { + if (!dunningInEffect) { + return; + } + return retainMinuteTicker(); + }, [dunningInEffect]); + + const lockDismissedFor = useSyncExternalStore(subscribeDunningStore, readLockDismissedFor); + const paymentSettledFor = useSyncExternalStore(subscribeDunningStore, readPaymentSettledFor); + + if (!dunning) { + return null; + } + const { paymentFailedAt, suspendsAt } = dunning; + + // The billing app reported a live, active subscription: payment went + // through, only the restart-scoped config is stale. + if (subscriptionStatus?.subscription?.status === 'active') { + return null; + } + + // Only suppress the failure that was settled this session. Comparing its + // server-provided identity avoids relying on the browser clock and lets a + // different paymentFailedAt re-arm the warnings. + if (paymentSettledFor === paymentFailedAt.toISOString()) { + return null; + } + + const windowMs = suspendsAt.getTime() - paymentFailedAt.getTime(); + const elapsedFraction = (now - paymentFailedAt.getTime()) / windowMs; + const daysLeft = Math.max(0, Math.ceil((suspendsAt.getTime() - now) / DAY_MS)); + + return { + phase: elapsedFraction >= LOCK_AT_FRACTION ? 'locked' : 'warning', + daysLeft, + urgent: elapsedFraction >= URGENT_AT_FRACTION, + lockDismissed: lockDismissedFor === paymentFailedAt.toISOString(), + paymentFailedAt, + suspendsAt, + }; +} diff --git a/apps/admin/src/dunning/use-owner-user.ts b/apps/admin/src/dunning/use-owner-user.ts new file mode 100644 index 00000000000..3923dee6a50 --- /dev/null +++ b/apps/admin/src/dunning/use-owner-user.ts @@ -0,0 +1,22 @@ +import { isOwnerUser, useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; + +/** + * Resolves the site owner, for the staff-facing owner card on the locked + * takeover. + * + * Queries the Owner directly, so their details are available even when they + * fall beyond the first page of staff. Returns `undefined` while loading or + * when the current user's role cannot browse users (e.g. contributors) — callers + * degrade to copy without the owner's details. + * + * Pass `enabled: false` to skip the request entirely: the consuming + * components mount on every Admin page, so the user list must only be + * fetched in the rare sessions that actually show the owner. + */ +export function useOwnerUser({ enabled = true }: { enabled?: boolean } = {}): User | undefined { + const { data } = useBrowseUsers({ + enabled, + searchParams: { filter: "roles.name:'Owner'", limit: '1', include: 'roles' }, + }); + return data?.users.find(isOwnerUser); +} diff --git a/apps/admin/src/layout/admin-layout.tsx b/apps/admin/src/layout/admin-layout.tsx index 3b828904561..b44ebf11ea4 100644 --- a/apps/admin/src/layout/admin-layout.tsx +++ b/apps/admin/src/layout/admin-layout.tsx @@ -8,6 +8,7 @@ import { cn } from '@tryghost/shade/utils'; import AppSidebar from './app-sidebar'; import { MobileNavBar } from './app-sidebar/mobile-nav-bar'; import { ContributorUserMenu } from './app-sidebar/user-menu'; +import { DunningBanner, DunningOverlay, useDunningLockTakeover } from '@/dunning'; const networkPageChrome = { contentClassName: 'max-w-(--content-width)', @@ -52,18 +53,54 @@ interface AdminLayoutProps { export function AdminLayout({ children }: AdminLayoutProps) { const { data: currentUser } = useCurrentUser(); const sidebarVisible = useAdminSidebarVisibility(); + const dunningLocked = useDunningLockTakeover(); const isContributor = currentUser && isContributorUser(currentUser); + // The dunning takeover is positioned against the scrollable inset, so the + // inset must not scroll (and must sit at the top) while the takeover is up — + // otherwise the covered page scrolls back into view from underneath it + const insetRef = React.useRef(null); + React.useEffect(() => { + if (dunningLocked) { + insetRef.current?.scrollTo?.(0, 0); + } + }, [dunningLocked]); + + // The covered regions become `inert` while the takeover is up: aria-modal is + // only a semantic hint, so without this the covered page stays reachable by + // keyboard and assistive technology. Applied through refs because React 18 + // has no first-class inert prop. Whichever refs the active layout branch + // doesn't render stay null and are skipped. + // + // A layout effect on purpose: layout effects run before passive-effect + // cleanups, so on dismissal inert is cleared before the overlay's cleanup + // restores focus — focus() on a still-inert element is a silent no-op. + const sidebarRef = React.useRef(null); + const mainRef = React.useRef(null); + const contributorMenuRef = React.useRef(null); + React.useLayoutEffect(() => { + for (const region of [sidebarRef.current, mainRef.current, contributorMenuRef.current]) { + if (region) { + region.inert = dunningLocked; + } + } + }, [dunningLocked]); + // Contributors get a floating profile menu instead of the full sidebar if (isContributor) { return (
-
+
+
{children}
-
+
+
); } @@ -77,16 +114,32 @@ export function AdminLayout({ children }: AdminLayoutProps) { open={!!currentUser && sidebarVisible} style={sidebarVisible ? ({ '--sidebar-width': '316px' } as React.CSSProperties) : undefined} > - {sidebarVisible && } + {sidebarVisible && ( + + )} -
+ +
{children}
- + {/* The mobile nav sits outside the takeover's cover (fixed, above the + inset) and its sheet opens in a portal, so it unmounts entirely + rather than relying on inert */} + {!dunningLocked && } + ); diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar.tsx index 837935a1947..061539eba5d 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar.tsx @@ -6,14 +6,16 @@ import AppSidebarHeader from './app-sidebar-header'; import AppSidebarFooter from './app-sidebar-footer'; import AppSidebarContent from './app-sidebar-content'; -function AppSidebar({ ...props }: React.ComponentProps) { - return ( - - - - - - ); -} +const AppSidebar = React.forwardRef>( + function AppSidebar({ ...props }, ref) { + return ( + + + + + + ); + }, +); export default AppSidebar; diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index fbde0e86de0..1ba6a23dd31 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -145,6 +145,12 @@ const features: Feature[] = [ 'Let AI agents pay for access to paid-members markdown (.md) URLs via Stripe Machine Payments Protocol', flag: 'machinePayments', }, + { + title: 'Dunning warnings', + description: + 'Show payment-failure warnings in Admin, driven by the hosting provider via hostSettings.billing.dunning', + flag: 'dunningWarnings', + }, ]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/test-utils/fixtures/dunning.ts b/apps/admin/test-utils/fixtures/dunning.ts new file mode 100644 index 00000000000..d9b21cf1fee --- /dev/null +++ b/apps/admin/test-utils/fixtures/dunning.ts @@ -0,0 +1,37 @@ +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A host-config dunning block `elapsedDays` into a `windowDays`-day + * paymentFailedAt -> suspendsAt window. Anchored on `now` (defaults to the + * current clock, so tests under fake timers get their frozen time). + */ +export function dunningWindow( + elapsedDays: number, + { windowDays = 28, now = Date.now() }: { windowDays?: number; now?: number } = {}, +) { + return { + active: true, + paymentFailedAt: new Date(now - elapsedDays * DAY_MS).toISOString(), + suspendsAt: new Date(now + (windowDays - elapsedDays) * DAY_MS).toISOString(), + }; +} + +/** + * A mocked `useBrowseConfig` return for a host carrying the given dunning + * block, with the dunningWarnings flag on unless overridden via `labs`. + */ +export function browseConfigWithDunning( + dunning?: unknown, + labs: Record = { dunningWarnings: true }, +) { + return { + data: { + config: { + labs, + hostSettings: { + billing: { enabled: true, url: 'https://billing.example.com', dunning }, + }, + }, + }, + }; +} diff --git a/apps/ember-admin/app/components/gh-billing-iframe.js b/apps/ember-admin/app/components/gh-billing-iframe.js index ad6d0743300..52dad609414 100644 --- a/apps/ember-admin/app/components/gh-billing-iframe.js +++ b/apps/ember-admin/app/components/gh-billing-iframe.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import {action} from '@ember/object'; import {htmlSafe} from '@ember/template'; import {inject} from 'ghost-admin/decorators/inject'; +import {parseDunningConfig} from '@tryghost/admin-x-framework/api/dunning'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; @@ -169,8 +170,17 @@ export default class GhBillingIframe extends Component { this.config.hostSettings.forceUpgrade = false; } - // Detect if the current subscription is in a grace state and render a notification - if (data.subscription.status === 'past_due' || data.subscription.status === 'unpaid') { + // Detect if the current subscription is in a grace state and render a notification. + // The dunningWarnings flag replaces this alert with the React admin's own + // payment-failure warning states, so it stands down while the flag is on — + // but only when React can use the host's dunning block. Missing or + // malformed config must leave the existing overdue alert available. + const dunningWarningsActive = this.feature.dunningWarnings + && parseDunningConfig(this.config.hostSettings?.billing?.dunning) !== null; + if ( + (data.subscription.status === 'past_due' || data.subscription.status === 'unpaid') + && !dunningWarningsActive + ) { // This notification needs to be shown to every user regardless their permissions to see billing this.notifications.showAlert(htmlSafe(`Your billing details need updating. The site owner must update payment information to avoid suspension.`), {type: 'error', key: 'billing.overdue'}); } else { diff --git a/apps/ember-admin/app/services/billing.js b/apps/ember-admin/app/services/billing.js index 56446d55279..7eeebbed6fa 100644 --- a/apps/ember-admin/app/services/billing.js +++ b/apps/ember-admin/app/services/billing.js @@ -1,5 +1,10 @@ import * as Sentry from '@sentry/ember'; import Service, {inject as service} from '@ember/service'; +import { + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, + parseDunningConfig +} from '@tryghost/admin-x-framework/api/dunning'; import {inject} from 'ghost-admin/decorators/inject'; import {tracked} from '@glimmer/tracking'; @@ -14,6 +19,11 @@ const NEWSLETTERS_DESTINATION = 'newsletters'; const NEWSLETTERS_ROUTE_WITH_AUTOMATIONS = '/settings/emails'; const NEWSLETTERS_ROUTE = '/settings/newsletters'; +// Not a route: asks Admin to go back to the page the user was on before the +// billing screen. Sent by the payment page's return flow after a successful +// payment, so a "Pay now" click from e.g. the editor lands back in the editor. +const PREVIOUS_PAGE_DESTINATION = 'previousPage'; + // Approved destinations the Billing app may request Ghost Admin to navigate to, // mapped to the Admin route that owns them. Ghost Admin owns this mapping — the // Billing app never sends raw URLs or routes. A null-prototype, frozen object is @@ -132,6 +142,33 @@ export default class BillingService extends Service { return; } + if (destination === PREVIOUS_PAGE_DESTINATION) { + this._markDunningPaymentSettled(); + + // Still only semantic navigation: no route or URL crosses the + // iframe boundary — the Admin side records where "Pay now" was + // clicked. Without a recorded route (a direct deep link to the + // payment page) the billing overview is the fallback; never + // history.back(), whose previous entry can lie outside Admin. + const returnRoute = this._takePayNowReturnRoute(); + + if (returnRoute) { + // The recorded route is same-origin Admin data, but it may no + // longer resolve (e.g. a page behind a since-disabled flag) — + // transitionTo throws on an unrecognized URL rather than + // navigating, so fall back to the overview instead of dying + try { + this.router.transitionTo(returnRoute); + return; + } catch (e) { + // fall through to the billing overview + } + } + + this.router.transitionTo('pro'); + return; + } + const route = this._resolveAdminDestinationRoute(destination); if (!route) { @@ -141,6 +178,44 @@ export default class BillingService extends Service { this.router.transitionTo(route); } + _markDunningPaymentSettled() { + if (!this.feature.dunningWarnings) { + return; + } + + const dunning = parseDunningConfig(this.config.hostSettings?.billing?.dunning); + if (!dunning) { + return; + } + + // Identify the failure from the boot config, not the browser's clock. + // A later failure must not inherit this payment's suppression. + try { + window.sessionStorage.setItem( + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + dunning.paymentFailedAt.toISOString() + ); + } catch (e) { + // Without storage the warnings stand down when the refreshed + // subscription state arrives instead + } + } + + // Consumes the route recorded by a dunning "Pay now" CTA (written by + // apps/admin/src/dunning) — once per payment return. + _takePayNowReturnRoute() { + try { + const route = window.sessionStorage.getItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY); + window.sessionStorage.removeItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY); + // Absolute Admin paths only: exactly one leading slash — '//host' + // is a protocol-relative URL, not a route + return route && route.startsWith('/') && !route.startsWith('//') ? route : null; + } catch (e) { + // Storage can be unavailable; fall back to the billing overview + return null; + } + } + _resolveAdminDestinationRoute(destination) { if (destination === NEWSLETTERS_DESTINATION) { return this.feature.automations ? NEWSLETTERS_ROUTE_WITH_AUTOMATIONS : NEWSLETTERS_ROUTE; diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 862de2527c3..ccfa42b589e 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -105,6 +105,7 @@ export default class FeatureService extends Service { @feature('membersCustomFields') membersCustomFields; @feature('editorReact') editorReact; @feature('improveSendingUI') improveSendingUI; + @feature('dunningWarnings') dunningWarnings; _user = null; _featureFlagOverridesRevision = 0; diff --git a/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js b/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js index 64110b5f1cb..34f2ee1b1cd 100644 --- a/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js +++ b/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js @@ -195,6 +195,77 @@ describe('Integration: Component: gh-billing-iframe', function () { expect(transitionTo.calledOnceWithExactly('/settings/newsletters')).to.be.true; }); + it('shows the overdue billing alert for a delinquent subscription', async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + + await render(hbs``); + + await postBillingMessage({subscription: {status: 'past_due'}}); + + expect(showAlert.calledOnce).to.be.true; + expect(showAlert.firstCall.args[1]).to.include({type: 'error', key: 'billing.overdue'}); + }); + + it('stands the overdue alert down when the dunningWarnings flag is enabled', async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + const closeAlerts = sinon.stub(notifications, 'closeAlerts'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + const feature = this.owner.lookup('service:feature'); + sinon.stub(feature, 'dunningWarnings').get(() => true); + const config = this.owner.lookup('config:main'); + config.hostSettings = { + ...config.hostSettings, + billing: { + ...config.hostSettings?.billing, + dunning: {active: true, paymentFailedAt: '2026-08-26', suspendsAt: '2026-09-23'} + } + }; + + await render(hbs``); + + await postBillingMessage({subscription: {status: 'past_due'}}); + + expect(showAlert.called).to.be.false; + expect(closeAlerts.calledWith('billing.overdue')).to.be.true; + }); + + for (const [label, dunning] of [ + ['missing config', undefined], + ['missing dates', {active: true}], + ['invalid dates', {active: true, paymentFailedAt: 'invalid', suspendsAt: '2026-09-29'}], + ['non-string dates', {active: true, paymentFailedAt: 1, suspendsAt: '2026-09-29'}], + ['an inverted window', {active: true, paymentFailedAt: '2026-09-29', suspendsAt: '2026-09-01'}], + ['an empty window', {active: true, paymentFailedAt: '2026-09-01', suspendsAt: '2026-09-01'}] + ]) { + for (const status of ['past_due', 'unpaid']) { + it(`keeps the overdue alert for ${status} with ${label}`, async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + const closeAlerts = sinon.stub(notifications, 'closeAlerts'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + sinon.stub(this.owner.lookup('service:feature'), 'dunningWarnings').get(() => true); + const config = this.owner.lookup('config:main'); + config.hostSettings = { + ...config.hostSettings, + billing: {...config.hostSettings?.billing, dunning} + }; + + await render(hbs``); + await postBillingMessage({subscription: {status}}); + + expect(showAlert.calledOnce).to.be.true; + expect(showAlert.firstCall.args[1]).to.include({type: 'error', key: 'billing.overdue'}); + expect(closeAlerts.calledWith('billing.overdue')).to.be.false; + }); + } + } + it('ignores a navigateToAdmin message with an unknown destination', async function () { const router = this.owner.lookup('service:router'); const transitionTo = sinon.stub(router, 'transitionTo'); diff --git a/apps/ember-admin/tests/unit/services/billing-test.js b/apps/ember-admin/tests/unit/services/billing-test.js index 95c35a06fd7..e6a4bf9a3fd 100644 --- a/apps/ember-admin/tests/unit/services/billing-test.js +++ b/apps/ember-admin/tests/unit/services/billing-test.js @@ -34,6 +34,8 @@ describe('Unit: Service: billing', function () { billingService?.clearBillingAppLoadMonitor(); billingService = null; sinon.restore(); + window.sessionStorage.removeItem('ghost-dunning-pay-return-route'); + window.sessionStorage.removeItem('ghost-dunning-payment-settled-for'); }); it('retries loading the billing app before reporting', async function () { @@ -434,6 +436,108 @@ describe('Unit: Service: billing', function () { expect(transitionTo.calledOnceWithExactly('/settings/newsletters')).to.be.true; }); + it('returns to the recorded route for the previousPage destination', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '/editor/post/abc123'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('/editor/post/abc123')).to.be.true; + // consumed: a later return without a fresh "Pay now" click must not reuse it + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + for (const now of ['2026-07-01T00:00:00Z', '2026-11-01T00:00:00Z']) { + it(`records the settled failure with the client clock at ${now}`, function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + sinon.stub(service.router, 'transitionTo'); + sinon.stub(service.feature, 'dunningWarnings').get(() => true); + sinon.useFakeTimers({now: new Date(now), toFake: ['Date']}); + const config = this.owner.lookup('config:main'); + config.hostSettings.billing.dunning = { + active: true, + paymentFailedAt: '2026-09-01T04:00:00+04:00', + suspendsAt: '2026-09-29T00:00:00Z' + }; + + service.navigateToAdminDestination('previousPage'); + + expect(window.sessionStorage.getItem('ghost-dunning-payment-settled-for')) + .to.equal('2026-09-01T00:00:00.000Z'); + }); + } + + for (const [label, enabled, dunning] of [ + ['flag disabled', false, {active: true, paymentFailedAt: '2026-09-01', suspendsAt: '2026-09-29'}], + ['missing config', true, undefined], + ['malformed config', true, {active: true, paymentFailedAt: 'invalid', suspendsAt: '2026-09-29'}] + ]) { + it(`does not record a settled failure with ${label}`, function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + sinon.stub(service.feature, 'dunningWarnings').get(() => enabled); + this.owner.lookup('config:main').hostSettings.billing.dunning = dunning; + + service.navigateToAdminDestination('previousPage'); + + expect(window.sessionStorage.getItem('ghost-dunning-payment-settled-for')).to.be.null; + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + }); + } + + it('falls back to the billing overview without a recorded return route', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.removeItem('ghost-dunning-pay-return-route'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + }); + + it('ignores a recorded return route that is not an absolute path', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', 'https://evil.example'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + it('ignores a protocol-relative recorded return route', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + // '//host' passes a bare startsWith('/') check but is a URL, not a route + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '//evil.example'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + it('falls back to the billing overview when the recorded route does not resolve', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + transitionTo.withArgs('/behind-a-flag').throws(new Error('UnrecognizedURLError: /behind-a-flag')); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '/behind-a-flag'); + + expect(() => service.navigateToAdminDestination('previousPage')).to.not.throw(); + + expect(transitionTo.calledWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + it('ignores destinations that are not approved keys', function () { const service = this.owner.lookup('service:billing'); billingService = service; diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 68aa0f889de..91eb4dddb17 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -64,6 +64,7 @@ const PRIVATE_FEATURES = [ 'postsListReact', 'membersActivityReact', 'editorReact', + 'dunningWarnings', ]; module.exports.GA_KEYS = [...GA_FEATURES]; From 54c310a119d30fcf8fc03db8e5efb30156fc648a Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 9 Sep 2026 19:30:07 +0200 Subject: [PATCH 14/27] Moved the scheduler idempotency key helper next to the other scheduling helpers ref https://linear.app/ghost/issue/ONC-1983 The automations service was the only consumer of the scheduler's idempotency key support, so the helper that builds the key lived in its folder and hard-coded the `ghost-automations-` prefix. Post scheduling is about to send keys too, and it needs the same recipe with a different namespace. The helper now lives in `adapters/scheduling`, beside `build-signed-job` which is the other half of assembling a job, and takes the namespace as an argument. The automations call site passes `automations`, so the keys it produces are byte-for-byte the same as before and jobs already queued under the old keys still dedupe correctly. This change should not change behaviour. --- .../core/server/adapters/scheduling/README.md | 4 ++ .../get-scheduler-idempotency-key.ts | 33 ++++++++++ .../get-scheduler-idempotency-key.ts | 8 --- .../server/services/automations/service.ts | 5 +- .../get-scheduler-idempotency-key.test.ts | 66 +++++++++++++++++++ .../get-scheduler-idempotency-key.test.ts | 44 ------------- 6 files changed, 106 insertions(+), 54 deletions(-) create mode 100644 ghost/core/core/server/adapters/scheduling/get-scheduler-idempotency-key.ts delete mode 100644 ghost/core/core/server/services/automations/get-scheduler-idempotency-key.ts create mode 100644 ghost/core/test/unit/server/adapters/scheduling/get-scheduler-idempotency-key.test.ts delete mode 100644 ghost/core/test/unit/server/services/automations/get-scheduler-idempotency-key.test.ts diff --git a/ghost/core/core/server/adapters/scheduling/README.md b/ghost/core/core/server/adapters/scheduling/README.md index 573065ac7a6..717ca5124f4 100644 --- a/ghost/core/core/server/adapters/scheduling/README.md +++ b/ghost/core/core/server/adapters/scheduling/README.md @@ -19,6 +19,10 @@ and `unschedule`, and inherit a registry of reschedulers from a job's fire time. - `build-signed-job.ts` — builds an adapter job whose callback URL carries that signed token, from an Admin API path and fire time. +- `get-scheduler-idempotency-key.ts` — `getSchedulerIdempotencyKey`, which + derives the idempotency key a job carries from its consumer namespace, fire + time, and final callback URL, so a persistent queue can recognise a + re-registration of a job it already holds. - `signed-flush-scheduler.ts` — `SignedFlushScheduler`, a flush-queue primitive on top of the two above: arms one job per fire time (deduplicated in memory), skips already-due times in favour of the caller's own recovery diff --git a/ghost/core/core/server/adapters/scheduling/get-scheduler-idempotency-key.ts b/ghost/core/core/server/adapters/scheduling/get-scheduler-idempotency-key.ts new file mode 100644 index 00000000000..66f2ae7b75c --- /dev/null +++ b/ghost/core/core/server/adapters/scheduling/get-scheduler-idempotency-key.ts @@ -0,0 +1,33 @@ +import crypto from 'node:crypto'; + +interface GetSchedulerIdempotencyKeyOptions { + // Consumer name, e.g. `automations`. Keeps keys from different consumers + // visibly apart in the scheduler's table and prevents two consumers from + // ever colliding on the same fire time and URL. + namespace: string; + // Fire time of the job. + date: Readonly; + // Final callback URL, including the signed token. Together with the fire + // time this identifies a job: the same resource at the same time under the + // same signing key always yields the same URL, and therefore the same key. + url: Readonly; +} + +/** + * Builds the idempotency key a scheduling adapter sends alongside a job, so a + * queue with persistent storage can recognise a re-registration of a job it + * already holds instead of creating a duplicate. + * + * The key is a hash rather than the raw inputs so it stays well inside the + * scheduler's 255-character printable-ASCII limit whatever the URL length. + */ +export function getSchedulerIdempotencyKey({ + namespace, + date, + url, +}: GetSchedulerIdempotencyKeyOptions): string { + const hash = crypto.createHash('sha256'); + hash.update(date.toISOString()); + hash.update(url.href); + return `ghost-${namespace}-${hash.digest('hex')}`; +} diff --git a/ghost/core/core/server/services/automations/get-scheduler-idempotency-key.ts b/ghost/core/core/server/services/automations/get-scheduler-idempotency-key.ts deleted file mode 100644 index 10b37573920..00000000000 --- a/ghost/core/core/server/services/automations/get-scheduler-idempotency-key.ts +++ /dev/null @@ -1,8 +0,0 @@ -import crypto from 'node:crypto'; - -export function getSchedulerIdempotencyKey(date: Readonly, url: Readonly): string { - const hash = crypto.createHash('sha256'); - hash.update(date.toISOString()); - hash.update(url.href); - return `ghost-automations-${hash.digest('hex')}`; -} diff --git a/ghost/core/core/server/services/automations/service.ts b/ghost/core/core/server/services/automations/service.ts index c35081c002b..2fcbced6d1c 100644 --- a/ghost/core/core/server/services/automations/service.ts +++ b/ghost/core/core/server/services/automations/service.ts @@ -6,7 +6,7 @@ import type DomainEvents from '@tryghost/domain-events'; import { oneAtATime } from '../../../shared/one-at-a-time'; import { poll } from './poll'; import * as automationsApi from './automations-api'; -import { getSchedulerIdempotencyKey } from './get-scheduler-idempotency-key'; +import { getSchedulerIdempotencyKey } from '../../adapters/scheduling/get-scheduler-idempotency-key'; import { buildSignedJob } from '../../adapters/scheduling/build-signed-job'; import { setImmediate as flushEventLoop } from 'node:timers/promises'; import { SoonestTimer } from '../../lib/soonest-timer'; @@ -86,7 +86,8 @@ export class AutomationsService { path: ['automations', 'poll'], time: schedulerPollTime.getTime(), key, - getIdempotencyKey: (url) => getSchedulerIdempotencyKey(date, url), + getIdempotencyKey: (url) => + getSchedulerIdempotencyKey({ namespace: 'automations', date, url }), }), ); } catch (err) { diff --git a/ghost/core/test/unit/server/adapters/scheduling/get-scheduler-idempotency-key.test.ts b/ghost/core/test/unit/server/adapters/scheduling/get-scheduler-idempotency-key.test.ts new file mode 100644 index 00000000000..570a2e7cb06 --- /dev/null +++ b/ghost/core/test/unit/server/adapters/scheduling/get-scheduler-idempotency-key.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; + +import { getSchedulerIdempotencyKey } from '../../../../../core/server/adapters/scheduling/get-scheduler-idempotency-key'; + +describe('getSchedulerIdempotencyKey', function () { + const namespace = 'automations'; + + it('returns same result for same date and URL', function () { + const date = new Date(); + const url = new URL('https://example.com/path?key=value'); + + const first = getSchedulerIdempotencyKey({ namespace, date, url }); + const second = getSchedulerIdempotencyKey({ namespace, date, url }); + + assert.equal(first, second); + }); + + it('returns different results for different times', function () { + const url = new URL('https://example.com/path?key=value'); + + const first = getSchedulerIdempotencyKey({ namespace, date: new Date(100), url }); + const second = getSchedulerIdempotencyKey({ namespace, date: new Date(200), url }); + + assert.notEqual(first, second); + }); + + it('returns different results for different URLs', function () { + const date = new Date(); + const firstUrl = new URL('https://example.com/path?key=one'); + const secondUrl = new URL('https://example.com/path?key=two'); + + const first = getSchedulerIdempotencyKey({ namespace, date, url: firstUrl }); + const second = getSchedulerIdempotencyKey({ namespace, date, url: secondUrl }); + + assert.notEqual(first, second); + }); + + it('returns different results for different namespaces', function () { + const date = new Date(); + const url = new URL('https://example.com/path?key=value'); + + const first = getSchedulerIdempotencyKey({ namespace: 'automations', date, url }); + const second = getSchedulerIdempotencyKey({ namespace: 'post-scheduling', date, url }); + + assert.notEqual(first, second); + }); + + it('prefixes with the ghost namespace', function () { + const date = new Date(); + const url = new URL('https://example.com/path?key=value'); + + const key = getSchedulerIdempotencyKey({ namespace, date, url }); + + assert(key.startsWith('ghost-automations-')); + }); + + it('stays within the scheduler key limits regardless of URL length', function () { + const date = new Date(); + const url = new URL(`https://example.com/path?token=${'x'.repeat(2000)}`); + + const key = getSchedulerIdempotencyKey({ namespace, date, url }); + + assert(key.length <= 255); + assert.match(key, /^[\x20-\x7e]+$/); + }); +}); diff --git a/ghost/core/test/unit/server/services/automations/get-scheduler-idempotency-key.test.ts b/ghost/core/test/unit/server/services/automations/get-scheduler-idempotency-key.test.ts deleted file mode 100644 index 49ddfa49673..00000000000 --- a/ghost/core/test/unit/server/services/automations/get-scheduler-idempotency-key.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; - -import { getSchedulerIdempotencyKey } from '../../../../../core/server/services/automations/get-scheduler-idempotency-key'; - -describe('getSchedulerIdempotencyKey', function () { - it('returns same result for same date and URL', function () { - const date = new Date(); - const url = new URL('https://example.com/path?key=value'); - - const first = getSchedulerIdempotencyKey(date, url); - const second = getSchedulerIdempotencyKey(date, url); - - assert.equal(first, second); - }); - - it('returns different results for different times', function () { - const url = new URL('https://example.com/path?key=value'); - - const first = getSchedulerIdempotencyKey(new Date(100), url); - const second = getSchedulerIdempotencyKey(new Date(200), url); - - assert.notEqual(first, second); - }); - - it('returns different results for different URLs', function () { - const date = new Date(); - const firstUrl = new URL('https://example.com/path?key=one'); - const secondUrl = new URL('https://example.com/path?key=two'); - - const first = getSchedulerIdempotencyKey(date, firstUrl); - const second = getSchedulerIdempotencyKey(date, secondUrl); - - assert.notEqual(first, second); - }); - - it('prefixes with an automations namespace', function () { - const date = new Date(); - const url = new URL('https://example.com/path?key=value'); - - const key = getSchedulerIdempotencyKey(date, url); - - assert(key.startsWith('ghost-automations-')); - }); -}); From 8547a26cb08cb4a35714bcfdca375535441ddcec Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 9 Sep 2026 19:33:03 +0200 Subject: [PATCH 15/27] Added an idempotency key to scheduled post and page jobs ref https://linear.app/ghost/issue/ONC-1983 Ghost(Pro)'s scheduler keeps its job queue in a database, so every boot rebuild has to replace the job it already holds for each scheduled post: delete by URL, then create. Those two calls are not ordered on the wire. When the create lands first, the delete removes both the old job and the new one, and the post silently never publishes. That is what happened to Hyperallergic on 1 September. The scheduler can already recognise a re-registration if the job carries an idempotency key, which is how the automations poll avoids duplicates. Post and page jobs now send one too, so the boot rebuild can stop deleting in a follow-up once every queued job has a key. The key is a hash of the fire time and the final callback URL, namespaced `post-scheduling`. The URL is the right identity because it already carries the resource ID and a token signed for that fire time under the current signing key: registering the same job again yields the same key, while a reschedule or a key rotation yields a new one. A key based on the post ID alone would dedupe a rescheduled job against the old one that the paired delete is about to remove, which recreates the same race in a new place. This change should not change behaviour: the delete-then-create on boot is untouched, and a persistent queue that already holds a key-less job for a post simply gains a keyed replacement, exactly as it gained a key-less replacement before. --- .../post-scheduling/post-scheduling.ts | 10 ++- .../post-scheduling/post-scheduling.test.js | 87 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/services/post-scheduling/post-scheduling.ts b/ghost/core/core/server/services/post-scheduling/post-scheduling.ts index cc398899e6f..d843b5232a9 100644 --- a/ghost/core/core/server/services/post-scheduling/post-scheduling.ts +++ b/ghost/core/core/server/services/post-scheduling/post-scheduling.ts @@ -3,6 +3,7 @@ import logging from '@tryghost/logging'; import type { SchedulerAdapter, SchedulerJob } from '@tryghost/adapter-base-scheduling'; import type { InternalApiKey, InternalKeys } from '../internal-keys'; import { buildSignedJob } from '../../adapters/scheduling/build-signed-job'; +import { getSchedulerIdempotencyKey } from '../../adapters/scheduling/get-scheduler-idempotency-key'; // CJS-only modules — typed loosely below. models is the Bookshelf registry // without TS declarations; the rest are JS modules without types. @@ -152,15 +153,22 @@ export default class PostScheduling { const publishedAt = event === 'unscheduled' ? model.previous('published_at') : model.get('published_at'); const previousPublishedAt = model.previous('published_at'); + const time = moment(publishedAt).valueOf(); return buildSignedJob({ apiUrl: this.#apiUrl, path: ['schedules', resource, `${model.get('id')}/`], - time: moment(publishedAt).valueOf(), + time, key, extra: { oldTime: previousPublishedAt ? moment(previousPublishedAt).valueOf() : null, }, + // The key identifies one job: the URL already carries the resource + // and a token signed for this fire time under the current signing key, + // so re-registering the same job (e.g. a boot rebuild) yields the same + // key, while a reschedule or key rotation yields a new one. + getIdempotencyKey: (url) => + getSchedulerIdempotencyKey({ namespace: 'post-scheduling', date: new Date(time), url }), }); } } diff --git a/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js index e578691b13b..cfe67c5d87c 100644 --- a/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js +++ b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js @@ -8,6 +8,9 @@ const SchedulingDefault = require('../../../../../core/server/adapters/scheduling/scheduling-default').default; const urlUtils = require('../../../../../core/shared/url-utils').default; const { getSignedAdminToken } = require('../../../../../core/server/adapters/scheduling/utils'); +const { + getSchedulerIdempotencyKey, +} = require('../../../../../core/server/adapters/scheduling/get-scheduler-idempotency-key'); const PostScheduling = require('../../../../../core/server/services/post-scheduling/post-scheduling').default; const nock = require('nock'); @@ -124,6 +127,90 @@ describe('PostScheduling', function () { assert.equal(job.url, callbackUrl); assert.equal(job.extra.httpMethod, 'PUT'); assert.equal(job.extra.oldTime, null); + assert.equal( + job.extra.idempotencyKey, + getSchedulerIdempotencyKey({ + namespace: 'post-scheduling', + date: new Date(job.time), + url: new URL(callbackUrl), + }), + ); + }); + }); + + describe('idempotency key', function () { + // Drives jobs through the boot rebuild rather than the event handlers so + // no listeners pile up on the shared events emitter between tests. + let scheduledPosts = []; + + beforeEach(function () { + sinon.stub(Post, 'findAll').callsFake(({ filter }) => { + return Promise.resolve(filter.includes('type:post') ? scheduledPosts : []); + }); + }); + + function scheduledPost(overrides = {}) { + return Post.forge( + testUtils.DataGenerator.forKnex.createPost({ + id: 4242, + lexical: testUtils.DataGenerator.markdownToLexical('something'), + ...overrides, + }), + ); + } + + async function rebuildAndGetJob({ post, keys = internalKeys }) { + scheduledPosts = [post]; + adapter.schedule.resetHistory(); + const service = new PostScheduling({ + apiUrl: 'http://scheduler.local:1111/', + internalKeys: keys, + adapter, + }); + await service.rescheduleAll(); + sinon.assert.calledOnce(adapter.schedule); + return adapter.schedule.args[0][0]; + } + + it('is the same when the same post is registered again for the same time', async function () { + // Outcome: a persistent queue fed by a boot rebuild sees the second + // registration as the job it already holds, not as a duplicate. + const post = scheduledPost(); + + const first = await rebuildAndGetJob({ post }); + const second = await rebuildAndGetJob({ post }); + + assert(first.extra.idempotencyKey.startsWith('ghost-post-scheduling-')); + assert.equal(first.extra.idempotencyKey, second.extra.idempotencyKey); + }); + + it('changes when the publish time changes', async function () { + // Outcome: a reschedule registers a distinct job rather than deduping + // against the job queued for the old time. + const publishedAt = moment().add(1, 'day').toDate(); + + const first = await rebuildAndGetJob({ post: scheduledPost({ published_at: publishedAt }) }); + const second = await rebuildAndGetJob({ + post: scheduledPost({ published_at: moment(publishedAt).add(1, 'hour').toDate() }), + }); + + assert.notEqual(first.extra.idempotencyKey, second.extra.idempotencyKey); + }); + + it('changes when the signing key changes', async function () { + // Outcome: after a key rotation the re-signed callback is a distinct + // job, so the unschedule of the old URL cannot take the new job with it. + const post = scheduledPost(); + + const first = await rebuildAndGetJob({ post }); + const second = await rebuildAndGetJob({ + post, + keys: new Map([ + ['ghost-scheduler', Promise.resolve({ id: 'rotatedKeyId', secret: 'bbbb' })], + ]), + }); + + assert.notEqual(first.extra.idempotencyKey, second.extra.idempotencyKey); }); }); From dfc51ac25c1dec2e0ac067ad73b4b83372e73ed1 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 9 Sep 2026 19:37:07 +0200 Subject: [PATCH 16/27] Made the scheduled publish callback safe to receive twice ref https://linear.app/ghost/issue/ONC-1983 The schedules endpoint read the post and then edited it with no transaction and no row lock. A scheduler with a persistent queue can hold two jobs for one post and fire both in the same tick, so the two callbacks overlap: both see the post as scheduled, both flip it to published, and both try to create the newsletter email. Today only the unique index on `emails.post_id` stops a second send, and it does so by failing the second request. In production this pattern shows up a couple of times a day. The read and the edit now run inside one transaction with the row locked for update. The second delivery waits for the first to commit, then finds the post is no longer scheduled and takes the existing no-op path: an empty 2xx the scheduler treats as done. The post is published once, the email is created once, and neither request fails. This is needed before the boot rebuild stops deleting existing jobs, because sites that sleep through that release will briefly hold a key-less job and a keyed twin for each pending post. --- .../server/services/posts/post-scheduling.js | 7 +++ .../test/legacy/api/admin/schedules.test.js | 44 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/services/posts/post-scheduling.js b/ghost/core/core/server/services/posts/post-scheduling.js index eb8c9fc1493..72cfec8a093 100644 --- a/ghost/core/core/server/services/posts/post-scheduling.js +++ b/ghost/core/core/server/services/posts/post-scheduling.js @@ -65,6 +65,13 @@ const publishNow = async (resourceType, id, force, options) => { throw err; } + // The read above is filtered to scheduled resources, so a resource that + // another delivery has already published normally surfaces as NotFound. + // Keep an explicit check so the outcome doesn't depend on that filter. + if (preScheduledResource.status !== 'scheduled') { + return NO_OP; + } + const publishedAtMoment = moment(preScheduledResource.published_at); if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) { diff --git a/ghost/core/test/legacy/api/admin/schedules.test.js b/ghost/core/test/legacy/api/admin/schedules.test.js index 33a79b5a329..55f3c164a0e 100644 --- a/ghost/core/test/legacy/api/admin/schedules.test.js +++ b/ghost/core/test/legacy/api/admin/schedules.test.js @@ -100,13 +100,27 @@ describe('Schedules API', function () { }), ); + resources.push( + testUtils.DataGenerator.forKnex.createPost({ + published_by: testUtils.getExistingData().users[0].id, + published_at: moment().subtract(10, 'seconds').toDate(), + status: 'scheduled', + slug: 'sixth', + authors: [ + { + id: testUtils.getExistingData().users[0].id, + }, + ], + }), + ); + const result = await Promise.all( resources.map((post) => { return models.Post.add(post, { context: { internal: true } }); }), ); - assert.equal(result.length, 5); + assert.equal(result.length, 6); }); describe('publish', function () { @@ -206,6 +220,34 @@ describe('Schedules API', function () { .expect(404); }); + it('two overlapping deliveries of the same job publish once', async function () { + // A scheduler with a persistent queue can hold two jobs for one post + // and fire both in the same tick. The first delivery publishes; the + // second must see the post is no longer scheduled and take the no-op + // path, so the post is not published (or emailed) twice. + const url = localUtils.API.getApiQuery(`schedules/posts/${resources[5].id}/?token=${token}`); + + const [first, second] = await Promise.all([ + request.put(url).expect('Content-Type', /json/).expect(200), + request.put(url).expect('Content-Type', /json/).expect(200), + ]); + + const published = [first, second].filter((res) => res.body.posts.length === 1); + const noOps = [first, second].filter((res) => res.body.posts.length === 0); + + assert.equal(published.length, 1, 'exactly one delivery publishes'); + assert.equal(noOps.length, 1, 'the other delivery is a no-op'); + assert.equal(published[0].body.posts[0].status, 'published'); + assertExists(published[0].headers['x-cache-invalidate']); + assert.equal(noOps[0].headers['x-cache-invalidate'], undefined); + + const post = await models.Post.findOne( + { id: resources[5].id }, + { context: { internal: true } }, + ); + assert.equal(post.get('status'), 'published'); + }); + it('a deleted resource is a no-op, not an error', async function () { // A scheduler that can't invalidate its jobs may fire one for a post // that has since been deleted. That should be a 2xx no-op it won't From 1bae24f7e1635e996da152f1351b12abf443ea1b Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Thu, 10 Sep 2026 17:45:23 +0100 Subject: [PATCH 17/27] Fixed scheduled newsletter publishes deadlocking on their own email (#30680) ref https://linear.app/ghost/issue/ONC-1983 The previous commit ran the scheduled publish read and edit inside one transaction with the post row locked for update, so overlapping deliveries would serialise on the database. That lock is applied to every eagerly loaded relation of the post as well, which on MySQL means gap locks on the `emails`, `posts_authors` and similar indexes. Publishing a post with a newsletter then creates the email outside that transaction and hands it to the batch sender straight away, so the insert waits on the locked post until the InnoDB lock wait timeout and the publish fails with the transaction rolled back. Any unrelated post insert in the meantime waits on the same gap locks, which is how the email preview acceptance test timed out in CI once a file earlier in the same worker had left a listening server for the scheduler to ping. Deliveries for one resource are now chained in process instead: the second runs after the first has finished, reads the post, finds it is no longer scheduled and takes the existing no-op path. This covers the production case of two jobs for one post firing in the same tick, and removes the row lock from the publish path entirely. The legacy overlapping-deliveries test now publishes a post with a newsletter and asserts the email is created once, which fails against the row lock. --- .../core/server/services/posts/post-scheduling.js | 4 ++-- ghost/core/test/legacy/api/admin/schedules.test.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ghost/core/core/server/services/posts/post-scheduling.js b/ghost/core/core/server/services/posts/post-scheduling.js index 72cfec8a093..69e084593ac 100644 --- a/ghost/core/core/server/services/posts/post-scheduling.js +++ b/ghost/core/core/server/services/posts/post-scheduling.js @@ -65,8 +65,8 @@ const publishNow = async (resourceType, id, force, options) => { throw err; } - // The read above is filtered to scheduled resources, so a resource that - // another delivery has already published normally surfaces as NotFound. + // The read above is filtered to scheduled resources, so a resource that an + // earlier delivery has already published normally surfaces as NotFound. // Keep an explicit check so the outcome doesn't depend on that filter. if (preScheduledResource.status !== 'scheduled') { return NO_OP; diff --git a/ghost/core/test/legacy/api/admin/schedules.test.js b/ghost/core/test/legacy/api/admin/schedules.test.js index 55f3c164a0e..229aa38761c 100644 --- a/ghost/core/test/legacy/api/admin/schedules.test.js +++ b/ghost/core/test/legacy/api/admin/schedules.test.js @@ -9,6 +9,7 @@ const SchedulingDefault = const models = require('../../../../core/server/models'); const config = require('../../../../core/shared/config'); const testUtils = require('../../../utils'); +const { mockManager } = require('../../../utils/e2e-framework'); const localUtils = require('./utils'); describe('Schedules API', function () { @@ -21,14 +22,18 @@ describe('Schedules API', function () { }); afterAll(function () { + mockManager.restore(); sinon.restore(); }); beforeAll(async function () { await localUtils.startGhost(); + mockManager.mockMailgun(); request = supertest.agent(config.get('url')); + const defaultNewsletter = await models.Newsletter.getDefaultNewsletter(); + resources.push( testUtils.DataGenerator.forKnex.createPost({ published_by: testUtils.getExistingData().users[0].id, @@ -106,6 +111,9 @@ describe('Schedules API', function () { published_at: moment().subtract(10, 'seconds').toDate(), status: 'scheduled', slug: 'sixth', + // Publishing with a newsletter creates the email, which is the path + // that must run exactly once when deliveries overlap + newsletter_id: defaultNewsletter.id, authors: [ { id: testUtils.getExistingData().users[0].id, @@ -246,6 +254,12 @@ describe('Schedules API', function () { { context: { internal: true } }, ); assert.equal(post.get('status'), 'published'); + + const emails = await models.Email.findAll({ + filter: `post_id:'${resources[5].id}'`, + context: { internal: true }, + }); + assert.equal(emails.length, 1, 'the newsletter email is created once'); }); it('a deleted resource is a no-op, not an error', async function () { From aeaf39fd31f050d9a67e80bf8259ec6caee8a9f6 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Thu, 17 Sep 2026 17:03:29 +0200 Subject: [PATCH 18/27] Fixed Admin 7 pill style inconsistencies (#30849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://linear.app/ghost/issue/DES-1535/paper-cuts-from-new-pill-styles Corrects the pill-style inconsistencies found during Admin 7 testing, while preserving the existing appearance when `admin7Pill` is disabled. - Keep form selects and input groups rounded, with pill-shaped header controls and embedded buttons. Preview/Copy actions are 24px tall. - Order Members header actions as Search → More → Filter → New member, apply the shared primary-action spacing on member details, and fix header dropdown icon sizing and spacing. - Use consistent icon-sized controls for the Automation back button and staff profile menu, including the standard outline treatment. Round the staff selector in History. - Add a shared `destructive-ghost` button variant with translucent red hover/pressed backgrounds and replace repeated local destructive-button styling. - Update the PageHeader contract and stories, including a generic “No primary action” example. Remove one CSS-class-only CopyField test; retain clipboard behavior tests. Validation: - Shade: 278 existing tests passed; lint and build passed. - Admin: type checking and lint on changed files passed; MembersActions' 7 existing tests passed. - Visually checked the affected controls in Admin and Storybook, including the flag-off header/button examples. - Repository-wide `pnpm check`: formatting and lint passed. The test phase encountered Core failures (a cron day-of-week assertion and timeouts) and two Admin country-filter timeouts. The Admin file passes independently (11 tests). Stopped the remaining unrelated Koenig run after these failures; the full check is not green. Reported screenshots and reproduction details are in DES-1535. These are visual fixes; no new style-assertion tests were added. --- .../components/automation-header.tsx | 4 +- .../members/components/members-actions.tsx | 4 ++ .../components/members-header-search.tsx | 3 +- .../src/members/detail/member-detail.tsx | 18 ++++---- apps/admin/src/members/members.tsx | 5 ++- .../src/settings/advanced/history-modal.tsx | 10 ++++- .../src/settings/advanced/integrations.tsx | 8 +--- .../advanced/integrations/webhooks-table.tsx | 3 +- .../newsletters/newsletter-detail-modal.tsx | 3 +- .../settings/general/user-detail-modal.tsx | 44 +++++++++++++------ apps/admin/src/settings/general/users.tsx | 3 +- .../edit-recommendation-modal.tsx | 3 +- .../custom-fields/custom-field-modal.tsx | 3 +- .../stripe/stripe-connect-modal.tsx | 3 +- .../membership/tiers/tier-detail-modal.tsx | 7 +-- .../src/components/patterns/page-header.mdx | 19 ++++---- .../patterns/page-header.stories.tsx | 32 +++++++------- .../src/components/patterns/page-header.tsx | 13 ++++-- .../src/components/ui/button.stories.tsx | 22 ++++++++++ apps/shade/src/components/ui/button.tsx | 20 ++++++++- .../src/components/ui/copy-field.stories.tsx | 2 +- apps/shade/src/components/ui/copy-field.tsx | 30 ++++++++----- .../src/components/ui/input-group.stories.tsx | 8 ++-- apps/shade/src/components/ui/input-group.tsx | 13 +++--- .../src/components/ui/select.stories.tsx | 9 ++-- apps/shade/src/components/ui/select.tsx | 6 +-- apps/shade/src/docs/introduction.mdx | 4 +- .../unit/components/ui/copy-field.test.tsx | 44 ------------------- 28 files changed, 185 insertions(+), 158 deletions(-) diff --git a/apps/admin/src/automations/components/automation-header.tsx b/apps/admin/src/automations/components/automation-header.tsx index ad16898da9c..5cd0b6338cf 100644 --- a/apps/admin/src/automations/components/automation-header.tsx +++ b/apps/admin/src/automations/components/automation-header.tsx @@ -1,5 +1,6 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; +import { useShade } from '@tryghost/shade/app'; import { Button, type ButtonProps, Skeleton } from '@tryghost/shade/components'; import { Link } from '@tryghost/admin-x-framework'; import { LucideIcon } from '@tryghost/shade/utils'; @@ -36,13 +37,14 @@ const AutomationHeader: React.FC = ({ onPublish, onTurnOff, }) => { + const { isAdmin7 } = useShade(); const name = automation?.name; const status = automation?.status; return (
- + + + )} diff --git a/apps/admin/src/members/members.tsx b/apps/admin/src/members/members.tsx index 9b11c110d2d..9fe2889de87 100644 --- a/apps/admin/src/members/members.tsx +++ b/apps/admin/src/members/members.tsx @@ -225,7 +225,6 @@ const MembersPage: React.FC = ({ - {isAdmin7 && headerFilters} {headerSearch} {!isAdmin7 && headerFilters} = ({ onImportComplete={() => { void refetch(); }} - /> + > + {isAdmin7 && headerFilters} + diff --git a/apps/admin/src/settings/advanced/history-modal.tsx b/apps/admin/src/settings/advanced/history-modal.tsx index 290a0f173a6..5e1a56965a2 100644 --- a/apps/admin/src/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/advanced/history-modal.tsx @@ -32,7 +32,8 @@ import { useParams } from '@tryghost/admin-x-framework'; import { useSettingsNavigation } from '@/settings/hooks/use-settings-navigation'; import { SettingsModal } from '@tryghost/shade/patterns'; import { type User } from '@tryghost/admin-x-framework/api/users'; -import { formatNumber } from '@tryghost/shade/utils'; +import { cn, formatNumber } from '@tryghost/shade/utils'; +import { useShade } from '@tryghost/shade/app'; import { keepPreviousData } from '@tanstack/react-query'; import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useFilterableApi } from '@tryghost/admin-x-framework/hooks'; @@ -96,6 +97,7 @@ const HistoryFilter: React.FC<{ toggleEventType: (event: string, included: boolean) => void; toggleResourceType: (resource: string, included: boolean) => void; }> = ({ userId, excludedEvents, excludedResources, toggleEventType, toggleResourceType }) => { + const { isAdmin7 } = useShade(); const { updateRoute } = useSettingsNavigation(); const usersApi = useFilterableApi({ path: '/users/', @@ -257,7 +259,11 @@ const HistoryFilter: React.FC<{
= ({ }; const buttons = custom ? ( - ) : disabled ? ( diff --git a/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx index 077c08e6b85..94b41472183 100644 --- a/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx @@ -137,10 +137,9 @@ const WebhooksTable: React.FC<{ integration: Integration }> = ({ integration }) + {isAdmin7 ? ( + + ) : ( + + )} {/* legacy SettingsModal overlay is z-[1000]; keep the portalled menu above it */} diff --git a/apps/admin/src/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx index f36d6a3f77a..67ed8165203 100644 --- a/apps/admin/src/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -244,11 +244,10 @@ const UserInviteActions: React.FC<{ invite: UserInvite }> = ({ invite }) => { return (
); diff --git a/apps/shade/src/components/patterns/page-header.mdx b/apps/shade/src/components/patterns/page-header.mdx index 8e956ea1de9..f866805c982 100644 --- a/apps/shade/src/components/patterns/page-header.mdx +++ b/apps/shade/src/components/patterns/page-header.mdx @@ -14,17 +14,18 @@ require a different overall layout. Place actions in reading and keyboard order: -1. **Labelled secondary actions:** icon + label, ordered by importance or frequency. -2. **Icon-only utilities:** ordered by importance or frequency, with **More actions - (•••) last** when present. -3. **An optional single primary action:** icon + label, at the right edge. +1. **Icon-only secondary actions:** ordered by importance or frequency, with the + overflow menu (•••) last when present. +2. **Labelled secondary actions:** icon + label, ordered by importance or frequency. +3. **A gap** separating secondary actions from the primary. +4. **An optional single primary action:** icon + label, at the right edge. -For example: Filter → Date range → Search → More actions → New member. Importance +For example: Search → More actions → Filter → Date range → gap → New member. Importance is a product decision within each group. The component preserves the supplied DOM order; it does not infer importance or sort children. A page without a meaningful primary action should omit it. -`PageHeader.ActionGroup` provides **4px** between secondary controls. +`PageHeader.ActionGroup` provides **8px** between secondary controls. `PageHeader.ActionGroup.Primary` provides **20px** separation from the preceding secondary control. Put at most one primary action in that slot. A primary alone has no leading separation. Avoid additional margins or gaps on individual actions. @@ -42,8 +43,8 @@ trigger must be constructed outside that slot, such as a modal trigger. ```tsx - Filter + Filter New member @@ -56,7 +57,9 @@ stroke width, ghost variant, tooltip styling, or page-specific CSS. Secondary controls use the ghost treatment and **2px icon strokes**. Dropdown text has the same medium weight as Filter; header dropdown triggers hide their down chevrons. -Use `PageHeader.SelectTrigger` inside Select for labelled dropdowns. Compose +Use `PageHeader.SelectTrigger` inside Select for labelled header dropdowns; it +provides the pill shape. Ordinary `SelectTrigger` form fields keep standard rounded +corners. Compose `PageHeader.Action` with `DropdownMenuTrigger asChild` for action menus. Refs, accessible state and event handlers reach the underlying control. Dropdown and popover triggers retain the inset pressed treatment while expanded. diff --git a/apps/shade/src/components/patterns/page-header.stories.tsx b/apps/shade/src/components/patterns/page-header.stories.tsx index df0ff53653c..567affe21b5 100644 --- a/apps/shade/src/components/patterns/page-header.stories.tsx +++ b/apps/shade/src/components/patterns/page-header.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; -import { ArrowUpDown, Calendar, Ellipsis, Plus, Save, Search } from 'lucide-react'; +import { ArrowUpDown, Ellipsis, Plus, Save, Search } from 'lucide-react'; import { PageHeader } from '@/components/patterns/page-header'; import { Filters, type Filter } from '@/components/patterns/filters'; import { @@ -13,6 +13,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/in import { Select, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Stack } from '@/components/primitives'; import ShadeApp from '@/shade-app'; +import { useShade } from '@/providers/shade-provider'; import { formatNumber } from '@/utils'; import { FilterBar } from '@/components/patterns/filter-bar'; @@ -25,7 +26,7 @@ const meta = { docs: { description: { component: - 'Page titles and action groups with shared ordering, spacing, control and tooltip conventions. See the attached Design contract for construction rules.', + 'Page titles and action groups with shared ordering, spacing, control and tooltip conventions. See the [Page header design contract](?path=/docs/patterns-page-header--design-contract) for construction rules and the Structure story below for a live example.', }, }, }, @@ -51,6 +52,7 @@ function MoreActions() { } function SearchAction({ initialQuery = '' }: { initialQuery?: string }) { + const { controlShape } = useShade(); const [query, setQuery] = React.useState(initialQuery); const [expanded, setExpanded] = React.useState(!!initialQuery); const restoreTriggerFocus = React.useRef(false); @@ -72,7 +74,7 @@ function SearchAction({ initialQuery = '' }: { initialQuery?: string }) { ); } return ( - + @@ -142,9 +144,9 @@ function MembersHeader({ - {filters.length === 0 && filterControls} + {filters.length === 0 && filterControls} {mobile && ( @@ -180,7 +182,7 @@ export const Structure: Story = { docs: { description: { story: - 'Canonical list header: labelled filters, search, more actions, then the separated primary. Open menus and hover or Tab to inspect their states.', + 'Canonical action order: icon-only secondary actions, icon + label secondary actions, a gap, then a single primary action. Open menus and hover or Tab to inspect their states.', }, }, }, @@ -222,25 +224,25 @@ export const Subview: Story = { docs: { description: { story: - 'Analytics needs no invented primary action. Labelled controls retain their usage order.', + 'A header can contain only secondary actions. Keep their usual order and omit the primary-action gap.', }, }, }, render: () => ( - Analytics + Page title - + + - Last 7 days - Last 30 days + Newest first + Oldest first @@ -321,13 +323,13 @@ export const DisabledActions: Story = { }, render: () => ( + + + Filter - - - diff --git a/apps/shade/src/components/patterns/page-header.tsx b/apps/shade/src/components/patterns/page-header.tsx index dc6b274d8fb..9de2e58f33c 100644 --- a/apps/shade/src/components/patterns/page-header.tsx +++ b/apps/shade/src/components/patterns/page-header.tsx @@ -139,14 +139,19 @@ const PageHeaderSelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { label: string } >(({ label, className, ...props }, ref) => { - const { isAdmin7 } = useShade(); + const { controlShape, isAdmin7 } = useShade(); return ( {isAdmin7 ? ( @@ -388,7 +393,7 @@ const PageHeaderActionGroup: PageHeaderActionGroupComponent = Object.assign( mobileMenuBreakpoint = DEFAULT_MOBILE_MENU_BREAKPOINT, }: PageHeaderActionGroupProps) { const { isAdmin7 } = useShade(); - const gap = isAdmin7 ? 'xs' : 'sm'; + const gap = 'sm'; const childNodes = React.Children.toArray(children); const desktopChildren: React.ReactNode[] = []; let mobileMenu: React.ReactElement | null = null; diff --git a/apps/shade/src/components/ui/button.stories.tsx b/apps/shade/src/components/ui/button.stories.tsx index a27db036773..a5dde946a1a 100644 --- a/apps/shade/src/components/ui/button.stories.tsx +++ b/apps/shade/src/components/ui/button.stories.tsx @@ -118,6 +118,27 @@ export const Destructive: Story = { }, }; +export const DestructiveGhost: Story = { + args: { + variant: 'destructive-ghost', + children: 'Delete item', + }, + render: (args) => ( + + + diff --git a/apps/shade/src/components/ui/button.tsx b/apps/shade/src/components/ui/button.tsx index 35130d09b94..6c4074d8255 100644 --- a/apps/shade/src/components/ui/button.tsx +++ b/apps/shade/src/components/ui/button.tsx @@ -19,6 +19,7 @@ const buttonVariants = cva( secondary: 'font-medium text-secondary-foreground', subtle: 'font-medium', ghost: 'font-medium hover:bg-accent hover:text-accent-foreground', + 'destructive-ghost': 'font-medium text-destructive hover:text-destructive', link: 'font-medium text-primary underline-offset-4 hover:underline', dropdown: 'border border-control-border bg-transparent hover:bg-button-hover hover:text-accent-foreground', @@ -43,6 +44,13 @@ const buttonVariants = cva( className: 'bg-secondary hover:bg-secondary/80', }, { isAdmin7: true, variant: 'secondary', className: 'bg-tab-active hover:bg-secondary' }, + { isAdmin7: false, variant: 'destructive-ghost', className: 'hover:bg-accent' }, + { + isAdmin7: true, + variant: 'destructive-ghost', + className: + 'hover:bg-destructive/10 enabled:active:bg-destructive/10 enabled:aria-expanded:bg-destructive/10', + }, { isAdmin7: false, variant: 'subtle', @@ -56,7 +64,7 @@ const buttonVariants = cva( }, { isAdmin7: true, - variant: ['secondary', 'ghost', 'subtle'], + variant: ['secondary', 'ghost', 'destructive-ghost', 'subtle'], className: 'enabled:active:shadow-control-pressed enabled:aria-expanded:shadow-control-pressed', }, @@ -79,7 +87,15 @@ const buttonVariants = cva( }, { isAdmin7: true, - variant: ['destructive', 'outline', 'secondary', 'ghost', 'dropdown', 'subtle'], + variant: [ + 'destructive', + 'outline', + 'secondary', + 'ghost', + 'destructive-ghost', + 'dropdown', + 'subtle', + ], size: ['default', 'sm', 'lg'], className: 'px-3', }, diff --git a/apps/shade/src/components/ui/copy-field.stories.tsx b/apps/shade/src/components/ui/copy-field.stories.tsx index b784a760fc4..5d52ba131bf 100644 --- a/apps/shade/src/components/ui/copy-field.stories.tsx +++ b/apps/shade/src/components/ui/copy-field.stories.tsx @@ -17,7 +17,7 @@ const meta = { docs: { description: { component: - 'A read-only value row with hover-revealed actions and intrinsic clipboard feedback.', + 'A read-only value row with hover-revealed actions and intrinsic clipboard feedback. Admin 7 uses 24px-tall pill buttons inside the field.', }, }, }, diff --git a/apps/shade/src/components/ui/copy-field.tsx b/apps/shade/src/components/ui/copy-field.tsx index 06d8f8ee0c0..a87a4af788b 100644 --- a/apps/shade/src/components/ui/copy-field.tsx +++ b/apps/shade/src/components/ui/copy-field.tsx @@ -4,6 +4,7 @@ import { Inline, Stack, Text } from '@/components/primitives'; import { Button, type ButtonProps } from '@/components/ui/button'; import { inputSurface } from '@/components/ui/input-surface'; import { cn } from '@/lib/utils'; +import { useShade } from '@/providers/shade-provider'; type CopyFieldContextValue = { copied: boolean; @@ -161,18 +162,23 @@ const CopyFieldValue = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ), + ({ className, ...props }, ref) => { + const { isAdmin7 } = useShade(); + + return ( + + ); + }, ); CopyFieldActions.displayName = 'CopyFieldActions'; diff --git a/apps/shade/src/components/ui/input-group.stories.tsx b/apps/shade/src/components/ui/input-group.stories.tsx index 5ba38bfcaa9..71e39c69c77 100644 --- a/apps/shade/src/components/ui/input-group.stories.tsx +++ b/apps/shade/src/components/ui/input-group.stories.tsx @@ -26,7 +26,7 @@ const meta = { docs: { description: { component: - 'Display additional information or actions alongside an input or textarea. Use addons to provide context, actions, or keyboard shortcuts that enhance the input experience.', + 'Display additional information or actions alongside an input or textarea. Input groups keep standard rounded corners; buttons inside inherit the action-button shape. Use addons to provide context, actions, or keyboard shortcuts that enhance the input experience.', }, }, }, @@ -81,7 +81,7 @@ export const Icon: Story = { export const GhostPill: Story = { render: () => (
- + @@ -101,7 +101,7 @@ export const GhostPill: Story = { export const SecondaryPill: Story = { render: () => (
- + @@ -244,7 +244,7 @@ export const Textarea: Story = { docs: { description: { story: - 'Textarea and block-aligned addons keep rounded corners in both designs; pill shapes apply to single-line groups.', + 'Input groups use standard rounded corners. Textareas and block-aligned addons retain those corners even when a pill shape is requested.', }, }, }, diff --git a/apps/shade/src/components/ui/input-group.tsx b/apps/shade/src/components/ui/input-group.tsx index 06051deb9d5..5dd577efe8e 100644 --- a/apps/shade/src/components/ui/input-group.tsx +++ b/apps/shade/src/components/ui/input-group.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { useShade } from '@/providers/shade-provider'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { inputSurfaceClasses } from '@/components/ui/input-surface'; @@ -42,7 +41,7 @@ const inputGroupVariants = cva( }, defaultVariants: { variant: 'default', - shape: 'pill', + shape: 'rounded', }, }, ); @@ -51,8 +50,7 @@ export interface InputGroupProps extends React.ComponentProps<'div'>, VariantProps {} function InputGroup({ className, variant, shape, ...props }: InputGroupProps) { - const { controlShape } = useShade(); - const resolvedShape = shape ?? controlShape; + const resolvedShape = shape ?? 'rounded'; return (
svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", - sm: 'h-8 gap-1.5 rounded-control px-2.5 has-[>svg]:px-2.5', - 'icon-xs': 'size-6 rounded-[calc(var(--input-group-radius)-5px)] p-0 has-[>svg]:p-0', + xs: "h-6 gap-1 px-2 has-[>svg]:px-2 data-[control-shape=rounded]:rounded-[calc(var(--input-group-radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5", + sm: 'h-8 gap-1.5 px-2.5 has-[>svg]:px-2.5 data-[control-shape=rounded]:rounded-control', + 'icon-xs': + 'size-6 p-0 has-[>svg]:p-0 data-[control-shape=rounded]:rounded-[calc(var(--input-group-radius)-5px)]', 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', }, }, diff --git a/apps/shade/src/components/ui/select.stories.tsx b/apps/shade/src/components/ui/select.stories.tsx index c7f93b02ef6..56ab1ceb957 100644 --- a/apps/shade/src/components/ui/select.stories.tsx +++ b/apps/shade/src/components/ui/select.stories.tsx @@ -18,7 +18,7 @@ const meta = { docs: { description: { component: - 'Dropdown selection component built on Radix UI. Provides accessible keyboard navigation, search, and customizable styling. Ghost and secondary pill triggers share the button inset shadow while pressed and while their list is open. The open appearance follows aria-expanded and resets on selection or dismissal.', + 'Dropdown selection component built on Radix UI. Form selects use standard rounded corners by default. Use PageHeader.SelectTrigger for pill-shaped header selectors. Provides accessible keyboard navigation, search, and customizable styling. Ghost and secondary pill triggers share the button inset shadow while pressed and while their list is open. The open appearance follows aria-expanded and resets on selection or dismissal.', }, }, }, @@ -61,7 +61,7 @@ export const Default: Story = { export const Pill: Story = { render: () => ( - + @@ -105,7 +105,7 @@ export const GhostPill: Story = { export const SecondaryPill: Story = { render: () => (